diff --git a/bin/gstack-inventory b/bin/gstack-inventory index 423f046de..a8f770afc 100755 --- a/bin/gstack-inventory +++ b/bin/gstack-inventory @@ -1,8 +1,9 @@ #!/usr/bin/env -S bun --no-env-file --config=/dev/null // gstack-inventory — every gstack part, its load per run and its evidence (ETHOS.md: 9. Earn Its Place). Logic: lib/inventory.ts. -import { writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { existsSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; -import { buildInventory, checkInventory, DOC_PATH, LEDGER_PATH, renderInventory } from "../lib/inventory"; +import { buildInventory, checkInventory, DOC_PATH, installReason, LEDGER_PATH, renderInventory } from "../lib/inventory"; const USAGE = `Usage: gstack-inventory [--root ] [--check | --json] gstack-inventory --help @@ -12,17 +13,24 @@ parts ranked by load per run, each part's evidence status, a "measure next" orde first), shared-part sizes, paid dispatches per run for /ship and /autoplan, and the key list. --check validates the ledger (every key has one row; each row has its status's fields), the quoted dispatch sources and the shared-part files, and writes nothing. --json prints the inventory. +The root defaults to the git repo you run it in; it never writes into a gstack install. Exit 0 when clean; 1 with one ": ()" line per problem; 2 on a bad argument.`; const args = process.argv.slice(2); if (args.includes("--help") || args.includes("-h")) { console.log(USAGE); process.exit(0); } const rootAt = args.indexOf("--root"); -const root = resolve(rootAt >= 0 ? args[rootAt + 1] ?? "" : join(import.meta.dir, "..")); +const rootArg = rootAt >= 0 ? args[rootAt + 1] : undefined; const unknown = args.find((a, i) => !["--check", "--json", "--root"].includes(a) && !(rootAt >= 0 && i === rootAt + 1)); -if (unknown !== undefined || (rootAt >= 0 && !args[rootAt + 1]) || (args.includes("--check") && args.includes("--json"))) { +if (unknown !== undefined || (rootAt >= 0 && (!rootArg || rootArg.startsWith("--"))) || (args.includes("--check") && args.includes("--json"))) { console.error(unknown !== undefined ? `gstack-inventory: unknown argument '${unknown}'; valid: --root --check --json --help` : USAGE); process.exit(2); } +const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 10_000 }); +const root = resolve(rootArg ?? (top.status === 0 ? top.stdout.trim() : process.cwd())); +if (rootArg === undefined && !existsSync(join(root, LEDGER_PATH))) { + console.error(`gstack-inventory: ${root} is not a gstack checkout (no ${LEDGER_PATH}); run it in your gstack checkout or pass --root `); + process.exit(2); +} const report = (problems: { key: string; code: string; message: string }[]) => { for (const p of problems) console.error(`gstack-inventory: ${p.key}: ${p.message} (${p.code})`); }; @@ -37,5 +45,10 @@ const inv = buildInventory(root); report(inv.problems); if (args.includes("--json")) { console.log(JSON.stringify(inv, null, 2)); process.exit(inv.problems.length ? 1 : 0); } if (inv.problems.length) { console.error(`gstack-inventory: ${DOC_PATH} not written; fix the problems above, then rerun`); process.exit(1); } +const installed = installReason(root); +if (installed) { + console.error(`gstack-inventory: refusing to write ${DOC_PATH} into ${root}: it is ${installed}, which gstack-upgrade owns; run it in your gstack checkout or pass --root `); + process.exit(2); +} writeFileSync(join(root, DOC_PATH), renderInventory(inv)); console.log(`INVENTORY: wrote ${DOC_PATH} keys=${inv.keys.length} commit=${inv.commit}`); diff --git a/lib/inventory.ts b/lib/inventory.ts index 3057128c9..9615e4841 100644 --- a/lib/inventory.ts +++ b/lib/inventory.ts @@ -9,9 +9,11 @@ */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { discoverTemplates } from '../scripts/discover-skills'; import { buildBill, contentClass, TOKEN_DIVISOR, TOKEN_DIVISORS } from './context-bill'; +import { CAPY_INSTALL_ROOT } from './doctor-components'; export const LEDGER_PATH = 'docs/inventory/evidence.json'; export const DOC_PATH = 'docs/inventory.md'; @@ -70,7 +72,7 @@ export type LedgerCode = | 'LEDGER_PARSE' | 'LEDGER_KEY_MISSING' | 'LEDGER_KEY_EXTRA' | 'LEDGER_STATUS_INVALID' | 'LEDGER_FIELD_UNKNOWN' | 'LEDGER_SOURCES_MISSING' | 'LEDGER_MODELS_NOT_FRONTIER' | 'LEDGER_PROMPT_NOT_PRESET' | 'LEDGER_SCOPE_MISSING' | 'LEDGER_UNMASKED' | 'LEDGER_R48_MISSING' | 'LEDGER_PLAN_MISSING' | 'LEDGER_PLAN_NONE_NEW' | 'LEDGER_PLAN_NOT_CONCRETE' - | 'LEDGER_REASON_MISSING' | 'LEDGER_OWNER_MISSING' | 'LEDGER_MEASURE_BY_MISSING' | 'LEDGER_PRIOR_INVALID' + | 'LEDGER_REASON_MISSING' | 'LEDGER_OWNER_MISSING' | 'LEDGER_MEASURE_BY_MISSING' | 'LEDGER_PRIOR_INVALID' | 'LEDGER_FIELD_INVALID' | 'INVENTORY_SOURCE_MISSING' | 'INVENTORY_PART_MISSING'; export interface LedgerProblem { key: string; code: LedgerCode; message: string } export interface LedgerEntry { status: Status; grandfathered?: boolean; plan?: string; prior?: Array<{ ref: string; why: string[]; note?: string }>; reason?: string; owner?: string; measure_by?: string; sources?: string[]; models?: string[]; prompt?: string; scope?: string; masked?: boolean; r48?: { verdict: string; ref: string }; seed?: string; aliases?: string[]; note?: string } @@ -94,8 +96,9 @@ export function validateEntry(key: string, entry: unknown): LedgerProblem[] { if (!isObj(entry)) { add('LEDGER_STATUS_INVALID', 'the row is not an object'); return problems; } for (const f of Object.keys(entry)) if (!FIELDS.includes(f)) add('LEDGER_FIELD_UNKNOWN', `unknown field ${f}; fields: ${FIELDS.join(', ')}`); const prior = entry.prior; - if (prior !== undefined && !(Array.isArray(prior) && prior.every(p => isObj(p) && str(p.ref) && Array.isArray(p.why) && p.why.length > 0 && p.why.every(w => typeof w === 'string' && w in PRIOR_WHY)))) + if (prior !== undefined && !(Array.isArray(prior) && prior.every(p => isObj(p) && str(p.ref) && Array.isArray(p.why) && p.why.length > 0 && p.why.every(w => typeof w === 'string' && Object.hasOwn(PRIOR_WHY, w))))) add('LEDGER_PRIOR_INVALID', `prior needs [{ref, why: [...]}] with why from: ${Object.keys(PRIOR_WHY).join(', ')}`); + if (entry.aliases !== undefined && !(Array.isArray(entry.aliases) && entry.aliases.length > 0 && entry.aliases.every(str))) add('LEDGER_FIELD_INVALID', 'aliases needs a list of names'); const status = entry.status; if (status === 'helps' || status === 'no-effect' || status === 'hurts') { if (!Array.isArray(entry.sources) || !entry.sources.length || !entry.sources.every(str)) add('LEDGER_SOURCES_MISSING', `${status} needs sources: one or more run ids or results files`); @@ -108,7 +111,7 @@ export function validateEntry(key: string, entry: unknown): LedgerProblem[] { const grandfathered = entry.grandfathered === true; if (!str(entry.plan)) add('LEDGER_PLAN_MISSING', 'untested needs plan: a reference, or "none" on a grandfathered row'); else if (entry.plan === 'none') { if (!grandfathered) add('LEDGER_PLAN_NONE_NEW', 'plan: none is only for grandfathered rows; a new part names its measurement plan'); } - else if (!grandfathered && !/tasks\.json$|prereg/i.test(entry.plan)) add('LEDGER_PLAN_NOT_CONCRETE', 'a new part\'s plan is a test/fixtures/skill-evals//tasks.json or an /eval-plan preregistration'); + else if (!grandfathered && !/^\S+\/tasks\.json$|^\S*prereg[^\s/]*\.md$/i.test(entry.plan)) add('LEDGER_PLAN_NOT_CONCRETE', 'a new part\'s plan is the path of a test/fixtures/skill-evals//tasks.json or of an /eval-plan preregistration (a .md file named for its prereg)'); if (!grandfathered) { if (!str(entry.reason)) add('LEDGER_REASON_MISSING', 'a new untested part states its reason'); if (!str(entry.owner)) add('LEDGER_OWNER_MISSING', 'a new untested part names its owner'); @@ -117,7 +120,7 @@ export function validateEntry(key: string, entry: unknown): LedgerProblem[] { } else if (status === 'safety') { if (!str(entry.reason)) add('LEDGER_REASON_MISSING', 'safety needs reason: what the guard protects'); } else add('LEDGER_STATUS_INVALID', `status must be one of ${STATUSES.join(', ')}`); - if (entry.r48 !== undefined && !validR48(entry.r48) && (status === 'untested' || status === 'safety')) add('LEDGER_R48_MISSING', `r48 needs {verdict: ${R48_VERDICTS.join('|')}, ref}`); + if (entry.r48 !== undefined && !validR48(entry.r48) && status !== 'no-effect' && status !== 'hurts') add('LEDGER_R48_MISSING', `r48 needs {verdict: ${R48_VERDICTS.join('|')}, ref}`); return problems; } @@ -140,6 +143,14 @@ export function loadLedger(root: string): { ledger: Ledger | null; problems: Led } } +/** Why root is a gstack install the CLI must never write into, or null: setup's skills/gstack folders and the Capy checkout (GSTACK_CAPY_ROOT, else CAPY_INSTALL_ROOT). */ +export function installReason(root: string, env: NodeJS.ProcessEnv = process.env, home: string = os.homedir()): string | null { + const real = (p: string) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } }; + if ([path.resolve(root), real(root)].some(p => /[\\/]skills[\\/]gstack$/.test(p))) return 'a skills/gstack install'; + const capy = (env.GSTACK_CAPY_ROOT || CAPY_INSTALL_ROOT).replace(/^\$HOME/, home); + return real(root) === real(capy) ? `the Capy install (${capy})` : null; +} + /** The skill keys (discoverTemplates, root skill as gstack, symlinked folders excluded) then SHARED_KEYS. */ export function inventoryKeys(root: string): string[] { const skills = discoverTemplates(root).map(t => path.dirname(t.tmpl)).map(d => (d === '.' ? ROOT_KEY : d)); @@ -157,9 +168,14 @@ export function evidenceRef(e: LedgerEntry): string { } /** gstack-skill-bill's EVIDENCE line for one skill folder in a gstack checkout. */ -export function skillEvidence(root: string, dir: string): { key: string; status: Status | 'missing'; ref: string; line: string } { +export function skillEvidence(root: string, dir: string): { key: string; status: Status | 'missing' | 'unreadable'; ref: string; line: string } { const key = path.relative(root, dir) || ROOT_KEY; - const entry = loadLedger(root).ledger?.parts?.[key]; + const loaded = loadLedger(root); + if (loaded.problems.length) { + const ref = `${loaded.problems[0]!.message}; fix the file, then run bin/gstack-inventory --check`; + return { key, status: 'unreadable', ref, line: `EVIDENCE: unreadable: ${ref}` }; + } + const entry = loaded.ledger?.parts?.[key]; if (!entry) { const ref = `no ${key} row in ${LEDGER_PATH}; add one (ETHOS.md: 9. Earn Its Place)`; return { key, status: 'missing', ref, line: `EVIDENCE: missing: ${ref}` }; diff --git a/test/inventory.test.ts b/test/inventory.test.ts index b254831b8..a26e5e64d 100644 --- a/test/inventory.test.ts +++ b/test/inventory.test.ts @@ -9,7 +9,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { discoverTemplates } from '../scripts/discover-skills'; -import { DISPATCHES, LEDGER_PATH, ROOT_KEY, SHARED_KEYS, SHARED_PARTS, inventoryKeys, keyOf, validateEntry, validateLedger } from '../lib/inventory'; +import { DISPATCHES, LEDGER_PATH, ROOT_KEY, SHARED_KEYS, SHARED_PARTS, installReason, inventoryKeys, keyOf, validateEntry, validateLedger } from '../lib/inventory'; const ROOT = path.resolve(import.meta.dir, '..'); const BIN = path.join(ROOT, 'bin', 'gstack-inventory'); @@ -89,6 +89,9 @@ describe('the per-status schema', () => { expect(codes('a', noReason)).toEqual(['LEDGER_REASON_MISSING']); expect(codes('a', { ...NEW_UNTESTED, plan: 'none' })).toEqual(['LEDGER_PLAN_NONE_NEW']); expect(codes('a', { ...NEW_UNTESTED, plan: 'we will test it later' })).toEqual(['LEDGER_PLAN_NOT_CONCRETE']); + expect(codes('a', { ...NEW_UNTESTED, plan: 'GEVA-2 registry row R56-11 (proposed, not yet preregistered)' })).toEqual(['LEDGER_PLAN_NOT_CONCRETE']); + expect(codes('a', { ...NEW_UNTESTED, plan: 'prereg later' })).toEqual(['LEDGER_PLAN_NOT_CONCRETE']); + expect(codes('a', { ...NEW_UNTESTED, plan: 'wiki/plans/2026-11-01-demo-prereg.md' })).toEqual([]); expect(codes('a', { status: 'untested', grandfathered: true })).toEqual(['LEDGER_PLAN_MISSING']); }); @@ -116,6 +119,15 @@ describe('the per-status schema', () => { expect(codes('a', { status: 'safety', reason: 'x', source: 'typo' })).toEqual(['LEDGER_FIELD_UNKNOWN']); expect(codes('a', { status: 'untested', grandfathered: true, plan: 'none', prior: [{ ref: 'run 7', why: ['vibes'] }] })).toEqual(['LEDGER_PRIOR_INVALID']); expect(codes('a', { status: 'untested', grandfathered: true, plan: 'none', prior: [{ why: ['stub'] }] })).toEqual(['LEDGER_PRIOR_INVALID']); + expect(codes('a', { status: 'untested', grandfathered: true, plan: 'none', prior: [{ ref: 'run 7', why: ['toString'] }] })).toEqual(['LEDGER_PRIOR_INVALID']); + }); + + test('a present r48 is checked on every status, and aliases is a list of names', () => { + expect(codes('a', { status: 'helps', ...VERDICT, r48: { verdict: 'DROP', ref: 'x' } })).toEqual(['LEDGER_R48_MISSING']); + expect(codes('a', { status: 'helps', ...VERDICT, r48: {} })).toEqual(['LEDGER_R48_MISSING']); + expect(codes('a', { status: 'safety', reason: 'x', aliases: 'other-name' })).toEqual(['LEDGER_FIELD_INVALID']); + expect(codes('a', { status: 'safety', reason: 'x', aliases: [] })).toEqual(['LEDGER_FIELD_INVALID']); + expect(codes('a', { status: 'safety', reason: 'x', aliases: ['other-name'] })).toEqual([]); }); }); @@ -183,6 +195,24 @@ describe('bin/gstack-inventory', () => { expect(r.stderr).toContain('(INVENTORY_SOURCE_MISSING)'); }); + test('a bad --root, a root outside a gstack checkout and a gstack install are refused, and nothing is written', () => { + const dir = fixtureTree(path.join(tmp, 'args'), grandfathered); + for (const args of [['--root', '--json'], ['--root', '--check'], ['--root']]) expect(run(args).status).toBe(2); + expect(fs.existsSync(path.join(dir, 'docs/inventory.md'))).toBe(false); + const outside = spawnSync(BIN, [], { cwd: tmp, encoding: 'utf8', timeout: 120_000 }); + expect(outside.status).toBe(2); + expect(outside.stderr).toContain('is not a gstack checkout'); + const installed = fixtureTree(path.join(tmp, 'home', '.claude', 'skills', 'gstack'), grandfathered); + const w = run(['--root', installed]); + expect(w.status).toBe(2); + expect(w.stderr).toContain('refusing to write docs/inventory.md'); + expect(fs.existsSync(path.join(installed, 'docs/inventory.md'))).toBe(false); + expect(run(['--root', installed, '--check']).status).toBe(0); + expect(installReason(dir, { GSTACK_CAPY_ROOT: dir })).toContain('the Capy install'); + expect(installReason(dir, {}, path.join(tmp, 'home'))).toBeNull(); + expect(installReason(path.join(tmp, 'capy-root'), {}, path.join(tmp, 'h'))).toBeNull(); + }); + test('the real ledger passes --check', () => { const r = run(['--check']); expect(r.status, r.stderr).toBe(0); diff --git a/test/skill-bill.test.ts b/test/skill-bill.test.ts index 3983cbc8c..3460e8bd6 100644 --- a/test/skill-bill.test.ts +++ b/test/skill-bill.test.ts @@ -137,6 +137,10 @@ describe('gstack-skill-bill EVIDENCE line (docs/inventory/evidence.json)', () => ledger({}); expect(bill(root, ['./demo']).stdout).toContain('EVIDENCE: missing: no demo row in docs/inventory/evidence.json'); expect(JSON.parse(bill(root, ['./demo', '--json']).stdout).evidence).toMatchObject({ key: 'demo', status: 'missing' }); + fs.writeFileSync(path.join(root, 'docs', 'inventory', 'evidence.json'), '{bad'); + const broken = bill(root, ['./demo']).stdout; + expect(broken).toContain('EVIDENCE: unreadable: cannot read docs/inventory/evidence.json'); + expect(broken).not.toContain('add one'); }); test('the real checkout: a guard prints its safety reason', () => {