@sabaiway/agent-workflow-kit 11.2.0 → 12.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "11.2.0",
6
+ "version": "12.1.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "11.2.0",
3
+ "version": "12.1.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -29,6 +29,7 @@ How to review:
29
29
  restatement of design intent back to the orchestrator.
30
30
  - **Respect what is already decided.** The prompt will name findings already folded and decisions
31
31
  already locked. Re-raising them is churn; checking whether the FIX is correct is real work.
32
+ - **When the artifact is a plan, walk around every check.** For every check bullet of each governing spec, name the invariant it proves and one state that walks around it; a named state is a finding with the replacement invariant, never an added case.
32
33
  - **Accuracy over volume.** A wrong finding costs the orchestrator more than a missed one — it
33
34
  spends a verification cycle and erodes trust in the whole list. If an angle turns up nothing, say
34
35
  so in one line and move on. Never pad.
@@ -8,7 +8,7 @@ The reader + consent-gated **writer** for the **host-level** bridge settings fil
8
8
 
9
9
  | key | bridge | values | note |
10
10
  |---|---|---|---|
11
- | `CODEX_SERVICE_TIER` | codex | `priority` | **SPEND KNOB** — the "Fast" tier: ~1.5× token speed at a **2.5× credit rate** on gpt-5.6-sol, quality-neutral (same model). Default unset ⇒ standard tier. |
11
+ | `CODEX_SERVICE_TIER` | codex | `priority` | **SPEND KNOB** — the "Fast" tier: "2x speed, **increased usage**" in the codex catalog's own words for gpt-6-astra (cache fetched 2026-09-05); it states no credit-rate figure; quality-neutral (same model). Default unset ⇒ standard tier. |
12
12
  | `CODEX_HARD_TIMEOUT` | codex | integer `1..86400` | hard wall-clock cap (seconds) via `timeout(1)`. |
13
13
  | `CODEX_REVIEW_MAX_TOTAL_BYTES` | codex | integer `1..100000000` | codex-review payload size above which the diff rides a temp file (never truncated). |
14
14
  | `AGY_HARD_TIMEOUT` | agy | duration `5m`/`30m`/`90s` (unit required, nonzero) | hard wall-clock cap via `timeout(1)`. |
@@ -25,7 +25,7 @@ The reader + consent-gated **writer** for the **host-level** bridge settings fil
25
25
 
26
26
  **Refusals (the guarded contract):** an **unknown key** or an **invalid / out-of-range value** → exit `2` (nothing read of the file, nothing written). A file that already carries **duplicate keys** → exit `1`, named and left **byte-untouched** (fix the duplicates by hand first — the writer never edits blindly around them). A **symlinked / non-regular / unreadable** settings file → exit `1` (refuses to write through it). `--apply` with `--dry-run`, a duplicate op for one key, or a malformed `--set` → usage exit `2`.
27
27
 
28
- **Spend consent (D4):** enabling `CODEX_SERVICE_TIER=priority` costs a **2.5× credit rate** — a per-host, consented act, never a default. The preview shows the caveat (from the manifest `effect`); confirm with the user before `--apply`. The tool also warns when an env var currently shadows the key you are writing (the env value wins for that session until unset).
28
+ **Spend consent (D4):** enabling `CODEX_SERVICE_TIER=priority` costs **increased usage** (the catalog states no credit-rate figure) — a per-host, consented act, never a default. The preview shows the caveat (from the manifest `effect`); confirm with the user before `--apply`. The tool also warns when an env var currently shadows the key you are writing (the env value wins for that session until unset).
29
29
 
30
30
  Output is **English/structured** — **localize it to the user's conversational language** when you narrate.
31
31
 
