@sabaiway/agent-workflow-kit 7.2.0 → 7.4.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 +61 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +1 -1
- package/references/modes/upgrade.md +6 -4
- package/references/scripts/check-docs-size.mjs +59 -62
- package/references/scripts/check-docs-size.test.mjs +79 -15
- package/references/scripts/spec-schema.mjs +355 -0
- package/references/scripts/spec-schema.test.mjs +212 -0
- package/references/templates/AGENTS.md +2 -2
- package/references/templates/SPEC_TEMPLATE.md +45 -0
- package/references/templates/agent_rules.md +4 -3
- package/references/templates/specs/index.md +21 -0
- package/tools/ensure-configs.mjs +21 -12
- package/tools/ensure-ops.mjs +11 -8
- package/tools/ensure-specs.mjs +214 -0
- package/tools/ensure-vocabulary.mjs +9 -4
- package/tools/known-footprint.mjs +2 -0
- package/tools/script-priors.mjs +41 -0
- package/tools/upgrade-runlist.mjs +1 -0
|
@@ -23,12 +23,12 @@ Read in order, then confirm before starting:
|
|
|
23
23
|
4. Confirm with the user: *"I'm taking task X. Confirm?"*
|
|
24
24
|
|
|
25
25
|
### 1.2. During Work
|
|
26
|
-
**Before any feature:**
|
|
26
|
+
**Before any feature:** name its governing spec(s) — the feature spec under `docs/ai/specs/` for every touched spec-covered slice (zero is legal during adoption; each cited spec's Out of scope bounds that slice's work). Where only a page spec (`docs/ai/pages/<page>.md`) exists it governs as an ADOPTION SHIM: state Out of scope + Revision inline in the plan. If the contract changes, the spec revision is authored WITH the plan (visible at review) and lands with the code, so docs and code never diverge.
|
|
27
27
|
|
|
28
28
|
**For every code change:**
|
|
29
29
|
1. Grep for similar implementations — reuse existing patterns.
|
|
30
30
|
2. Check the design-system layer for an existing component; if missing, add it there FIRST, then use it.
|
|
31
|
-
3. Verify changes align with `docs/ai/pages/<page>.md`;
|
|
31
|
+
3. Verify changes align with the governing spec(s) and `docs/ai/pages/<page>.md`; a new feature gets a `draft` spec (the skill's `SPEC_TEMPLATE.md`), a new page a full page spec.
|
|
32
32
|
4. Follow §2 (Self-Review): functional style, named exports, full variable names, no magic literals.
|
|
33
33
|
5. Write/update tests FIRST (TDD): unit for pure functions, E2E for user flows.
|
|
34
34
|
6. Run quality checks: lint, type-check, tests.
|
|
@@ -36,7 +36,7 @@ Read in order, then confirm before starting:
|
|
|
36
36
|
### 1.3. Task Completion
|
|
37
37
|
Before claiming "done":
|
|
38
38
|
1. Run all quality gates (lint + type-check + tests) — all green.
|
|
39
|
-
2. Update docs: `current_state.md` (feature ready), `changelog.md` (entry), `handover.md` (**REPLACE** the last-session block — session delta, never append; older deltas live in `changelog.md` → `history/`), `pages/<page>.md` (matches implementation). Only bump "Last Updated" when content actually changed.
|
|
39
|
+
2. Update docs: `current_state.md` (feature ready), `changelog.md` (entry), `handover.md` (**REPLACE** the last-session block — session delta, never append; older deltas live in `changelog.md` → `history/`), `pages/<page>.md` (matches implementation), the governing spec (its revision lands with the code: `draft` -> `live` on the landing row). Only bump "Last Updated" when content actually changed.
|
|
40
40
|
3. Run the docs cap-validator + index-freshness gate (pre-commit also enforces). On failure: trim the offending file, or run the changelog rotation if the offender is `changelog.md`.
|
|
41
41
|
4. If the work executed a plan file — run that plan's final **Phase: Cleanup** (see the planning skill / §5). Without it the plan is not done.
|
|
42
42
|
5. **Ask before committing** (§4): report lint / type-check / test counts + docs status, then wait for explicit approval. DO NOT auto-commit.
|
|
@@ -78,6 +78,7 @@ Apply these when authoring a plan, reviewing, folding a finding, or editing code
|
|
|
78
78
|
- **Fold by code, not prose.** Before folding a code-touching finding into a plan or change, read the cited `file:line` and cite it — a prose fold drifts from the code and seeds the next bug.
|
|
79
79
|
- **Finding scope (plan-execution) — name the invariant BEFORE the edit.** During EXECUTION only — a plan under authoring has no shipped behaviour to call a live defect in, so plan-review carries none of this. Every finding names the invariant its fix would enforce, and where that invariant already lives decides the disposition: already an acceptance criterion of the phase → **fold here**; it would have to be ADDED → ship the **narrow fix** for the found site (red first, then green) and queue ONLY the generalization — a deferral row carries the invariant, the origin `file:line`, the narrow fix, its proof and a residual exposure declared NOT live; no correct narrow fix → **blocking**: the phase does not close, and it is **never queued**. Two bars declared before each round: a finding counts only if it changes a **WRITE/REMOVE decision** or is a false statement in shipped text; a repeat finding in one subarea **routes to SUBTRACTION**, not a fourth patch.
|
|
80
80
|
- **Right altitude.** Pin intent + invariants + acceptance criteria (named tests); leave fine code-mechanics to Execute, where prose cannot diverge from reality.
|
|
81
|
+
- **Spec-first.** A plan names its GOVERNING spec(s) — zero, one or many, one per touched spec-covered slice (the feature spec under `docs/ai/specs/`; page-only coverage governs as an ADOPTION SHIM, with Out of scope + Revision stated inline in the plan). Each cited spec's Out of scope bounds that slice's work and the plan's non-goals restate it per slice — no global union; a cross-spec conflict is resolved by a spec revision BEFORE approval, never by silent precedence. A NEW feature's draft spec exists AT plan review (a `create` row); a change to a governed contract rides the plan as its proposed revision (a `modify` row); approval confirms plan and contract atomically, and the revision lands with the code. Scenario bindings are per scenario: a new scenario is `unbound` until its test lands in the same plan, and a status never regresses for an extension.
|
|
81
82
|
- **No code-mechanics in the plan.** A ledger row carries its path and anchor, and Verification carries the exact commands (the plan-shape canon) — checked syntax: the plan's own Verification runs them against an explicit expected outcome or gate; the only other syntax a plan may carry is a literal fixture/schema fragment a named test copies or validates. Un-run, logic-bearing syntax — control-flow, a regex, a glob, a grammar, an algorithm body, a mini-DSL — never lives in plan prose, however plausible or shell-verified it looks: a fold or draft that wants one is the trigger to write the test instead.
|
|
82
83
|
- **Test-as-spec.** Fold a code-touching finding into a red→green TEST, not a prose paragraph — the gate is the only deterministic checker; a paragraph cannot self-check.
|
|
83
84
|
- **Characterize-first.** Before editing UNCOVERED code, pin its current behavior in a green test, then edit — any unintended change goes red. Never edit what has no checker; first give it one. Keep edits atomic/reversible; prefer SUBTRACTIVE folds.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: spec
|
|
3
|
+
lastUpdated: {{DATE}}
|
|
4
|
+
scope: permanent
|
|
5
|
+
staleAfter: 90d
|
|
6
|
+
owner: none
|
|
7
|
+
maxLines: 80
|
|
8
|
+
kind: index
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Specs
|
|
12
|
+
|
|
13
|
+
> Up: [technical_specification.md](../technical_specification.md)
|
|
14
|
+
|
|
15
|
+
The top spec is the system root; this store holds the per-feature contracts under it. Every folder's
|
|
16
|
+
`index.md` lists ONLY its immediate children (at most 30); a spec over 150 lines is promoted to
|
|
17
|
+
`<slug>/index.md` + parts. Author a new spec from the skill's `SPEC_TEMPLATE.md`; the canon is the
|
|
18
|
+
engine's `references/specs.md`.
|
|
19
|
+
|
|
20
|
+
## Children
|
|
21
|
+
|
package/tools/ensure-configs.mjs
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// ensure-configs.mjs — ONE runnable command for the
|
|
2
|
+
// ensure-configs.mjs — ONE runnable command for the six stamp-independent upgrade ensures:
|
|
3
3
|
//
|
|
4
4
|
// orchestration docs/ai/orchestration.json seed, or refresh a still-canonical onboarding note
|
|
5
5
|
// gates docs/ai/gates.json seed-if-missing (an existing declaration is authored content)
|
|
6
6
|
// autonomy docs/ai/autonomy.json seed-if-missing (same)
|
|
7
7
|
// scripts scripts/<ADR enforcement> seed-if-missing, ADR-layout detect FIRST
|
|
8
|
+
// specs scripts/<spec layer> reader pair seed-if-missing, checker pair refreshed only on
|
|
9
|
+
// + docs/ai/specs/index.md a shipped prior body, store root seeded behind a current checker
|
|
8
10
|
// index docs/ai/index.md regenerate-if-missing-or-stale (a GENERATED artifact)
|
|
9
11
|
//
|
|
10
12
|
// Each was prose in references/modes/upgrade.md that an agent performed by hand. One command instead
|
|
11
|
-
// of
|
|
12
|
-
// has a single invocation point whose
|
|
13
|
+
// of six is deliberate: six independent runs would be six chances to skip one, and the mode doc now
|
|
14
|
+
// has a single invocation point whose six outcome lines it relays.
|
|
13
15
|
//
|
|
14
16
|
// The contract (pinned by this module's tests):
|
|
15
17
|
// • --reconcile is REQUIRED. A bare run is a usage error, so nothing writes by accident.
|
|
@@ -17,7 +19,7 @@
|
|
|
17
19
|
// • The ops run in a FIXED order and one op's failure NEVER skips the rest: every op reports its own
|
|
18
20
|
// token, and the exit is non-zero when any of them failed.
|
|
19
21
|
// • The deployment gate runs ONCE, before any op: an absent/symlinked docs/ai stops the whole run
|
|
20
|
-
// with the gate's own message rather than
|
|
22
|
+
// with the gate's own message rather than six copies of it.
|
|
21
23
|
//
|
|
22
24
|
// Output is ENGLISH/structured (repo-artifact Hard Constraint); the agent localizes when narrating.
|
|
23
25
|
// Exit codes: 0 every op fine · 1 an op failed, or the deployment gate stopped the run · 2 usage.
|
|
@@ -29,10 +31,15 @@ import { dirname, resolve } from 'node:path';
|
|
|
29
31
|
import { fileURLToPath } from 'node:url';
|
|
30
32
|
import { assertDocsAiDeployment } from './atomic-write.mjs';
|
|
31
33
|
import { isDirectRun } from './direct-run.mjs';
|
|
32
|
-
import {
|
|
34
|
+
import { ENSURE_OPS, OWN_IMPLEMENTATIONS, failedOutcome } from './ensure-ops.mjs';
|
|
35
|
+
import { ensureSpecs } from './ensure-specs.mjs';
|
|
33
36
|
|
|
34
37
|
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
35
38
|
|
|
39
|
+
// The op table this CLI walks — name → implementation, in ENSURE_OPS order. Composed HERE (the one
|
|
40
|
+
// consumer) because the spec-layer ensure imports ensure-ops.mjs for its outcome door and probes.
|
|
41
|
+
export const ENSURE_IMPLEMENTATIONS = Object.freeze({ ...OWN_IMPLEMENTATIONS, specs: ensureSpecs });
|
|
42
|
+
|
|
36
43
|
const EXIT_OK = 0;
|
|
37
44
|
const EXIT_FAILED = 1;
|
|
38
45
|
const EXIT_USAGE = 2;
|
|
@@ -44,22 +51,24 @@ const EMPTY_ONLY = `--only needs one operation name (${ENSURE_OPS.join(' | ')})
|
|
|
44
51
|
const REPEATED_ONLY = '--only was passed more than once — this selector names exactly ONE operation';
|
|
45
52
|
const unknownOp = (value) => `--only ${value}: no such operation (${ENSURE_OPS.join(' | ')}) — nothing was run`;
|
|
46
53
|
|
|
47
|
-
const HELP = `ensure-configs — the
|
|
54
|
+
const HELP = `ensure-configs — the six stamp-independent upgrade ensures, as ONE command.
|
|
48
55
|
|
|
49
56
|
Usage:
|
|
50
57
|
node ensure-configs.mjs --reconcile [--dry-run] [--only <op>] [--cwd <project>]
|
|
51
58
|
|
|
52
|
-
--reconcile required — run the
|
|
59
|
+
--reconcile required — run the six ensures (${ENSURE_OPS.join(', ')})
|
|
53
60
|
--dry-run report what each ensure WOULD do; write nothing
|
|
54
61
|
--only <op> run EXACTLY ONE of them (an unknown, missing or repeated value is a usage error)
|
|
55
62
|
--cwd <dir> the target project (default: the current directory)
|
|
56
63
|
--help, -h this help
|
|
57
64
|
|
|
58
65
|
Every SEED is CREATE-ONLY: an existing file is preserved byte-for-byte, never clobbered and never
|
|
59
|
-
refreshed in place.
|
|
60
|
-
matches a canonical the kit shipped (your own wording is preserved verbatim)
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
refreshed in place. Three ops refresh instead: the orchestration onboarding note, only while it still
|
|
67
|
+
matches a canonical the kit shipped (your own wording is preserved verbatim); the spec-layer checker
|
|
68
|
+
pair, only while its bytes are a body a release shipped (an edited checker is preserved, and the spec
|
|
69
|
+
store root is then NOT seeded behind it); and the navigator index — a GENERATED artifact, regenerated
|
|
70
|
+
whenever it is missing or stale. The enforcement-script ensure detects an older ADR-store layout
|
|
71
|
+
FIRST and instructs the opt-in migration instead of seeding.
|
|
63
72
|
|
|
64
73
|
Exit codes: 0 every op fine; 1 an op failed (its line says so) or there is no deployment here; 2 usage.`;
|
|
65
74
|
|
|
@@ -138,7 +147,7 @@ export const main = (argv = [], ctx = {}) => {
|
|
|
138
147
|
const cwd = resolve(args.cwd ?? ctx.cwd ?? process.cwd());
|
|
139
148
|
const deps = ctx.deps ?? {};
|
|
140
149
|
// ONE deployment gate for the whole run (see the header): with no docs/ai there is nothing to
|
|
141
|
-
// reconcile, and
|
|
150
|
+
// reconcile, and six identical STOPs would read as six separate problems.
|
|
142
151
|
assertDocsAiDeployment(cwd, deps, { noun: 'the project configuration', rel: 'under docs/ai' });
|
|
143
152
|
const outcomes = runEnsures({ cwd, kitRoot: ctx.kitRoot ?? KIT_ROOT, dryRun: args.dryRun, deps, only: args.only });
|
|
144
153
|
return {
|
package/tools/ensure-ops.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// ensure-ops.mjs — the
|
|
2
|
-
// shape
|
|
3
|
-
//
|
|
1
|
+
// ensure-ops.mjs — FIVE of the upgrade ensure operations, one function each, behind one shared
|
|
2
|
+
// outcome shape; the sixth (the spec-layer ensure, ensure-specs.mjs) composes its outcomes through
|
|
3
|
+
// the same door and probes exported below. The CLI that orders and runs them is ensure-configs.mjs —
|
|
4
|
+
// it owns the op table, so the import graph stays acyclic; this module owns what each ensure DOES
|
|
5
|
+
// and, more importantly, what it is allowed to CLAIM.
|
|
4
6
|
//
|
|
5
7
|
// Why they became code at all: `references/modes/upgrade.md` prescribed each of them as prose an agent
|
|
6
8
|
// was expected to carry out by hand ("create it from the template if missing", "copy the pair from
|
|
@@ -86,14 +88,14 @@ export const failedOutcome = (op, err) => loud(op, 'unexpected-error', `${op}: $
|
|
|
86
88
|
// directory or a symlink named gates.json report a green ensure while the declaration the project
|
|
87
89
|
// needs does not exist — an exit 0 proving nothing (both review backends found this).
|
|
88
90
|
const NODE_KIND = (st) => (st.isSymbolicLink() ? 'a symlink' : st.isDirectory() ? 'a directory' : 'not a regular file');
|
|
89
|
-
const probeSeedTarget = (abs, lstat) => {
|
|
91
|
+
export const probeSeedTarget = (abs, lstat) => {
|
|
90
92
|
const st = lstatNoFollow(abs, lstat);
|
|
91
93
|
if (st === null) return { present: false };
|
|
92
94
|
return st.isFile() ? { present: true } : { present: true, wrongKind: NODE_KIND(st) };
|
|
93
95
|
};
|
|
94
96
|
|
|
95
97
|
// A leftover temp file never fails a completed write, and is never silent either.
|
|
96
|
-
const tmpNote = (rel, tmpLeftBehind) =>
|
|
98
|
+
export const tmpNote = (rel, tmpLeftBehind) =>
|
|
97
99
|
(tmpLeftBehind ? [`${rel}: the write stands, but its temp file could not be removed — delete it by hand: ${tmpLeftBehind}`] : []);
|
|
98
100
|
|
|
99
101
|
// ── 1. orchestration.json — seed, or refresh ONLY a still-canonical onboarding note ────────────────
|
|
@@ -204,7 +206,7 @@ export const ensureAutonomy = ({ cwd, kitRoot, dryRun = false, deps = {} }) =>
|
|
|
204
206
|
|
|
205
207
|
// A project with no package.json at its root is not where Node enforcement scripts belong. Stated,
|
|
206
208
|
// never silent: the token names the evidence, and the three config ensures still run.
|
|
207
|
-
const isNodeProject = (cwd, lstat) => lstatNoFollow(join(cwd, PACKAGE_JSON), lstat) !== null;
|
|
209
|
+
export const isNodeProject = (cwd, lstat) => lstatNoFollow(join(cwd, PACKAGE_JSON), lstat) !== null;
|
|
208
210
|
|
|
209
211
|
const OLD_ADR_LAYOUTS = new Set(['old', 'old-unrotated']);
|
|
210
212
|
|
|
@@ -352,8 +354,9 @@ export const ensureIndex = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
|
|
|
352
354
|
return loud('index', 'index-probe-failed', `${INDEX_REL}: the generator reported a regeneration, but the verifying probe answered neither fresh nor stale — ${MAY_HAVE_WRITTEN}. ${verify.error ? causeOf(verify.error) : verdict}`);
|
|
353
355
|
};
|
|
354
356
|
|
|
355
|
-
// The
|
|
356
|
-
|
|
357
|
+
// The five ops this module owns, by name. The CLI composes the full ENSURE_OPS table from these plus
|
|
358
|
+
// the spec-layer ensure (ensure-specs.mjs imports THIS module, so the table cannot live here).
|
|
359
|
+
export const OWN_IMPLEMENTATIONS = Object.freeze({
|
|
357
360
|
orchestration: ensureOrchestration,
|
|
358
361
|
gates: ensureGates,
|
|
359
362
|
autonomy: ensureAutonomy,
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// ensure-specs.mjs — the SIXTH upgrade ensure: the spec layer (memory 4.6.0, AD-112) delivered to an
|
|
2
|
+
// EXISTING deployment on an equal-head upgrade. Four deployed scripts and one store root, written in
|
|
3
|
+
// a FIXED order the measured coupling dictates:
|
|
4
|
+
// 1. the reader pair scripts/spec-schema.mjs + .test.mjs — CREATE-ONLY (brief D1: a new
|
|
5
|
+
// deployed script ships create-only before any refresh of a file that imports it);
|
|
6
|
+
// 2. the checker pair scripts/check-docs-size.mjs + .test.mjs — REFRESHED only when the deployed
|
|
7
|
+
// bytes are a body a release shipped (script-priors.mjs), created when absent, a custom body
|
|
8
|
+
// preserved verbatim — and only behind a reader pair that is byte-current;
|
|
9
|
+
// 3. the store root docs/ai/specs/index.md — seeded (placeholders rendered)
|
|
10
|
+
// only once both pairs are current.
|
|
11
|
+
// Why the order: the kit's bundled navigator generator collapses `specs/` into one row, while the
|
|
12
|
+
// project's pre-commit hook runs ITS deployed checker — an older one renders the store row by row and
|
|
13
|
+
// reds `--check-index`. A store root seeded behind a stale checker breaks the hook, so the store is
|
|
14
|
+
// the LAST write and admits only behind a checker this run has proven current.
|
|
15
|
+
//
|
|
16
|
+
// The state table (enumerate by PROOF, never by exclusion): every file is classified into exactly one
|
|
17
|
+
// of current | prior | custom | absent | wrong-kind before anything is written, each write admits
|
|
18
|
+
// through ONE conjunction of those facts (decideWrites), and every other cell is a stated refusal —
|
|
19
|
+
// a custom file of either pair preserves itself AND withholds every write that depends on it. Writes
|
|
20
|
+
// run in order and each is idempotent, so a run that stopped partway converges on the next run.
|
|
21
|
+
//
|
|
22
|
+
// Dependency-free, Node >= 22. Every fs primitive is injectable (deps.*). No side effects on import.
|
|
23
|
+
|
|
24
|
+
import { readFileSync, lstatSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { writeContainedFileAtomic, writeProjectFileCreateOnly } from './atomic-write.mjs';
|
|
27
|
+
import { PRIOR_FILES, classifyDeployedScript } from './script-priors.mjs';
|
|
28
|
+
import { composeFailure, composeOutcome, isNodeProject, probeSeedTarget, tmpNote } from './ensure-ops.mjs';
|
|
29
|
+
|
|
30
|
+
const OP = 'specs';
|
|
31
|
+
const SCRIPTS_DIR = 'scripts';
|
|
32
|
+
const BUNDLED_SCRIPTS = ['references', 'scripts'];
|
|
33
|
+
const STORE_ROOT_REL = 'docs/ai/specs/index.md';
|
|
34
|
+
const STORE_ROOT_TEMPLATE = ['references', 'templates', 'specs', 'index.md'];
|
|
35
|
+
const DATE_PLACEHOLDER = '{{DATE}}';
|
|
36
|
+
const READER_PAIR = Object.freeze(['spec-schema.mjs', 'spec-schema.test.mjs']);
|
|
37
|
+
const CHECKER_PAIR = PRIOR_FILES;
|
|
38
|
+
|
|
39
|
+
const ok = (token, lines) => composeOutcome(OP, token, lines, false);
|
|
40
|
+
const loud = (cause, ...lines) => composeFailure(OP, cause, ...lines);
|
|
41
|
+
const causeOf = (err) => String((err && err.message) || err);
|
|
42
|
+
const todayIso = () => new Date().toISOString().slice(0, 10);
|
|
43
|
+
|
|
44
|
+
// ── the survey: five paths classified, nothing written ────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
// One deployed script → { rel, name, state, bundle }; `wrongKind` carries the node kind for the
|
|
47
|
+
// refusal, `prior`/`custom` come from the catalog classifier against the bundled body.
|
|
48
|
+
const surveyScript = ({ cwd, kitRoot, name, read, lstat }) => {
|
|
49
|
+
const rel = `${SCRIPTS_DIR}/${name}`;
|
|
50
|
+
let bundle;
|
|
51
|
+
try {
|
|
52
|
+
bundle = read(join(kitRoot, ...BUNDLED_SCRIPTS, name));
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return { rel, name, state: 'bundle-unreadable', error: causeOf(err) };
|
|
55
|
+
}
|
|
56
|
+
const probe = probeSeedTarget(join(cwd, SCRIPTS_DIR, name), lstat);
|
|
57
|
+
if (probe.wrongKind) return { rel, name, state: 'wrong-kind', wrongKind: probe.wrongKind, bundle };
|
|
58
|
+
if (!probe.present) return { rel, name, state: 'absent', bundle };
|
|
59
|
+
return { rel, name, state: classifyDeployedScript(read(join(cwd, SCRIPTS_DIR, name)), name, bundle), bundle };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const surveyStoreRoot = (cwd, lstat) => {
|
|
63
|
+
const probe = probeSeedTarget(join(cwd, STORE_ROOT_REL), lstat);
|
|
64
|
+
if (probe.wrongKind) return { rel: STORE_ROOT_REL, state: 'wrong-kind', wrongKind: probe.wrongKind };
|
|
65
|
+
return { rel: STORE_ROOT_REL, state: probe.present ? 'present' : 'absent' };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ── the decision: which writes the survey admits (pure over the survey) ───────────────────────────
|
|
69
|
+
|
|
70
|
+
const CURRENT_AFTER_SEED = new Set(['current', 'absent']);
|
|
71
|
+
const CHECKER_ELIGIBLE = new Set(['current', 'prior', 'absent']);
|
|
72
|
+
|
|
73
|
+
export const decideWrites = ({ reader, checker, store }) => {
|
|
74
|
+
const readerCurrentAfter = reader.every((f) => CURRENT_AFTER_SEED.has(f.state));
|
|
75
|
+
const checkerEligible = readerCurrentAfter && checker.every((f) => CHECKER_ELIGIBLE.has(f.state));
|
|
76
|
+
const writes = [];
|
|
77
|
+
for (const f of reader) if (f.state === 'absent') writes.push({ kind: 'seed', file: f });
|
|
78
|
+
if (checkerEligible) {
|
|
79
|
+
for (const f of checker) {
|
|
80
|
+
if (f.state === 'absent') writes.push({ kind: 'seed', file: f });
|
|
81
|
+
if (f.state === 'prior') writes.push({ kind: 'refresh', file: f });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (checkerEligible && store.state === 'absent') writes.push({ kind: 'store' });
|
|
85
|
+
return { writes, withheld: !checkerEligible };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ── the lines (composed from what HAPPENED, never from what was planned) ──────────────────────────
|
|
89
|
+
|
|
90
|
+
// Every file's line is a function of its surveyed state and the FATE of its write this run:
|
|
91
|
+
// `written` · `stood` (lost the create-only race to the bundled body) · `would` (dry run) ·
|
|
92
|
+
// `withheld` (a custom file in the pair) · `stopped` (the run failed before reaching it) · `none`
|
|
93
|
+
// (no write was admitted or needed). A line never claims a write that did not happen.
|
|
94
|
+
const LEFT_FOR = { withheld: 'it waits on the pair named above', stopped: 'the run stopped before it' };
|
|
95
|
+
const SCRIPT_LINES = {
|
|
96
|
+
current: () => 'already the bundled body — nothing written',
|
|
97
|
+
custom: () => "carries a body this kit did not ship as current — preserved verbatim; the writes that depend on it wait until the pair matches the bundled scripts (copy them by hand from the kit's references/scripts/ when convenient, then re-run the upgrade)",
|
|
98
|
+
absent: (fate) => ({
|
|
99
|
+
written: 'copied from the bundled scripts',
|
|
100
|
+
stood: 'appeared while this run was seeding it — the bundled body stands',
|
|
101
|
+
would: 'absent — would be copied from the bundled scripts',
|
|
102
|
+
})[fate] ?? `absent — not copied${fate === 'withheld' ? ' this run' : ''}; ${LEFT_FOR[fate]}`,
|
|
103
|
+
prior: (fate) => `matches a body an earlier release shipped — ${({
|
|
104
|
+
written: 'refreshed to the bundled one',
|
|
105
|
+
would: 'would be refreshed to the bundled one',
|
|
106
|
+
})[fate] ?? `left as is; ${LEFT_FOR[fate]}`}`,
|
|
107
|
+
};
|
|
108
|
+
const scriptLine = (f, fate) => `${f.rel}: ${SCRIPT_LINES[f.state](fate)}`;
|
|
109
|
+
const STORE_LINES = {
|
|
110
|
+
present: 'already present — preserved byte-for-byte, nothing written',
|
|
111
|
+
written: "created from the bundled template (today's date rendered)",
|
|
112
|
+
stood: 'appeared while this run was seeding it — the existing file stands',
|
|
113
|
+
would: 'absent — would be created from the bundled template',
|
|
114
|
+
withheld: "not seeded — an older or edited checker renders the spec store row by row and reds the hook's index check, so the store root waits for a current checker pair",
|
|
115
|
+
stopped: 'absent — not created; the run stopped before it',
|
|
116
|
+
};
|
|
117
|
+
const storeLine = (store, fate) => `${STORE_ROOT_REL}: ${STORE_LINES[store.state === 'present' ? 'present' : fate]}`;
|
|
118
|
+
const relOf = (write) => (write.kind === 'store' ? STORE_ROOT_REL : write.file.rel);
|
|
119
|
+
|
|
120
|
+
// The fate of every path for the lines: a write that ran carries its result, an admitted write the
|
|
121
|
+
// run never reached is `stopped`, and a path with no admitted write is `withheld` behind a custom
|
|
122
|
+
// file or `none` when nothing was needed.
|
|
123
|
+
const fateOf = (rel, { writes, fates, withheld, stopped }) => {
|
|
124
|
+
if (fates.has(rel)) return fates.get(rel);
|
|
125
|
+
if (writes.some((w) => relOf(w) === rel)) return stopped ? 'stopped' : 'would';
|
|
126
|
+
return withheld ? 'withheld' : 'none';
|
|
127
|
+
};
|
|
128
|
+
const composeLines = ({ reader, checker, store, writes, fates, withheld, stopped }) => {
|
|
129
|
+
const fate = (rel) => fateOf(rel, { writes, fates, withheld, stopped });
|
|
130
|
+
const lines = [...reader, ...checker].map((f) => scriptLine(f, fate(f.rel)));
|
|
131
|
+
const storeFate = fate(STORE_ROOT_REL);
|
|
132
|
+
if (store.state === 'present' || storeFate !== 'none') lines.push(storeLine(store, storeFate));
|
|
133
|
+
return lines;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// A per-file loop can stop partway; the run says which files already landed — never "nothing happened".
|
|
137
|
+
const partialNote = (count) =>
|
|
138
|
+
(count > 0 ? [`the writes stopped PARTWAY — the ${count} file(s) named above were already written and are NOT rolled back`] : []);
|
|
139
|
+
|
|
140
|
+
// ── the ensure ────────────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
// The store body is read and rendered BEFORE the first write, so a missing or unrenderable template
|
|
143
|
+
// refuses with nothing written.
|
|
144
|
+
const renderStoreRoot = (kitRoot, read, today) => {
|
|
145
|
+
const body = String(read(join(kitRoot, ...STORE_ROOT_TEMPLATE), 'utf8')).replaceAll(DATE_PLACEHOLDER, today);
|
|
146
|
+
if (body.includes('{{')) throw new Error('the bundled template carries a placeholder this kit cannot render — reinstall the kit');
|
|
147
|
+
return body;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const ensureSpecs = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
|
|
151
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
152
|
+
const read = deps.readFile ?? readFileSync;
|
|
153
|
+
if (!isNodeProject(cwd, lstat)) {
|
|
154
|
+
return ok('skipped-no-node', [`${SCRIPTS_DIR}/: no package.json at the project root — the spec reader and checker are Node scripts; nothing written`]);
|
|
155
|
+
}
|
|
156
|
+
const survey = (name) => surveyScript({ cwd, kitRoot, name, read, lstat });
|
|
157
|
+
const reader = READER_PAIR.map(survey);
|
|
158
|
+
const checker = CHECKER_PAIR.map(survey);
|
|
159
|
+
const store = surveyStoreRoot(cwd, lstat);
|
|
160
|
+
for (const f of [...reader, ...checker]) {
|
|
161
|
+
if (f.state === 'bundle-unreadable') return loud('bundle-unreadable', `${f.rel}: the bundled script could not be read, so nothing was written — reinstall the kit. ${f.error}`);
|
|
162
|
+
}
|
|
163
|
+
for (const f of [...reader, ...checker, store]) {
|
|
164
|
+
if (f.state === 'wrong-kind') return loud('wrong-node-kind', `${f.rel}: exists but is ${f.wrongKind} — nothing was read or written; resolve it by hand, then re-run`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const { writes, withheld } = decideWrites({ reader, checker, store });
|
|
168
|
+
const kinds = new Set(writes.map((w) => w.kind));
|
|
169
|
+
// ONE token by precedence — a write that happened (or, dry, would happen) always outranks the
|
|
170
|
+
// preserved-custom report, which names a run that wrote nothing behind an edited pair.
|
|
171
|
+
const tokenFor = (seeded, refreshed) => {
|
|
172
|
+
if (seeded) return dryRun ? 'would-seed' : 'seeded';
|
|
173
|
+
if (refreshed) return dryRun ? 'would-refresh' : 'refreshed';
|
|
174
|
+
return withheld ? 'customized-preserved' : 'already-present';
|
|
175
|
+
};
|
|
176
|
+
const fates = new Map();
|
|
177
|
+
const linesNow = (stopped) => composeLines({ reader, checker, store, writes, fates, withheld, stopped });
|
|
178
|
+
const writtenCount = () => [...fates.values()].filter((fate) => fate === 'written').length;
|
|
179
|
+
if (dryRun) return ok(tokenFor(kinds.has('seed') || kinds.has('store'), kinds.has('refresh')), linesNow(false));
|
|
180
|
+
|
|
181
|
+
let storeBody = null;
|
|
182
|
+
if (kinds.has('store')) {
|
|
183
|
+
try {
|
|
184
|
+
storeBody = renderStoreRoot(kitRoot, read, deps.today ?? todayIso());
|
|
185
|
+
} catch (err) {
|
|
186
|
+
return loud('template-unreadable', `${STORE_ROOT_REL}: ${causeOf(err)}`, ...linesNow(true));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const writeOne = ({ kind, file }) => {
|
|
190
|
+
if (kind === 'store') return writeProjectFileCreateOnly(cwd, STORE_ROOT_REL, storeBody, deps, { noun: 'the spec store root' });
|
|
191
|
+
if (kind === 'seed') return writeProjectFileCreateOnly(cwd, file.rel, String(file.bundle), deps, { noun: 'a seeded spec-layer script' });
|
|
192
|
+
return writeContainedFileAtomic(cwd, join(cwd, file.rel), String(file.bundle), deps, { label: file.rel });
|
|
193
|
+
};
|
|
194
|
+
// A seed that lost the create-only race is re-PROVEN before anything that depends on it runs: the
|
|
195
|
+
// file that appeared must carry the bundled body, or the tree is changing underneath this run.
|
|
196
|
+
const stoodCurrent = ({ kind, file }) => kind === 'store' || classifyDeployedScript(read(join(cwd, file.rel)), file.name, file.bundle) === 'current';
|
|
197
|
+
const notes = [];
|
|
198
|
+
for (const write of writes) {
|
|
199
|
+
const rel = relOf(write);
|
|
200
|
+
let result;
|
|
201
|
+
try {
|
|
202
|
+
result = writeOne(write);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
return loud('write-refused', `${rel}: ${causeOf(err)}`, ...linesNow(true), ...notes, ...partialNote(writtenCount()));
|
|
205
|
+
}
|
|
206
|
+
if (write.kind !== 'refresh' && !result.created && !stoodCurrent(write)) {
|
|
207
|
+
return loud('race-unresolved', `${rel}: appeared while this run was seeding it and does not carry the bundled body — something is writing there underneath this run; nothing further written, re-run when the tree is settled`, ...linesNow(true), ...notes, ...partialNote(writtenCount()));
|
|
208
|
+
}
|
|
209
|
+
fates.set(rel, write.kind !== 'refresh' && !result.created ? 'stood' : 'written');
|
|
210
|
+
notes.push(...tmpNote(rel, result.tmpLeftBehind));
|
|
211
|
+
}
|
|
212
|
+
const landed = (kind) => writes.some((w) => w.kind === kind && fates.get(relOf(w)) === 'written');
|
|
213
|
+
return ok(tokenFor(landed('seed') || landed('store'), landed('refresh')), [...linesNow(false), ...notes]);
|
|
214
|
+
};
|
|
@@ -8,12 +8,16 @@
|
|
|
8
8
|
// read-only tool's import graph. Vocabulary here, behaviour in ensure-ops.mjs.
|
|
9
9
|
|
|
10
10
|
// The FIXED order the CLI runs them in — the order references/modes/upgrade.md already prescribed.
|
|
11
|
-
|
|
11
|
+
// `specs` sits after `scripts` (its reader pair is a scripts/ seed too) and BEFORE `index`: the store
|
|
12
|
+
// root it seeds is a docs/ai file the navigator must count.
|
|
13
|
+
export const ENSURE_OPS = Object.freeze(['orchestration', 'gates', 'autonomy', 'scripts', 'specs', 'index']);
|
|
12
14
|
|
|
13
15
|
// Tokens that assert a WRITE happened. --dry-run may never emit one of these (the CLI's contract test
|
|
14
|
-
// walks this set), and each has exactly one `would-` counterpart below.
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
// walks this set), and each has exactly one `would-` counterpart below. `refreshed` is the spec-layer
|
|
17
|
+
// ensure's checker-pair refresh — a deployed script on a body a release shipped, rewritten to the
|
|
18
|
+
// bundled one (a custom body is never refreshed).
|
|
19
|
+
export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed', 'refreshed', 'regenerated']);
|
|
20
|
+
export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note', 'would-refresh', 'would-regenerate']);
|
|
17
21
|
|
|
18
22
|
// The CLOSED outcome vocabulary. Closed at RUNTIME, not by convention: composing an outcome with a
|
|
19
23
|
// token outside this list throws, so an op cannot quietly invent a word the mode doc has never heard
|
|
@@ -65,6 +69,7 @@ export const RELAYED_FAILURE_CAUSES = FAILURE_CAUSES;
|
|
|
65
69
|
export const RELAYED_ENSURE_TOKENS = Object.freeze([
|
|
66
70
|
'seeded',
|
|
67
71
|
'note-refreshed',
|
|
72
|
+
'refreshed',
|
|
68
73
|
'regenerated',
|
|
69
74
|
'already-current',
|
|
70
75
|
'customized-preserved',
|
|
@@ -69,6 +69,8 @@ export const KIT_OWN_PATHS = [
|
|
|
69
69
|
'/scripts/migrate-gates-branches.test.mjs',
|
|
70
70
|
'/scripts/migrate-gates.mjs',
|
|
71
71
|
'/scripts/migrate-gates.test.mjs',
|
|
72
|
+
'/scripts/spec-schema.mjs',
|
|
73
|
+
'/scripts/spec-schema.test.mjs',
|
|
72
74
|
'/docs/plans/',
|
|
73
75
|
'/.claude/settings.local.json',
|
|
74
76
|
'/.claude/settings.json',
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// script-priors.mjs — the APPEND-ONLY catalog of every enforcement-script body this family has shipped
|
|
2
|
+
// into a project's `scripts/`, as sha256 digests, and the ONE classifier the spec-layer ensure asks
|
|
3
|
+
// before it refreshes a deployed script: `current` (byte-equal to the bundled copy) · `prior` (a body
|
|
4
|
+
// a release shipped, never edited since) · `custom` (anything else — preserved verbatim, never
|
|
5
|
+
// overwritten). The lens-region idiom (AD-041) over whole script bodies.
|
|
6
|
+
//
|
|
7
|
+
// Append discipline: when a release changes one of PRIOR_FILES, append the OUTGOING body's row here
|
|
8
|
+
// in the same release and copy its bytes to test/fixtures/script-priors/<firstShipped>/<file>.txt —
|
|
9
|
+
// script-priors.test.mjs holds the two sides equal both ways and pins the row count. Rows are never
|
|
10
|
+
// edited or removed: a deployment on any shipped body must keep classifying `prior`.
|
|
11
|
+
//
|
|
12
|
+
// Pure leaf: node:crypto only. No side effects on import.
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
// The deployed scripts the spec-layer ensure may REFRESH (the checker pair that imports the reader).
|
|
17
|
+
// Every other deployed script is outside the refresh lane by design.
|
|
18
|
+
export const PRIOR_FILES = Object.freeze(['check-docs-size.mjs', 'check-docs-size.test.mjs']);
|
|
19
|
+
|
|
20
|
+
const prior = (file, firstShipped, lastShipped, digest) => Object.freeze({ file, firstShipped, lastShipped, digest });
|
|
21
|
+
|
|
22
|
+
// One row per DISTINCT shipped body (memory package versions; the body is the memory canon the kit
|
|
23
|
+
// mirrors). `firstShipped`..`lastShipped` is the inclusive release range that carried the body.
|
|
24
|
+
export const SCRIPT_PRIORS = Object.freeze([
|
|
25
|
+
prior('check-docs-size.mjs', '4.0.0', '4.3.0', '84fb3673b034d4b2ba5bedf4a3e47899f98da3971c17902d1f2a548d07dc53bf'),
|
|
26
|
+
prior('check-docs-size.mjs', '4.4.0', '4.5.0', '7a5cd7f98571c3248d0378623172e9c60073b8d8761bce7a95c263f99bfb3a42'),
|
|
27
|
+
prior('check-docs-size.mjs', '4.5.1', '4.5.4', 'fef3555b14a5ade46071bac18bd6dfc87daec39dd63ce1f7965864c3e51558d9'),
|
|
28
|
+
prior('check-docs-size.test.mjs', '4.0.0', '4.5.4', '88fbb3d7f097d74771b7c5d9ad99fcd58b274ae33f391e1ff01f4b138b9236cd'),
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export const digestOf = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
32
|
+
|
|
33
|
+
// classifyDeployedScript(deployedBytes, file, bundleBytes, priors?) → 'current' | 'prior' | 'custom'.
|
|
34
|
+
// The bundled body is compared FIRST, so a bundle that happens to equal a catalogued body still
|
|
35
|
+
// reads as current; the catalog is consulted by file name, so a body shipped under another name is
|
|
36
|
+
// never a prior for this one.
|
|
37
|
+
export const classifyDeployedScript = (deployedBytes, file, bundleBytes, priors = SCRIPT_PRIORS) => {
|
|
38
|
+
const digest = digestOf(deployedBytes);
|
|
39
|
+
if (digest === digestOf(bundleBytes)) return 'current';
|
|
40
|
+
return priors.some((row) => row.file === file && row.digest === digest) ? 'prior' : 'custom';
|
|
41
|
+
};
|