@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/CHANGELOG.md +76 -0
- package/README.md +2 -1
- package/SKILL.md +5 -1
- package/bridges/codex-cli-bridge/SKILL.md +14 -12
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +16 -15
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +9 -9
- package/bridges/codex-cli-bridge/bin/codex-review.sh +14 -13
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +8 -8
- package/bridges/codex-cli-bridge/capability.json +4 -4
- package/bridges/codex-cli-bridge/references/driving-codex.md +2 -2
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +6 -6
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/agents/review-lens.md +1 -0
- package/references/modes/bridge-settings.md +2 -2
- package/references/modes/now.md +30 -0
- package/references/scripts/spec-schema.mjs +54 -30
- package/references/scripts/spec-schema.test.mjs +65 -0
- package/tools/bridge-settings.mjs +1 -1
- package/tools/commands.mjs +7 -0
- package/tools/feedback-record.mjs +6 -2
- package/tools/now-cli.mjs +104 -0
- package/tools/now-facts.mjs +67 -0
- package/tools/now-plan-facts.mjs +129 -0
- package/tools/now-render.mjs +162 -0
- package/tools/now-status.mjs +145 -0
- package/tools/plan-shape-facts.mjs +1 -1
- package/tools/procedures.mjs +2 -1
- package/tools/queue-audit-cli.mjs +17 -10
- package/tools/queue-audit-rows.mjs +98 -18
- package/tools/queue-audit.mjs +58 -12
- package/tools/queue-row-name.mjs +149 -0
- package/tools/script-priors.mjs +2 -0
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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) {
|
package/tools/commands.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
`- [ ] ${
|
|
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
|
+
};
|