@@ -0,0 +1,30 @@
1
+ ### Mode: now
2
+
3
+ <!-- opt-in-capability: none — an inspection surface that configures nothing -->
4
+
5
+ Read-only. The single answer to where the work stands. It writes no file, cache, receipt, lock or git state; it never commits and never runs a subscription CLI.
6
+
7
+ Run `node ${CLAUDE_SKILL_DIR}/tools/now-cli.mjs --dir <project> --json`. For a direct terminal render, use `--format=plain|ansi|json`; `--json` is strict sugar for `--format=json`.
8
+
9
+ Render the returned envelope compactly in the user's conversational language. Never paste the JSON and never expose an internal field name. Preserve every fact, evidence label, annotation and withheld cause while translating only the prose around source-language commands, paths and identifiers.
10
+
11
+ The four blocks keep this order:
12
+
13
+ 1. **NOW** — every plan in flight, its derived phase, current and next step, then the tree fingerprint, clean state, review verdict lines, final run and red-proof currency. No plan and several plans are rendered states; never pick one silently.
14
+ 2. **STEPS** — every ledger row with one of `landed`, `in progress`, `pending` or `unjudged`. The rows are the steps; invent no numbering.
15
+ 3. **QUEUE** — every work-carrying row (live, parked, ambiguous) under the full line-numbered heading path it carries, with per-class counts covering every row of the bucket, terminal and record included, and names rather than ids. Only a path through `## Pending / backlog` calls its position priority; every other bucket is archive and calls it position.
16
+ 4. **CAMPAIGN** — source-size config state (`absent`, `authored`, `incomplete`, `minted`), caps, roots, recorded over-cap files and aggregate lines. An absent opt-in config is a rendered state.
17
+
18
+ Evidence alone sets a ledger status: the row's path and verb, then the path fact, changed set and exclusion fact. The plan's prose never sets a status. A checked box or terminal marker is only a claim; when evidence contradicts it, print the claim beside the evidence-derived status. Keep anomaly and current red-proof annotations beside the same status too. Every status line names its evidence source.
19
+
20
+ A block that cannot be derived is `WITHHELD` and prints its cause on one line. NOW has two halves, plans and tree, each independently a value or `WITHHELD`; NOW itself is `WITHHELD` only when both are. Render every other block normally.
21
+
22
+ Exit codes:
23
+
24
+ | Code | Meaning |
25
+ |------|---------|
26
+ | 0 | Anything rendered, including default states, contradictions and a WITHHELD block. |
27
+ | 1 | Nothing rendered: every block withheld, each cause printed on stderr; the usual cause is a `--dir` that is not a git work tree. |
28
+ | 2 | Usage: an unknown flag, an invalid or missing value, conflicting `--json` / `--format`, or `--help` combined with anything. |
29
+
30
+ **Invariants:** read-only · the plan's prose never sets status · evidence is shown beside every status · compact user-language render with no JSON or internal field names · `process.exitCode`, never `process.exit()`.
@@ -1,13 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // spec-schema.mjs — the ONE reader that DEFINES a well-formed spec document under docs/ai/specs/.
3
- //
4
- // Pure text in, verdict out: readSpecDocument(text, rel) never touches the filesystem and imports
5
- // nothing, so it seeds layout-free into any deployment. The navigator's collapse (check-docs-size.mjs)
6
- // and the future spec-check read through THIS module — "malformed" has one definition, never two.
7
- // `rel` is the path INSIDE docs/ai/specs/ (the store root navigator is `index.md`).
8
- //
9
- // SPEC_SCHEMA carries the frozen values; the engine canon (references/specs.md) is pinned against it.
10
- // A refusal names exactly one rule id per defect, so a fixture corpus can be read rule by rule.
2
+ // Canon: the methodology engine's references/specs.md (frozen schema, refusals table).
11
3
 
12
4
  export const SPEC_SCHEMA = Object.freeze({
13
5
  storePrefix: 'docs/ai/specs/',
@@ -34,17 +26,25 @@ export const SPEC_SCHEMA = Object.freeze({
34
26
  rootOwnedKeys: Object.freeze(['status', 'revision']),
35
27
  rootOwnedSections: Object.freeze(['## Scenarios', '## Out of scope', '## Module', '## Parts']),
36
28
  scenarioGrammar: '- S<N> <name> :: <repo-relative test path> :: spec:<slug>/S<N> | - S<N> <name> :: unbound',
29
+ openList: Object.freeze({
30
+ checkVerbs: Object.freeze(['refuse', 'refusal', 'REFUSE', 'REFUSAL']),
31
+ enumerationForms: Object.freeze([Object.freeze([',', ' or ']), Object.freeze([' · '])]),
32
+ closureTokens: Object.freeze([
33
+ 'only if', 'only when', 'the only', 'everything else', 'anything else', 'nothing else',
34
+ 'every other', 'any other', 'otherwise', 'exactly', 'one of', 'closed',
35
+ ]),
36
+ emphaticToken: 'ONLY',
37
+ }),
37
38
  rules: Object.freeze([
38
39
  'frontmatter', 'frontmatter-key', 'substrate-key', 'type', 'kind', 'maxlines', 'status', 'revision',
39
40
  'root-owns', 'slug', 'kind-path', 'root-uplink', 'title', 'section-missing', 'section-order',
40
- 'section-forbidden', 'fence', 'children-link', 'children-duplicate', 'fan-out', 'scenario-line',
41
+ 'section-forbidden', 'fence', 'open-list', 'children-link', 'children-duplicate', 'fan-out', 'scenario-line',
41
42
  'scenario-number', 'scenario-marker', 'scenario-path', 'scenarios-empty', 'out-of-scope', 'module-line', 'module-empty',
42
43
  'module-traversal', 'module-absolute', 'module-backslash', 'module-glob', 'module-mix', 'parts',
43
44
  ]),
44
45
  });
45
46
 
46
- // The descriptor check-docs-size.mjs joins to its ADR group: rows under `prefix` whose reader verdict
47
- // is clean collapse into ONE navigator row linking `navPath`; a row with reader errors stays visible.
47
+ // Consumer contract: scripts/check-docs-size.mjs.
48
48
  export const SPECS_COLLAPSE = Object.freeze({
49
49
  prefix: SPEC_SCHEMA.storePrefix,
50
50
  navPath: `${SPEC_SCHEMA.storePrefix}${SPEC_SCHEMA.navigatorFile}`,
@@ -72,8 +72,6 @@ const FORBIDDEN_SECTIONS = Object.freeze({
72
72
 
73
73
  const KNOWN_KEYS = Object.freeze([...SPEC_SCHEMA.substrateKeys, 'kind', ...SPEC_SCHEMA.rootOwnedKeys]);
74
74
 
75
- // The frontmatter is a closed key set: an unknown key, a repeated key or a line that is not
76
- // `key: value` is a defect (`frontmatter-key`), never silently dropped or last-one-wins.
77
75
  const parseFrontmatter = (text) => {
78
76
  const match = text.match(FRONTMATTER_RE);
79
77
  if (!match) return null;
@@ -91,10 +89,6 @@ const parseFrontmatter = (text) => {
91
89
 
92
90
  const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
93
91
 
94
- // The body as { title, sections: [{ heading, lines }], preamble, fenced } — the title is the FIRST
95
- // `# ` line, every `## ` line opens a section, the lines before the first section are the preamble.
96
- // The reader parses NO markdown code: a fence line is recorded (the `fence` refusal) rather than
97
- // modelled, so a spec carries no code sample and no line is ever ambiguous between code and structure.
98
92
  const parseBody = (body) => {
99
93
  const sections = [];
100
94
  const preamble = [];
@@ -116,8 +110,6 @@ const parseBody = (body) => {
116
110
  return { title: state.title, sections, preamble, fenced, sectionBeforeTitle: state.sectionBeforeTitle };
117
111
  };
118
112
 
119
- // Lexical classification of a repo-relative path field — the same vocabulary for module roots and
120
- // scenario bindings. Realpath/symlink containment needs the filesystem and is the checker's duty.
121
113
  export const classifyPath = (path) => {
122
114
  if (path.trim() === '') return 'empty';
123
115
  if (path.includes('\\')) return 'backslash';
@@ -139,8 +131,47 @@ const MODULE_PATH_RULES = Object.freeze({
139
131
  const isBullet = (line) => line === '-' || line.startsWith('- ');
140
132
  const bulletsOf = (lines) => lines.filter(isBullet).map((line) => line.slice(2));
141
133
  const contentOf = (lines) => lines.filter((line) => line.trim() !== '');
134
+ const isAtxHeading = (line) => /^ {0,3}#{1,6}(?:[ \t]|$)/.test(line);
135
+ const topLevelBulletsOf = (lines) => lines.reduce((state, raw) => {
136
+ const line = raw.replace(/\s+$/, '');
137
+ if (isBullet(line)) {
138
+ state.current = [line === '-' ? '' : line.slice(2)];
139
+ state.bullets.push(state.current);
140
+ } else if (line.trim() === '' || isAtxHeading(line)) state.current = null;
141
+ else if (state.current !== null) state.current.push(line.trim());
142
+ return state;
143
+ }, { bullets: [], current: null }).bullets.map((parts) => parts.join(' ').replace(/\s+/g, ' ').trim());
144
+
145
+ const CLAUSE_BREAK = String.fromCharCode(0);
146
+ const MASKED_DOT = String.fromCharCode(1);
147
+ const MASKED_SEMICOLON = String.fromCharCode(2);
148
+ const maskClauseSpans = (text) => [...text].reduce((state, character) => {
149
+ if (character === '`') state.inSpan = !state.inSpan;
150
+ state.output.push(state.inSpan && character === '.' ? MASKED_DOT : state.inSpan && character === ';' ? MASKED_SEMICOLON : character);
151
+ return state;
152
+ }, { inSpan: false, output: [] }).output.join('');
153
+ const clausesOf = (bullet) => maskClauseSpans(bullet).replace(/([.;][*`\)\]]*) /g, `$1${CLAUSE_BREAK}`).split(CLAUSE_BREAK)
154
+ .map((clause) => clause.split(MASKED_DOT).join('.').split(MASKED_SEMICOLON).join(';').trim()).filter(Boolean);
155
+ const containsInOrder = (text, tokens) => tokens.reduce((position, token) => {
156
+ const found = position === -1 ? -1 : text.indexOf(token, position);
157
+ return found === -1 ? -1 : found + token.length;
158
+ }, 0) !== -1;
159
+ const hasBoundedToken = (clause, token, flags) => new RegExp(`(^|[^A-Za-z0-9_-])${token}([^A-Za-z0-9_-]|$)`, flags).test(clause);
160
+ const checkOpenLists = (parsed, kind, errors) => {
161
+ const groups = kind === 'spec' ? parsed.sections.filter((section) => section.heading === '## Contract').map((section) => section.lines)
162
+ : kind === 'part' ? [parsed.preamble, ...parsed.sections.map((section) => section.lines)] : [];
163
+ for (const clause of groups.flatMap(topLevelBulletsOf).flatMap(clausesOf)) {
164
+ const hasVerb = SPEC_SCHEMA.openList.checkVerbs.some((verb) => clause.includes(verb));
165
+ const hasEnumeration = SPEC_SCHEMA.openList.enumerationForms.some((form) => containsInOrder(clause, form));
166
+ const hasClosure = SPEC_SCHEMA.openList.closureTokens.some((token) => hasBoundedToken(clause, token, 'i'))
167
+ || hasBoundedToken(clause, SPEC_SCHEMA.openList.emphaticToken, '');
168
+ if (hasVerb && hasEnumeration && !hasClosure) {
169
+ const prefix = clause.split(/\s+/).slice(0, 6).join(' ');
170
+ errors.push({ rule: 'open-list', message: `the clause "${prefix}…" is an open list — add a closing form to it such as "only if …; everything else …"` });
171
+ }
172
+ }
173
+ };
142
174
 
143
- // The slug a document owns: the file stem for a flat file, the folder name for an index.md.
144
175
  const describeRel = (rel) => {
145
176
  const segments = rel.split('/');
146
177
  const file = segments[segments.length - 1];
@@ -314,11 +345,7 @@ const checkParts = (parsed, at, errors) => {
314
345
  if (duplicate !== undefined) errors.push({ rule: 'parts', message: `part "${duplicate}" is listed twice` });
315
346
  };
316
347
 
317
- // The structure verdict (additive, slice 2a): the DETERMINISTIC extraction of what parsed, per
318
- // section, targets VERBATIM as written (`./x.md` and `./x/index.md` stay distinct strings). The
319
- // grammar is per line for scenarios/children/parts — a malformed line is simply absent — while the
320
- // module is a conjunction (ONE `dir/` root or an all-file list): prose, a refused path, a mix or
321
- // `*(empty)*` extracts null. `## Links` is free prose and is never extracted.
348
+ // Structure verdict: the same canon, "The frozen schema".
322
349
  const scenarioEntry = (line) => {
323
350
  const scenario = parseScenario(line);
324
351
  if (scenario === null) return null;
@@ -345,10 +372,6 @@ const extractStructure = (parsed) => ({
345
372
  module: extractModule(parsed),
346
373
  });
347
374
 
348
- // The verdict: { kind, status, revision, structure, errors: [{ rule, message }], warnings: [{ rule,
349
- // message }] }. Errors are collected past the first defect wherever later checks stay meaningful; a
350
- // missing or defective frontmatter and an unknown kind each end the read (structure stays null),
351
- // because no shape can be judged without them.
352
375
  export const readSpecDocument = (text, rel) => {
353
376
  const errors = [];
354
377
  const warnings = [];
@@ -372,6 +395,7 @@ export const readSpecDocument = (text, rel) => {
372
395
  const at = checkPath(rel, kind, errors);
373
396
  const parsed = parseBody(body);
374
397
  if (parsed.fenced.length > 0) errors.push({ rule: 'fence', message: `"${parsed.fenced[0]}" — a spec document carries no code fence` });
398
+ checkOpenLists(parsed, kind, errors);
375
399
  checkSections(parsed, kind, errors);
376
400
  if (kind === 'index') {
377
401
  checkChildren(parsed, errors);
@@ -32,6 +32,8 @@ const refuses = (text, rel, rule) => {
32
32
  const verdict = readSpecDocument(text, rel);
33
33
  expect(rulesOf(verdict)).toEqual([rule]);
34
34
  };
35
+ const withContract = (contract) => specDoc().replace('Accepts a credential pair.', contract);
36
+ const openListErrors = (contract, rel = 'login.md') => readSpecDocument(withContract(contract), rel).errors;
35
37
 
36
38
  describe('readSpecDocument — accept', () => {
37
39
  it('a flat draft spec reads clean with kind/status/revision', () => {
@@ -269,6 +271,69 @@ describe('readSpecDocument — the structure verdict (additive, slice 2a)', () =
269
271
  });
270
272
  });
271
273
 
274
+ describe('readSpecDocument — open-list clauses', () => {
275
+ const refusal = 'the clause "an anchor path reported modified, untracked…" is an open list — add a closing form to it such as "only if …; everything else …"';
276
+ const open = '- an anchor path reported modified, untracked or ignored is the named refusal `anchor-dirty`';
277
+ const closed = '- an anchor path is accepted only if `ls-files -v` lists it with tag `H` and `status` is silent — everything else, a path reported modified, untracked or ignored, is the named refusal `anchor-dirty`';
278
+
279
+ it('refuses the measured clause with the exact message; verb alone, form alone and an in-clause closure pass', () => {
280
+ expect(openListErrors(open)).toEqual([{ rule: 'open-list', message: refusal }]);
281
+ for (const text of [closed, '- refuse one case', '- alpha, beta or gamma']) expect(openListErrors(text)).toEqual([]);
282
+ });
283
+
284
+ it('joins continuation lines, collapses whitespace and keeps an indented bullet in its top-level bullet', () => {
285
+ for (const text of ['- refuse alpha,\n beta or ignored', '- refuse alpha,\n - beta or ignored']) expect(rulesOf(readSpecDocument(withContract(text), 'login.md'))).toEqual(['open-list']);
286
+ });
287
+
288
+ it('ends a bullet at a blank, the next top-level bullet or every ATX heading; Contract prose is ignored', () => {
289
+ for (const tail of ['\n\nEverything else closes', '\n- Everything else closes', ...Array.from({ length: 6 }, (_, i) => `\n${'#'.repeat(i + 1)} Everything else closes`)]) {
290
+ expect(rulesOf(readSpecDocument(withContract('- refuse alpha, beta or gamma' + tail), 'login.md'))).toEqual(['open-list']);
291
+ }
292
+ expect(openListErrors('refuse alpha, beta or gamma')).toEqual([]);
293
+ });
294
+
295
+ it('ends a bullet at indented and tab-separated ATX headings', () => {
296
+ for (const tail of ['\n ## Everything else closes', '\n#\tEverything else closes']) expect(rulesOf(readSpecDocument(withContract('- refuse alpha, beta or gamma' + tail), 'login.md'))).toEqual(['open-list']);
297
+ });
298
+
299
+ it('starts a new empty top-level bullet before following prose', () => {
300
+ for (const tail of ['\n- \nEverything else closes', '\n-\nonly if closes']) expect(rulesOf(readSpecDocument(withContract('- refuse alpha, beta or gamma' + tail), 'login.md'))).toEqual(['open-list']);
301
+ });
302
+
303
+ it('judges an open list in every Contract section', () => {
304
+ const duplicate = specDoc().replace('## Scenarios', '## Contract\n\n- refuse alpha, beta or gamma\n\n## Scenarios');
305
+ expect(rulesOf(readSpecDocument(duplicate, 'login.md')).includes('open-list')).toBe(true);
306
+ });
307
+
308
+ it('splits clauses at punctuation plus markdown tails, but not inside backtick spans', () => {
309
+ for (const tail of ['.** ', '.` ', '.) ', '.] ', '; ']) expect(rulesOf(readSpecDocument(withContract(`- refuse alpha, beta or gamma${tail}Everything else closes`), 'login.md'))).toEqual(['open-list']);
310
+ for (const text of ['- refuse `alpha. beta`, gamma or delta', '- refuse `alpha; beta`, gamma or delta', '- refuse `alpha. beta, gamma or delta']) expect(rulesOf(readSpecDocument(withContract(text), 'login.md'))).toEqual(['open-list']);
311
+ expect(openListErrors('- refuse alpha``. beta, gamma or delta')).toEqual([]);
312
+ });
313
+
314
+ it('keeps span content in judgement, and a closure in a neighbouring clause cannot lift a refusal', () => {
315
+ expect(rulesOf(readSpecDocument(withContract('- `refuse` alpha, beta or gamma'), 'login.md'))).toEqual(['open-list']);
316
+ expect(openListErrors('- refuse alpha, beta or gamma with `everything else`')).toEqual([]);
317
+ expect(rulesOf(readSpecDocument(withContract('- refuse alpha, beta or gamma. Everything else closes'), 'login.md'))).toEqual(['open-list']);
318
+ });
319
+
320
+ it('matches verbs, forms and closure tokens with the frozen case and boundary rules', () => {
321
+ for (const verb of ['refuse', 'refuses', 'refused', 'refusal', 'refusals', 'REFUSE', 'REFUSAL']) expect(rulesOf(readSpecDocument(withContract(`- ${verb} alpha, beta or gamma`), 'login.md'))).toEqual(['open-list']);
322
+ for (const text of ['- Refuse alpha, beta or gamma', '- refuse alpha, beta and gamma']) expect(openListErrors(text)).toEqual([]);
323
+ expect(rulesOf(readSpecDocument(withContract('- refuse alpha · beta'), 'login.md'))).toEqual(['open-list']);
324
+ for (const token of SPEC_SCHEMA.openList.closureTokens) expect(openListErrors(`- ${token.toUpperCase()}: refuse alpha, beta or gamma`)).toEqual([]);
325
+ for (const text of ['- refuse alpha, beta or gamma — none of these', '- refuse alpha, beta or gamma — closed-key', '- refuse alpha, beta or gamma — READ-ONLY', '- refuse alpha, beta or gamma — if only this', '- refuse alpha, beta or gamma — not only this']) expect(rulesOf(readSpecDocument(withContract(text), 'login.md'))).toEqual(['open-list']);
326
+ expect(openListErrors('- refuse alpha, beta or gamma — ONLY this')).toEqual([]);
327
+ });
328
+
329
+ it('judges every part bullet, never an index, and freezes every schema list and inner pair', () => {
330
+ expect(rulesOf(readSpecDocument(partDoc({ extra: `\n${open}\n` }), 'auth/login/sessions.md'))).toEqual(['open-list']);
331
+ expect(readSpecDocument(indexDoc({ children: [], preamble: `\n${open}\n` }), 'auth/index.md').errors).toEqual([]);
332
+ expect(SPEC_SCHEMA.openList).toEqual({ checkVerbs: ['refuse', 'refusal', 'REFUSE', 'REFUSAL'], enumerationForms: [[',', ' or '], [' · ']], closureTokens: ['only if', 'only when', 'the only', 'everything else', 'anything else', 'nothing else', 'every other', 'any other', 'otherwise', 'exactly', 'one of', 'closed'], emphaticToken: 'ONLY' });
333
+ expect([SPEC_SCHEMA.openList, SPEC_SCHEMA.openList.checkVerbs, SPEC_SCHEMA.openList.enumerationForms, ...SPEC_SCHEMA.openList.enumerationForms, SPEC_SCHEMA.openList.closureTokens].every(Object.isFrozen)).toBe(true);
334
+ });
335
+ });
336
+
272
337
  describe('classifyPath + the frozen constants', () => {
273
338
  it('classifies the lexical path forms', () => {
274
339
  expect(['src/', 'src/a.mjs', '../x', '/x', 'C:/x', 'a\\b', 'src/*.mjs'].map(classifyPath)).toEqual([
@@ -273,7 +273,7 @@ export const main = (argv = [], ctx = {}) => {
273
273
  const body = joinLines(newLines);
274
274
 
275
275
  // The machine surface carries the SAME spend/credit-rate caveats the human preview prints (D4) — a
276
- // --json consumer must not miss the 2.5x-credit consent warning the human render shows.
276
+ // --json consumer must not miss the spend consent warning the human render shows.
277
277
  const jsonBody = (wrote) => ({ path, wrote, noop, changes: changes.map((c) => ({ key: c.key, kind: c.kind, before: c.before, after: c.after })), envShadows, caveats });
278
278
 
279
279
  if (!apply || noop) {
@@ -84,6 +84,13 @@ const CATALOG = [
84
84
  kind: READ_ONLY,
85
85
  oneLine: 'Show what is installed and at what version, your settings and backends, and what is deployed in this project.',
86
86
  },
87
+ {
88
+ key: 'now',
89
+ invocation: invocationOf('now'),
90
+ group: 'Inspect',
91
+ kind: READ_ONLY,
92
+ oneLine: 'Show where work stands: NOW answers the plan and tree, STEPS the evidence-derived ledger state, QUEUE its named positions, and CAMPAIGN its size-practice figures; the plan’s own text never sets a status.',
93
+ },
87
94
  {
88
95
  key: 'backends',
89
96
  invocation: invocationOf('backends'),
@@ -18,7 +18,11 @@ const NOTES_HEADING = '## Notes';
18
18
  const TABLE_HEADER = '| # | Claim | Evidence | Verdict | Disposition |';
19
19
  const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu;
20
20
  const ANCHOR = /^`([^`,\s]+):([1-9][0-9]*)(?:-([1-9][0-9]*))?`$/u;
21
- const ROW_ID = '[A-Z0-9][A-Z0-9-]*';
21
+ // The row id a checked record hands over, and the ONE home of that grammar: the queue's name
22
+ // judge reads it back for the EXPLICITLY marked form (`— id <ID>`), so every id this writer may
23
+ // legally emit round-trips through the judge — including `FOO`, `TASK-123` and `1-TASK`, which
24
+ // the judge's own heuristic deliberately does not read unmarked.
25
+ export const ROW_ID = '[A-Z0-9][A-Z0-9-]*';
22
26
  const QUEUE = new RegExp(`^queue (${ROW_ID})$`, 'u');
23
27
  const ALREADY_QUEUED = new RegExp(`^already-queued (${ROW_ID})$`, 'u');
24
28
  const REASONED_DISPOSITION = /^(?:declined|folded): (.+)$/u;
@@ -227,7 +231,7 @@ export const renderRows = (record, { date, recordPath }) => {
227
231
  return ids.map((id) => {
228
232
  const claims = record.claims.filter((claim) => QUEUE.exec(claim.disposition)?.[1] === id);
229
233
  return [
230
- `- [ ] ${id}: ${claims[0].claim} (${date})`,
234
+ `- [ ] ${claims[0].claim} — id ${id} (${date})`,
231
235
  ...claims.map((claim) => ` - ${claim.claim}; verdict ${claim.verdict}; evidence ${claim.anchors.map(rendersAnchor).join(', ')}`),
232
236
  ` - record: ${recordPath}`,
233
237
  ` - head: ${record.head}`,
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ // The CLI half of `now`: argv, the io seam and the pipeline. The rules live in now-status.mjs, the
3
+ // facts in now-facts.mjs, the phrases in now-render.mjs.
4
+
5
+ import { resolve } from 'node:path';
6
+ import { fail } from '../references/scripts/markdown-blocks.mjs';
7
+ import { isDirectRun } from './direct-run.mjs';
8
+ import { FORMATS, detectSurface } from './surface.mjs';
9
+ import { gatherNowFacts } from './now-facts.mjs';
10
+ import { isWithheld } from './now-status.mjs';
11
+ import { renderNow, toNowViewModel } from './now-render.mjs';
12
+
13
+ const BLOCKS = Object.freeze(['now', 'steps', 'queue', 'campaign']);
14
+ const SCHEMA = 1;
15
+ const FORMAT_FLAG = '--format=';
16
+ const HELP = `now — read-only: where the work stands, in four blocks.
17
+
18
+ Usage:
19
+ node now-cli.mjs [--dir <project>] [--format=<${FORMATS.join('|')}>] [--json]
20
+
21
+ --dir the project to read (default: the current directory).
22
+ --format the output surface; --json is strict sugar for --format=json, and naming both with
23
+ different values is usage.
24
+ --help answered only when it is the whole invocation.
25
+
26
+ Blocks: NOW (the plans in flight, the phase, the current and the next step, the tree), STEPS (the
27
+ ledger rows, one status each), QUEUE (the backlog folded under each row's heading path), CAMPAIGN
28
+ (the size practice). A block that cannot be derived is WITHHELD with its cause on one line.
29
+
30
+ A status is a function of the row's (path, verb) pair and the evidence at that path; the row's prose
31
+ is printed as a claim beside a status it never moves.
32
+
33
+ Exit codes: 0 anything rendered, a withheld block included; 1 nothing rendered at all (a --dir that
34
+ is not a git work tree); 2 usage.
35
+
36
+ Reads only: no store, no receipt, no cache, no lock.`;
37
+
38
+ const valueAt = (argv, index, flag) => {
39
+ const value = argv[index + 1];
40
+ if (value === undefined || value === '' || value.startsWith('-')) throw fail(2, `${flag} takes a value`);
41
+ return value;
42
+ };
43
+
44
+ export const parseArgv = (argv) => {
45
+ if (argv.includes('--help') || argv.includes('-h')) {
46
+ if (argv.length !== 1) throw fail(2, '--help is answered only when it is the whole invocation');
47
+ return { help: true, dir: null, formats: [] };
48
+ }
49
+ const parsed = argv.reduce((state, arg, index) => {
50
+ if (state.skip) return { ...state, skip: false };
51
+ if (arg === '--dir') return { ...state, dir: valueAt(argv, index, arg), skip: true };
52
+ if (arg === '--format') return { ...state, formats: [...state.formats, valueAt(argv, index, arg)], skip: true };
53
+ if (arg.startsWith(FORMAT_FLAG)) return { ...state, formats: [...state.formats, arg.slice(FORMAT_FLAG.length)] };
54
+ if (arg === '--json') return { ...state, formats: [...state.formats, 'json'], json: true };
55
+ throw fail(2, `unknown argument "${arg}" — run with --help`);
56
+ }, { help: false, dir: null, formats: [], json: false, skip: false });
57
+ if (parsed.json && parsed.formats.some((format) => format !== 'json')) {
58
+ throw fail(2, '--json is strict sugar for --format=json — naming both with different values is usage');
59
+ }
60
+ return parsed;
61
+ };
62
+
63
+ const resolveSurface = (formats, env, io) => detectSurface({
64
+ argv: formats.map((format) => `${FORMAT_FLAG}${format}`),
65
+ env,
66
+ isTTY: io.isTTY ?? Boolean(process.stdout.isTTY),
67
+ columns: io.columns ?? process.stdout.columns,
68
+ platform: io.platform ?? process.platform,
69
+ });
70
+
71
+ const toEnvelope = (dir, vm) => ({ schema: SCHEMA, command: 'now', dir, ...vm });
72
+
73
+ export const main = (argv, io = {}) => {
74
+ const log = io.log ?? console.log;
75
+ const error = io.error ?? console.error;
76
+ const env = io.env ?? process.env;
77
+ const start = (() => {
78
+ try {
79
+ const options = parseArgv(argv);
80
+ return options.help ? { help: true } : { options, surface: resolveSurface(options.formats, env, io) };
81
+ } catch (err) {
82
+ error(err?.message ?? String(err));
83
+ return { code: err?.exitCode ?? 2 };
84
+ }
85
+ })();
86
+ if (start.code !== undefined) return start.code;
87
+ if (start.help) {
88
+ log(HELP);
89
+ return 0;
90
+ }
91
+ const cwd = resolve(io.cwd ?? process.cwd(), start.options.dir ?? '.');
92
+ // The composed readers spawn git under process.env alone, so the flag must reach them there too.
93
+ process.env.GIT_OPTIONAL_LOCKS = '0';
94
+ const vm = toNowViewModel(gatherNowFacts({ cwd, env: { ...env, GIT_OPTIONAL_LOCKS: '0' } }));
95
+ const blocks = BLOCKS.map((name) => vm[name]);
96
+ if (blocks.every(isWithheld)) {
97
+ for (const cause of new Set(blocks.map(({ withheld }) => withheld))) error(cause);
98
+ return 1;
99
+ }
100
+ log(start.surface.mode === 'json' ? JSON.stringify(toEnvelope(cwd, vm), null, 2) : renderNow(vm, start.surface));
101
+ return 0;
102
+ };
103
+
104
+ if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,67 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import { buildSummaryState, isTreeClean } from './core-evidence.mjs';
4
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
5
+ import { resolveGitLocation } from './git-env.mjs';
6
+ import { auditQueue } from './queue-audit.mjs';
7
+ import { loadSourceSizeConfig, practiceFacts } from './source-size-config.mjs';
8
+ import { gatherPlanFacts, readSummaryFacts } from './now-plan-facts.mjs';
9
+ import { isWithheld } from './now-status.mjs';
10
+
11
+ const QUEUE_REL = 'docs/plans/queue.md';
12
+ const BLOCKS = Object.freeze(['plans', 'tree', 'queue', 'campaign']);
13
+
14
+ const attempt = (label, read) => {
15
+ try {
16
+ return read();
17
+ } catch (error) {
18
+ return { withheld: `${label}: ${error?.message ?? error}` };
19
+ }
20
+ };
21
+
22
+ const readQueueFacts = (cwd, deps) => {
23
+ const read = readRegularFileNoFollow(join(cwd, QUEUE_REL), deps.io ?? {});
24
+ if (read.outcome !== 'ok') throw new Error(`${QUEUE_REL} is not a readable regular file (${read.className ?? read.code ?? read.outcome})`);
25
+ const { rows, counts, total } = auditQueue(read.content, { label: QUEUE_REL });
26
+ return { path: QUEUE_REL, rows, counts, total };
27
+ };
28
+
29
+ // The practice is opt-in: an absent declaration is a rendered STATE, only an unreadable one is withheld.
30
+ const readCampaignFacts = (cwd, deps) => {
31
+ const loaded = loadSourceSizeConfig(cwd, deps);
32
+ return loaded.state === 'absent' ? { state: loaded.state } : { state: loaded.state, ...practiceFacts(loaded.config) };
33
+ };
34
+
35
+ // A store the summary cannot trust never attests: the tree is withheld with the store's own cause.
36
+ const describeUntrustedStore = (summary) => {
37
+ if (summary.evidenceUnavailable) return `the core-evidence store is malformed or unreadable (${summary.storeReadError ?? `${summary.storeMalformed} malformed line(s)`})`;
38
+ if (summary.receiptsUnavailable) return `the review receipts store is malformed or unreadable (${summary.receiptsReadError ?? `${summary.receiptsMalformed} malformed line(s)`})`;
39
+ return null;
40
+ };
41
+
42
+ const toTreeFacts = (summary, clean) => {
43
+ const untrusted = describeUntrustedStore(summary);
44
+ if (untrusted) return { withheld: untrusted };
45
+ const { redProofs, receipts } = readSummaryFacts(summary);
46
+ return { fingerprint: summary.fingerprint, clean, verdicts: receipts.verdicts, finalRun: receipts.finalRun, redProofs };
47
+ };
48
+
49
+ export const gatherNowFacts = ({ cwd, env: given = process.env, deps = {} }) => {
50
+ // Every reader honouring an env argument spawns under this one: a plain `git diff` rewrites .git/index without the flag.
51
+ const env = { ...given, GIT_OPTIONAL_LOCKS: '0' };
52
+ const location = resolveGitLocation(cwd, { spawn: deps.spawn ?? spawnSync, env });
53
+ if (location.state !== 'work-tree') {
54
+ const withheld = `${cwd} is not a git work tree (${location.state}: ${location.cause})`;
55
+ return Object.fromEntries(BLOCKS.map((block) => [block, { withheld }]));
56
+ }
57
+ const root = location.top;
58
+ const summary = attempt('the evidence summary', () => deps.summary ?? buildSummaryState({ cwd: root, env }));
59
+ return {
60
+ plans: attempt('the plans in flight', () => ({
61
+ entries: gatherPlanFacts({ cwd: root, env, deps }).plans,
62
+ })),
63
+ tree: isWithheld(summary) ? summary : attempt('the tree state', () => toTreeFacts(summary, isTreeClean(root, { env }))),
64
+ queue: attempt('the queue', () => readQueueFacts(root, deps)),
65
+ campaign: attempt('the practice', () => readCampaignFacts(root, deps)),
66
+ };
67
+ };