@sabaiway/agent-workflow-kit 5.5.0 → 5.7.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 +122 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +7 -1
- package/references/modes/doc-parity.md +1 -1
- package/references/modes/gates.md +20 -4
- package/references/modes/procedures.md +2 -0
- package/references/modes/recommendations.md +4 -1
- package/references/modes/review-state.md +1 -1
- package/references/modes/setup.md +18 -2
- package/references/modes/upgrade.md +38 -18
- package/references/modes/velocity.md +1 -0
- package/references/scripts/migrate-gates-branches.test.mjs +146 -1
- package/references/scripts/migrate-gates.mjs +295 -60
- package/references/scripts/migrate-gates.test.mjs +206 -14
- package/references/shared/deploy-tail.md +1 -1
- package/references/templates/gates.json +1 -1
- package/tools/ack-write.mjs +20 -11
- package/tools/atomic-write.mjs +71 -18
- package/tools/checker-claim.mjs +100 -0
- package/tools/coverage-producer.mjs +43 -6
- package/tools/direct-run.mjs +76 -0
- package/tools/doc-parity.mjs +34 -3
- package/tools/engine-source.mjs +12 -8
- package/tools/ensure-configs.mjs +141 -0
- package/tools/ensure-ops.mjs +284 -0
- package/tools/ensure-vocabulary.mjs +71 -0
- package/tools/flow-check-cores.mjs +253 -0
- package/tools/flow-check-git-lane.mjs +56 -0
- package/tools/flow-check-rungs.mjs +330 -0
- package/tools/flow-check.mjs +23 -611
- package/tools/gates-declaration.mjs +36 -11
- package/tools/gates-init.mjs +140 -25
- package/tools/hide-footprint.mjs +21 -3
- package/tools/lens-region.mjs +74 -23
- package/tools/orchestration-config.mjs +5 -3
- package/tools/orchestration-write.mjs +7 -0
- package/tools/procedures.mjs +64 -5
- package/tools/recommendations.mjs +384 -34
- package/tools/refresh-parity.mjs +263 -0
- package/tools/run-gates.mjs +8 -5
- package/tools/setup-backends.mjs +88 -77
- package/tools/source-size-check.mjs +310 -0
- package/tools/source-size-config.mjs +244 -0
- package/tools/source-size-core.mjs +59 -0
- package/tools/source-size-gate-cmd.mjs +27 -0
- package/tools/source-size-judge.mjs +114 -0
- package/tools/source-size-refusal.mjs +70 -0
- package/tools/source-size-report.mjs +254 -0
- package/tools/source-size-scope.mjs +145 -0
- package/tools/tracked-tree-census.mjs +102 -0
- package/tools/upgrade-runlist.mjs +92 -0
- package/tools/velocity-profile.mjs +24 -3
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// checker-claim.mjs — what a declared gate cmd CLAIMS about one of the kit's own `--check` tools,
|
|
2
|
+
// as three named outcomes instead of a boolean. A LEAF: node built-ins only, so every surface that
|
|
3
|
+
// must recognize a tool invocation (the source-size matcher, the advisor's probes, the standalone
|
|
4
|
+
// migration's twin) decides through ONE screen.
|
|
5
|
+
// Dependency-free, Node >= 22. No side effects on import.
|
|
6
|
+
|
|
7
|
+
import { lstatSync, realpathSync } from 'node:fs';
|
|
8
|
+
import { isAbsolute, join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
// checker-claim canon >>> BEGIN drift-guarded region
|
|
11
|
+
// Authored TWICE, byte-identically: in the composition root's tools/checker-claim.mjs and in the
|
|
12
|
+
// memory substrate's references/scripts/migrate-gates.mjs. Neither side imports the other — the
|
|
13
|
+
// substrate is standalone and must not depend on the root, and the root must not import mirrored
|
|
14
|
+
// bytes — so a TEXT drift guard beside the root's copy holds them equal. Edit BOTH, then re-run the
|
|
15
|
+
// mirror sync.
|
|
16
|
+
//
|
|
17
|
+
// A cmd makes exactly ONE of three claims about a given tool, and collapsing them into a boolean is
|
|
18
|
+
// what makes a VENDORED copy of the tool read as "the tool is not declared at all" — a false
|
|
19
|
+
// absence, with a remedy (adopt it) that then collides with the entry already there:
|
|
20
|
+
// • canonical — this tool's `--check` invocation, resolving to THIS copy of it
|
|
21
|
+
// • tool-elsewhere — the same invocation shape, resolving to a DIFFERENT real copy
|
|
22
|
+
// • not-the-tool — anything else: another command, a masked form, an inadmissible token, or a
|
|
23
|
+
// path nothing can resolve
|
|
24
|
+
// The realpath anchor never widens: a lookalike file that merely carries the basename is not this
|
|
25
|
+
// tool, whatever it prints. What widens is the VOCABULARY. Stated residual, unchanged by the split:
|
|
26
|
+
// nothing here reads the file's CONTENT, so a byte-swapped file at the canonical path is invisible.
|
|
27
|
+
export const CHECKER_CLAIM = Object.freeze({
|
|
28
|
+
CANONICAL: 'canonical',
|
|
29
|
+
ELSEWHERE: 'tool-elsewhere',
|
|
30
|
+
NOT_THE_TOOL: 'not-the-tool',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// The token is screened by the rules of the quoting it actually carries, because the two halves are
|
|
34
|
+
// interpreted differently and a single screen would be wrong for one of them:
|
|
35
|
+
// • QUOTED — double quotes survive most bytes, so only what breaks OUT of them is refused.
|
|
36
|
+
// • BARE — anything the shell may split, expand or glob makes the executed command different
|
|
37
|
+
// from the string, so a bare token is admitted only from a known-safe alphabet.
|
|
38
|
+
// Either way the point is the same: a path that resolves literally here while the shell would read
|
|
39
|
+
// it differently must never be called a claim about this tool, or the screen certifies a command
|
|
40
|
+
// that never runs.
|
|
41
|
+
export const dqUnsafePath = (text) => [...text].some((ch) => {
|
|
42
|
+
const code = ch.codePointAt(0);
|
|
43
|
+
return ch === '"' || ch === '$' || code === 96 || code === 92 || code === 13 || code === 10;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Stated as the bytes the shell ACTS on, not as an alphabet of blessed ones: an allow-list refuses
|
|
47
|
+
// perfectly ordinary paths (`@`, `+`, `,`, `%`, `=`, anything non-ASCII) that the shell passes
|
|
48
|
+
// through verbatim, and refusing a command that really is canonical is its own defect. Whitespace
|
|
49
|
+
// and ASCII control bytes are refused too — a bare token cannot contain them and still be one token.
|
|
50
|
+
const SHELL_ACTIVE_BARE = new Set([...'"\'\\$|&;<>(){}[]*?!#~^`']);
|
|
51
|
+
const bareTokenSafe = (text) => text.length > 0 && ![...text].some((ch) => {
|
|
52
|
+
const code = ch.codePointAt(0);
|
|
53
|
+
return code <= 0x20 || code === 0x7f || SHELL_ACTIVE_BARE.has(ch);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const RE_META = /[.*+?^${}()|[\]\\]/g;
|
|
57
|
+
|
|
58
|
+
// checkerClaimTool(basename, canonicalPath) → the screen for ONE tool. The shape is the STRICT full
|
|
59
|
+
// command — `node` + ONE (quoted or bare) path token + the exact basename + ` --check` + END — so a
|
|
60
|
+
// masked form (`--check --help`, `--check || true`, a prefix command) is never any claim at all.
|
|
61
|
+
// Separators are PLAIN SPACES, not \s: a newline between the tokens is not a command a runner would
|
|
62
|
+
// execute as written. The basename is regex-escaped here, never by the caller — a caller-escaped
|
|
63
|
+
// literal is one forgotten backslash away from a dot matching any byte.
|
|
64
|
+
export const checkerClaimTool = (basename, canonicalPath) => {
|
|
65
|
+
const safe = basename.replace(RE_META, '\\$&');
|
|
66
|
+
return Object.freeze({
|
|
67
|
+
re: new RegExp(`^node +(?:"((?:[^"]*[/\\\\])?${safe})"|((?:[^\\s"]*[/\\\\])?${safe})) +--check$`),
|
|
68
|
+
canonical: canonicalPath,
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// classifyCheckerClaim(tool, cmd, projectDir) → one CHECKER_CLAIM value. Every unresolvable side
|
|
73
|
+
// fails CLOSED to `not-the-tool`: an unresolvable path is not evidence the tool lives elsewhere, it
|
|
74
|
+
// is evidence nothing can be told about it — and `tool-elsewhere` is a claim a consumer ACTS on.
|
|
75
|
+
//
|
|
76
|
+
// Two screens beyond the shape, for the same reason the quoting screens exist — a claim must never
|
|
77
|
+
// be minted for a command that cannot run the tool as written:
|
|
78
|
+
// • a token starting with `-` is an OPTION to node, whatever it resolves to on disk. (First-order,
|
|
79
|
+
// like the producer canon's own leading-`-` rule: `{x,-y}` still defeats it, and the cost of a
|
|
80
|
+
// miss is only a withheld claim.)
|
|
81
|
+
// • the RESOLVED target must be a REGULAR FILE. A directory or a FIFO carrying the basename
|
|
82
|
+
// resolves perfectly well and is not a copy of anything; `realpathSync` succeeding proves a path
|
|
83
|
+
// exists, never that it is a tool. lstat runs AFTER realpath, so there is no link left to follow.
|
|
84
|
+
export const classifyCheckerClaim = (tool, cmd, projectDir) => {
|
|
85
|
+
if (typeof cmd !== 'string' || typeof projectDir !== 'string') return CHECKER_CLAIM.NOT_THE_TOOL;
|
|
86
|
+
const match = tool.re.exec(cmd.trim());
|
|
87
|
+
if (!match) return CHECKER_CLAIM.NOT_THE_TOOL;
|
|
88
|
+
const token = match[1] ?? match[2];
|
|
89
|
+
const admissible = match[1] !== undefined ? !dqUnsafePath(token) : bareTokenSafe(token);
|
|
90
|
+
if (!admissible || token.startsWith('-')) return CHECKER_CLAIM.NOT_THE_TOOL;
|
|
91
|
+
const declared = isAbsolute(token) ? token : join(projectDir, token);
|
|
92
|
+
try {
|
|
93
|
+
const resolved = realpathSync(declared);
|
|
94
|
+
if (!lstatSync(resolved).isFile()) return CHECKER_CLAIM.NOT_THE_TOOL;
|
|
95
|
+
return resolved === realpathSync(tool.canonical) ? CHECKER_CLAIM.CANONICAL : CHECKER_CLAIM.ELSEWHERE;
|
|
96
|
+
} catch {
|
|
97
|
+
return CHECKER_CLAIM.NOT_THE_TOOL;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
// checker-claim canon <<< END drift-guarded region
|
|
@@ -13,13 +13,29 @@
|
|
|
13
13
|
//
|
|
14
14
|
// The destination is written against AW_GIT_DIR, which run-gates exports to every gate child on a
|
|
15
15
|
// plain run AND on --final (AW_LCOV_FILE is --final only), so one cmd survives the unmet
|
|
16
|
-
// producer-variable preflight in both modes. The
|
|
17
|
-
//
|
|
16
|
+
// producer-variable preflight in both modes. The `:?` is not decoration either: this cmd is also
|
|
17
|
+
// PASTE-READY, and the required-parameter form makes bash refuse BY NAME when AW_GIT_DIR is unset
|
|
18
|
+
// or EMPTY, where a bare `$AW_GIT_DIR` expanded to empty and wrote the lcov to the filesystem ROOT.
|
|
19
|
+
// Residual, stated: `:?` says nothing about the value's ORIGIN — a STALE exported AW_GIT_DIR
|
|
20
|
+
// expands fine and the lcov lands under it; only the runner's own injection makes it the right dir.
|
|
21
|
+
// The explicit stdout reporter keeps the human stream: without it the lcov reporter swallows the
|
|
22
|
+
// TAP/spec output.
|
|
18
23
|
export const UNIT_TESTS_COVERAGE_FLAGS =
|
|
19
|
-
'--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout';
|
|
24
|
+
'--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="${AW_GIT_DIR:?exported by run-gates}/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout';
|
|
20
25
|
|
|
21
|
-
//
|
|
26
|
+
// Every flag set the kit has EVER emitted — APPEND-ONLY, newest first. Emission uses the head; the
|
|
27
|
+
// tail exists so a declaration written by an EARLIER kit and living on disk in a deployed project
|
|
28
|
+
// keeps reading as the producer it is. De-recognizing a prior form would silently reclassify a
|
|
29
|
+
// working suite gate as customized and withhold the checker over it.
|
|
30
|
+
export const KNOWN_COVERAGE_FLAG_SETS = Object.freeze([
|
|
31
|
+
UNIT_TESTS_COVERAGE_FLAGS,
|
|
32
|
+
'--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
// The ONE suite body that produces that lcov with no extra dependency (the EMITTED form), beside
|
|
36
|
+
// the closed set of bodies recognition accepts.
|
|
22
37
|
export const COVERAGE_PRODUCER_BODY = `node --test ${UNIT_TESTS_COVERAGE_FLAGS}`;
|
|
38
|
+
const KNOWN_PRODUCER_BODIES = Object.freeze(KNOWN_COVERAGE_FLAG_SETS.map((flags) => `node --test ${flags}`));
|
|
23
39
|
|
|
24
40
|
// The per-PM exec wrappers a fill offer puts that body behind. Recognition must cover every form
|
|
25
41
|
// the kit has EMITTED, so the prefixes are matched literally; gates-init's execCmdFor stays the one
|
|
@@ -52,8 +68,9 @@ const PRODUCER_EXEC_PREFIXES = Object.freeze([
|
|
|
52
68
|
const PRODUCER_PATH_TOKEN = /^(?!-)[A-Za-z0-9_./*{},:@+=~?[\]!'"-]+$/;
|
|
53
69
|
const pathShapedTail = (tail) => tail === '' || tail.split(/[ \t]+/).every((token) => PRODUCER_PATH_TOKEN.test(token));
|
|
54
70
|
const carriesProducerBody = (text) =>
|
|
55
|
-
|
|
56
|
-
|
|
71
|
+
KNOWN_PRODUCER_BODIES.some(
|
|
72
|
+
(body) => text === body || (text.startsWith(`${body} `) && pathShapedTail(text.slice(body.length).trim())),
|
|
73
|
+
);
|
|
57
74
|
|
|
58
75
|
// matchesCoverageProducer(cmd) → CLOSED-WORLD over the full command forms the kit emits, never a
|
|
59
76
|
// substring probe: `echo "$AW_GIT_DIR/agent-workflow-lcov.info"`, a half-written reporter flag set,
|
|
@@ -65,4 +82,24 @@ export const matchesCoverageProducer = (cmd) => {
|
|
|
65
82
|
if (carriesProducerBody(trimmed)) return true;
|
|
66
83
|
return PRODUCER_EXEC_PREFIXES.some((prefix) => trimmed.startsWith(prefix) && carriesProducerBody(trimmed.slice(prefix.length)));
|
|
67
84
|
};
|
|
85
|
+
|
|
86
|
+
// isCoverageProducerGate(gate) → the GATE-level producer question, and the ONE predicate every
|
|
87
|
+
// consumer asks it through: does THIS declared entry write the lcov the canonical checker reads?
|
|
88
|
+
// Exactly two ways to be one — the cmd passes the closed world above, or the declaration CLAIMS
|
|
89
|
+
// production through the optional `lcovProducer` marker. The marker exists because the closed world
|
|
90
|
+
// is a `node --test` world: a project whose primary suite is another runner has NO cmd form
|
|
91
|
+
// recognition can accept, so without it the checker over such a suite reads as a dead pair forever.
|
|
92
|
+
// Recognition itself never widens (anti-squatter) — the marker is a declared claim, not a new
|
|
93
|
+
// grammar. Only the literal `true` claims: any truthy value would let the string "false" certify.
|
|
94
|
+
// And the claim is about the DECLARATION, never the run — a marked gate that produces no lcov still
|
|
95
|
+
// ends `skipped-no-lcov` / `attested=no` at run time.
|
|
96
|
+
// An entry with no RUNNABLE cmd claims nothing (fail closed): no string cmd, an empty or
|
|
97
|
+
// whitespace-only one, or one carrying an embedded newline. The strict validator already refuses all
|
|
98
|
+
// three, but this predicate has a SECOND host — the standalone migration's loader is deliberately
|
|
99
|
+
// lenient — and a marker must never make a checker pair with an entry that runs nothing there.
|
|
100
|
+
export const isCoverageProducerGate = (gate) => {
|
|
101
|
+
if (gate === null || typeof gate !== 'object' || Array.isArray(gate) || typeof gate.cmd !== 'string') return false;
|
|
102
|
+
if (gate.cmd.trim() === '' || /[\r\n]/.test(gate.cmd)) return false;
|
|
103
|
+
return matchesCoverageProducer(gate.cmd) || gate.lcovProducer === true;
|
|
104
|
+
};
|
|
68
105
|
// coverage-producer canon <<< END drift-guarded region
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// direct-run.mjs — the ONE direct-invocation predicate for kit modules, plus the EXPLICIT registry of
|
|
2
|
+
// library-only modules and the refusal they print when someone runs one as a command.
|
|
3
|
+
//
|
|
4
|
+
// Two facts this leaf owns:
|
|
5
|
+
//
|
|
6
|
+
// 1. WAS this module the process entry point? Compared by REAL path, not lexically. ESM resolves a
|
|
7
|
+
// symlinked entry point to its target, so `import.meta.url === pathToFileURL(process.argv[1]).href`
|
|
8
|
+
// is FALSE whenever a tool is invoked through a link — a CLI whose gate reads that way exits 0
|
|
9
|
+
// having run nothing, which reads as PASS. The realpath compare was fixed once inside the
|
|
10
|
+
// source-size writer; this module is that fix extracted, so every consumer shares it (the
|
|
11
|
+
// THE-LEXICAL-DIRECT-RUN-GUARD class stays closed in one place instead of per file).
|
|
12
|
+
//
|
|
13
|
+
// 2. WHICH modules have no CLI at all. A library-only module invoked directly today runs its
|
|
14
|
+
// top-level code, prints nothing, and exits 0 — indistinguishable from a tool that worked. The
|
|
15
|
+
// registry below turns that silence into a one-line pointer at the command that DOES the thing,
|
|
16
|
+
// and a non-zero exit.
|
|
17
|
+
//
|
|
18
|
+
// The registry is EXPLICIT, never a repo-wide heuristic (D6). Its membership rule — the one the
|
|
19
|
+
// completeness test enforces — is REACHABILITY BY NAME: a module a mode doc names is a module an
|
|
20
|
+
// agent or a user can try to run, and it is exactly those that need a pointer. A library module no
|
|
21
|
+
// document names is unreachable by name and stays out.
|
|
22
|
+
//
|
|
23
|
+
// A pure leaf: it imports NOTHING from tools/ (the read-graph purity walk depends on that) and has no
|
|
24
|
+
// side effect on import.
|
|
25
|
+
|
|
26
|
+
import { realpathSync } from 'node:fs';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { basename } from 'node:path';
|
|
29
|
+
|
|
30
|
+
// The usage exit code of the family's CLIs — invoking a library module is a usage error, not a
|
|
31
|
+
// precondition failure.
|
|
32
|
+
export const DIRECT_RUN_USAGE_EXIT = 2;
|
|
33
|
+
|
|
34
|
+
// Compared by REAL path (see (1) above). Exported as a test seam: the unresolvable arm cannot be
|
|
35
|
+
// reached through a real invocation, where an existing entry point is a precondition.
|
|
36
|
+
export const sameFile = (a, b) => {
|
|
37
|
+
try {
|
|
38
|
+
return realpathSync(a) === realpathSync(b);
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// isDirectRun(import.meta.url) → was THIS module the entry point of the current process?
|
|
45
|
+
export const isDirectRun = (moduleUrl, argv1 = process.argv[1]) =>
|
|
46
|
+
Boolean(argv1) && sameFile(fileURLToPath(moduleUrl), argv1);
|
|
47
|
+
|
|
48
|
+
// The registry: library-only module → the command that does what someone reaching for it wants. The
|
|
49
|
+
// value is a user-facing command token of the kit's own catalog (commands.mjs), pinned by the test —
|
|
50
|
+
// never a raw node invocation, which would just move the confusion one file over.
|
|
51
|
+
export const LIBRARY_ONLY_MODULES = Object.freeze({
|
|
52
|
+
// Named by references/modes/upgrade.md as the source of truth for the _README refresh decision; the
|
|
53
|
+
// command that APPLIES that decision is the ensure CLI, and the one that edits the config by intent
|
|
54
|
+
// is set-recipe.
|
|
55
|
+
'orchestration-config.mjs': '/agent-workflow-kit set-recipe',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// The frozen refusal line. One line, names the module, names the command.
|
|
59
|
+
export const libraryOnlyLine = (name) => {
|
|
60
|
+
const command = LIBRARY_ONLY_MODULES[name];
|
|
61
|
+
if (command === undefined) {
|
|
62
|
+
throw new Error(`[agent-workflow-kit] ${name} is not in LIBRARY_ONLY_MODULES — register it before guarding it`);
|
|
63
|
+
}
|
|
64
|
+
return `${name}: library module — no CLI; use ${command}`;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// refuseDirectRun(import.meta.url) — the guard a library-only module ends with. A no-op when imported
|
|
68
|
+
// (the overwhelmingly common case); on a DIRECT run it prints the pointer and sets a non-zero exit
|
|
69
|
+
// code. `process.exitCode` rather than `process.exit`: nothing else is pending on a direct run, and a
|
|
70
|
+
// hard exit inside module evaluation would be a side effect of import if the predicate ever misfired.
|
|
71
|
+
export const refuseDirectRun = (moduleUrl, deps = {}) => {
|
|
72
|
+
if (!isDirectRun(moduleUrl, deps.argv1 ?? process.argv[1])) return 0;
|
|
73
|
+
(deps.errlog ?? console.error)(libraryOnlyLine(basename(fileURLToPath(moduleUrl))));
|
|
74
|
+
(deps.setExitCode ?? ((code) => { process.exitCode = code; }))(DIRECT_RUN_USAGE_EXIT);
|
|
75
|
+
return DIRECT_RUN_USAGE_EXIT;
|
|
76
|
+
};
|
package/tools/doc-parity.mjs
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
ACKS_FILE,
|
|
34
34
|
} from './recommendations.mjs';
|
|
35
35
|
import { SKIPPED_READONLY } from './setup-backends.mjs';
|
|
36
|
+
// The parity verdicts that read-only skip may report — a CLOSED set the same two mode docs enumerate.
|
|
37
|
+
import { PARITY } from './refresh-parity.mjs';
|
|
36
38
|
// The host-conditional qualifier every settings-derived runtime claim carries (Decision 11).
|
|
37
39
|
import { HOST_HONORS_QUALIFIER } from './velocity-profile.mjs';
|
|
38
40
|
import { LATENT_ARM_NOTICE } from './review-state.mjs';
|
|
@@ -47,6 +49,14 @@ import { RECEIPT_DEADLINE_CONTRACT } from './receipt-deadline.mjs';
|
|
|
47
49
|
import { DISPATCH_CONTRACT } from './dispatch.mjs';
|
|
48
50
|
// The coverage vocabulary leaf: a CLOSED value set the gates contract doc must enumerate.
|
|
49
51
|
import { COVERAGE } from './coverage-state.mjs';
|
|
52
|
+
// The canonical producer body: gates.md prints the whole command byte for byte, so the doc is a
|
|
53
|
+
// hand copy of a moving constant unless it is bound here.
|
|
54
|
+
import { COVERAGE_PRODUCER_BODY } from './coverage-producer.mjs';
|
|
55
|
+
// The ensure outcomes upgrade.md relays: the doc enumerates them for the agent, so an outcome the
|
|
56
|
+
// tool renames or drops must fail here rather than leave the doc teaching a vocabulary nobody emits.
|
|
57
|
+
// Imported from the VOCABULARY leaf, never from the ops: a read-only lint must not pull the ensure
|
|
58
|
+
// implementation — and through it the orchestration writer — into its import graph.
|
|
59
|
+
import { RELAYED_ENSURE_TOKENS } from './ensure-vocabulary.mjs';
|
|
50
60
|
|
|
51
61
|
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
52
62
|
|
|
@@ -116,6 +126,18 @@ export const BINDINGS = Object.freeze([
|
|
|
116
126
|
// (setup.md owns --refresh-placed; upgrade.md pastes its lines) — a reworded doc dropping the
|
|
117
127
|
// outcome fails this pin plus the gate. The token tracks the exported SETUP constant.
|
|
118
128
|
valueBinding('refresh-skipped-readonly', SKIPPED_READONLY, SKIPPED_READONLY, [SETUP_DOC, UPGRADE_DOC]),
|
|
129
|
+
// And the parity verdict that outcome now carries (feedback-hardening Plan 1 F3 / D3): the skip
|
|
130
|
+
// line used to assert "PARTIALLY updated" and "drift persists" unconditionally, so the docs
|
|
131
|
+
// described a post-state nothing had checked. The verdict set is CLOSED — one binding per value,
|
|
132
|
+
// backticked so a bare word in prose cannot pass for the pinned token — and both docs that
|
|
133
|
+
// enumerate the refresh outcomes must carry all three, or a reworded doc goes back to promising a
|
|
134
|
+
// claim the tool no longer makes.
|
|
135
|
+
...Object.values(PARITY).map((token) => valueBinding(`refresh-parity:${token}`, token, `\`${token}\``, [SETUP_DOC, UPGRADE_DOC])),
|
|
136
|
+
// The project-configuration ensures (feedback-hardening Plan 1 F4): upgrade.md used to PRESCRIBE
|
|
137
|
+
// each of the four as prose, so the doc was the only place the outcome set existed and drifted for
|
|
138
|
+
// free. Now one command performs them and the doc enumerates its tokens — backticked, so a bare
|
|
139
|
+
// word in a sentence cannot pass for the pinned outcome.
|
|
140
|
+
...RELAYED_ENSURE_TOKENS.map((token) => valueBinding(`ensure-outcome:${token}`, token, `\`${token}\``, [UPGRADE_DOC])),
|
|
119
141
|
// The "the tool knows and does not say" contract: a clean-tree PASS must still name a latent arm.
|
|
120
142
|
// It was a prose-only bar a doc could silently drop, so it is pinned to the live string the tool
|
|
121
143
|
// actually emits — a reworded doc dropping the notice fails this pin plus the gate.
|
|
@@ -163,6 +185,13 @@ export const BINDINGS = Object.freeze([
|
|
|
163
185
|
// CLOSED value set, so a renamed or added value fails here instead of leaving the doc describing
|
|
164
186
|
// a vocabulary the runner no longer speaks. One binding per value — the set is small and closed.
|
|
165
187
|
...Object.values(COVERAGE).map((value) => valueBinding(`coverage-state:${value}`, value, `\`coverage=${value}\``, [GATES_DOC])),
|
|
188
|
+
// The canonical producer body: gates.md prints the whole command byte for byte, so the doc is a
|
|
189
|
+
// HAND COPY of a constant that moves (it moved once already, when the destination became a
|
|
190
|
+
// required-parameter expansion). Nothing else pinned it — the drift guard holds the two authored
|
|
191
|
+
// copies of the code region equal, and the producer test pins the shipped template, but neither
|
|
192
|
+
// sees the doc. Bound here, a future move fails a declared gate instead of leaving the contract
|
|
193
|
+
// doc quietly describing a command the kit no longer emits.
|
|
194
|
+
valueBinding('coverage-producer-body', COVERAGE_PRODUCER_BODY, COVERAGE_PRODUCER_BODY, [GATES_DOC]),
|
|
166
195
|
].map((b) => Object.freeze(b)));
|
|
167
196
|
|
|
168
197
|
// ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
|
|
@@ -209,13 +238,15 @@ Usage:
|
|
|
209
238
|
A CLOSED, exported registry binds each live code constant — the autonomy-doctor contract (the EXIT
|
|
210
239
|
table, the status tokens, the trusted-dir allowlist), the recommendations/upgrade presentation
|
|
211
240
|
contract (section header, empty line, verdict templates), the acks-store path, the host-conditional
|
|
212
|
-
qualifier every settings-derived runtime claim carries, the setup refresh degrade token
|
|
241
|
+
qualifier every settings-derived runtime claim carries, the setup refresh degrade token and the three
|
|
242
|
+
parity verdicts it reports, the review-state clean-tree latent-arm notice, the worktrees provision-record
|
|
213
243
|
orientation contract (shared-queue rule, landing-from-main, no-dependencies install posture), the
|
|
214
244
|
worktrees cleanup-ownership rule, the worktrees include-identity rule, the worktrees
|
|
215
245
|
resume-verify rule, the flow tolerate contract (the accepted flow schema version + the
|
|
216
246
|
lagging-kit sentence, procedures.md), the receipt-deadline arrival contract, the dispatch engine's
|
|
217
|
-
FORM-only + aggregate-refusal contract (dispatch.md),
|
|
218
|
-
vocabulary (gates.md)
|
|
247
|
+
FORM-only + aggregate-refusal contract (dispatch.md), the runner's closed coverage= summary
|
|
248
|
+
vocabulary (gates.md), and the canonical coverage-producer-body command the same doc prints in full
|
|
249
|
+
(gates.md) — to
|
|
219
250
|
the exact token its references/modes/*.md contract must carry, and
|
|
220
251
|
asserts the CURRENT value renders into every bound file. A drifted doc, an unreadable bound file,
|
|
221
252
|
or an absent token FAILS CLOSED.
|
package/tools/engine-source.mjs
CHANGED
|
@@ -111,7 +111,7 @@ export const detectEngine = (engineDir, { source, rel } = {}, deps = {}) => {
|
|
|
111
111
|
? `engine manifest name "${report.name}" is not "${EXPECTED_ENGINE_NAME}"`
|
|
112
112
|
: report.available === false
|
|
113
113
|
? 'engine manifest is a declared stub (available:false)'
|
|
114
|
-
: `engine
|
|
114
|
+
: `a required engine file is missing (${fragmentRel})`;
|
|
115
115
|
return { ok, reason, dir: engineDir };
|
|
116
116
|
};
|
|
117
117
|
|
|
@@ -124,15 +124,19 @@ export const detectEngine = (engineDir, { source, rel } = {}, deps = {}) => {
|
|
|
124
124
|
export const readEngineFragment = (engineDir, deps = {}) => {
|
|
125
125
|
const detection = detectEngine(engineDir, { source: deps.source, rel: deps.rel }, deps);
|
|
126
126
|
const installHint = `npx @sabaiway/agent-workflow-engine@latest init (or set ${ENGINE_ENV})`;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
// The typed {stable, reason} pair lets a consumer split the classified human line from the raw
|
|
128
|
+
// diagnostic (its machine-line channel) without parsing the message; .message stays whole for
|
|
129
|
+
// consumers that print one line.
|
|
130
|
+
const installMe = (reason) => Object.assign(
|
|
131
|
+
new Error(`methodology engine not found/invalid at ${engineDir} (${reason}) — install it: ${installHint}`),
|
|
132
|
+
{ stable: `methodology engine not found/invalid at ${engineDir} — install it: ${installHint}`, reason },
|
|
133
|
+
);
|
|
134
|
+
if (!detection.ok) throw installMe(detection.reason);
|
|
130
135
|
const read = deps.readFileSync ?? readFileSync;
|
|
136
|
+
const fragmentRel = deps.rel ?? ENGINE_FRAGMENT_REL;
|
|
131
137
|
try {
|
|
132
|
-
return read(join(engineDir,
|
|
138
|
+
return read(join(engineDir, fragmentRel), 'utf8');
|
|
133
139
|
} catch (err) {
|
|
134
|
-
throw
|
|
135
|
-
`methodology engine not found/invalid at ${engineDir} (fragment unreadable: ${err.message}) — install it: ${installHint}`,
|
|
136
|
-
);
|
|
140
|
+
throw installMe(`a required engine file is unreadable (${fragmentRel}): ${err.message}`);
|
|
137
141
|
}
|
|
138
142
|
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ensure-configs.mjs — ONE runnable command for the four stamp-independent upgrade ensures:
|
|
3
|
+
//
|
|
4
|
+
// orchestration docs/ai/orchestration.json seed, or refresh a still-canonical onboarding note
|
|
5
|
+
// gates docs/ai/gates.json seed-if-missing (an existing declaration is authored content)
|
|
6
|
+
// autonomy docs/ai/autonomy.json seed-if-missing (same)
|
|
7
|
+
// scripts scripts/<ADR enforcement> seed-if-missing, ADR-layout detect FIRST
|
|
8
|
+
//
|
|
9
|
+
// Each was prose in references/modes/upgrade.md that an agent performed by hand. One command instead
|
|
10
|
+
// of four is deliberate: four independent runs would be four chances to skip one, and the mode doc now
|
|
11
|
+
// has a single invocation point whose four outcome lines it relays.
|
|
12
|
+
//
|
|
13
|
+
// The contract (pinned by this module's tests):
|
|
14
|
+
// • --reconcile is REQUIRED. A bare run is a usage error, so nothing writes by accident.
|
|
15
|
+
// • --dry-run reports `would-*` tokens and writes nothing — never a write token.
|
|
16
|
+
// • The ops run in a FIXED order and one op's failure NEVER skips the rest: every op reports its own
|
|
17
|
+
// token, and the exit is non-zero when any of them failed.
|
|
18
|
+
// • The deployment gate runs ONCE, before any op: an absent/symlinked docs/ai stops the whole run
|
|
19
|
+
// with the gate's own message rather than four copies of it.
|
|
20
|
+
//
|
|
21
|
+
// Output is ENGLISH/structured (repo-artifact Hard Constraint); the agent localizes when narrating.
|
|
22
|
+
// Exit codes: 0 every op fine · 1 an op failed, or the deployment gate stopped the run · 2 usage.
|
|
23
|
+
// main(argv, ctx) → { code, stdout, stderr }; cwd + fs are injectable for host-independent tests.
|
|
24
|
+
//
|
|
25
|
+
// Dependency-free, Node >= 22. No side effects on import (the isDirectRun idiom).
|
|
26
|
+
|
|
27
|
+
import { dirname, resolve } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { assertDocsAiDeployment } from './atomic-write.mjs';
|
|
30
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
31
|
+
import { ENSURE_IMPLEMENTATIONS, ENSURE_OPS, failedOutcome } from './ensure-ops.mjs';
|
|
32
|
+
|
|
33
|
+
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
|
|
35
|
+
const EXIT_OK = 0;
|
|
36
|
+
const EXIT_FAILED = 1;
|
|
37
|
+
const EXIT_USAGE = 2;
|
|
38
|
+
|
|
39
|
+
const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
40
|
+
|
|
41
|
+
const EMPTY_CWD = '--cwd needs a path argument (an empty value would silently mean the current directory)';
|
|
42
|
+
|
|
43
|
+
const HELP = `ensure-configs — the four stamp-independent upgrade ensures, as ONE command.
|
|
44
|
+
|
|
45
|
+
Usage:
|
|
46
|
+
node ensure-configs.mjs --reconcile [--dry-run] [--cwd <project>]
|
|
47
|
+
|
|
48
|
+
--reconcile required — run the four ensures (orchestration, gates, autonomy, scripts)
|
|
49
|
+
--dry-run report what each ensure WOULD do; write nothing
|
|
50
|
+
--cwd <dir> the target project (default: the current directory)
|
|
51
|
+
--help, -h this help
|
|
52
|
+
|
|
53
|
+
Every seed is CREATE-ONLY: an existing file is preserved byte-for-byte, never clobbered and never
|
|
54
|
+
refreshed in place. The one refresh is the orchestration onboarding note, and only while it still
|
|
55
|
+
matches a canonical the kit shipped — your own wording is preserved verbatim. The enforcement-script
|
|
56
|
+
ensure detects an older ADR-store layout FIRST and instructs the opt-in migration instead of seeding.
|
|
57
|
+
|
|
58
|
+
Exit codes: 0 every op fine; 1 an op failed (its line says so) or there is no deployment here; 2 usage.`;
|
|
59
|
+
|
|
60
|
+
// argv → { reconcile, dryRun, cwd, help }. Order-independent; an unknown flag or a missing --cwd
|
|
61
|
+
// value is a usage error, never a silently-ignored argument.
|
|
62
|
+
export const parseArgs = (argv) => {
|
|
63
|
+
const out = { reconcile: false, dryRun: false, cwd: undefined, help: false };
|
|
64
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
65
|
+
const a = argv[i];
|
|
66
|
+
if (a === '--help' || a === '-h') out.help = true;
|
|
67
|
+
else if (a === '--reconcile') out.reconcile = true;
|
|
68
|
+
else if (a === '--dry-run') out.dryRun = true;
|
|
69
|
+
else if (a === '--cwd') {
|
|
70
|
+
// An EMPTY value resolves to the ambient cwd — a writing CLI would then act on a different
|
|
71
|
+
// project than the caller named, silently. Both spellings refuse it.
|
|
72
|
+
const next = argv[i + 1];
|
|
73
|
+
if (next === undefined || next === '' || next.startsWith('-')) throw fail(EXIT_USAGE, EMPTY_CWD);
|
|
74
|
+
out.cwd = next;
|
|
75
|
+
i += 1;
|
|
76
|
+
} else if (a.startsWith('--cwd=')) {
|
|
77
|
+
const value = a.slice('--cwd='.length);
|
|
78
|
+
if (value === '') throw fail(EXIT_USAGE, EMPTY_CWD);
|
|
79
|
+
out.cwd = value;
|
|
80
|
+
} else throw fail(EXIT_USAGE, `unknown argument: ${a}`);
|
|
81
|
+
}
|
|
82
|
+
if (!out.help && !out.reconcile) {
|
|
83
|
+
throw fail(EXIT_USAGE, 'nothing to do — pass --reconcile (see --help). This tool never writes without it.');
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Run every op in ENSURE_OPS order. A throw from one op becomes THAT op's failed outcome — the
|
|
89
|
+
// remaining ops still run, because a project missing its gate declaration should not also be left
|
|
90
|
+
// without its autonomy seed just because the first ensure hit an unreadable file.
|
|
91
|
+
export const runEnsures = ({ cwd, kitRoot, dryRun, deps }) =>
|
|
92
|
+
ENSURE_OPS.map((op) => {
|
|
93
|
+
try {
|
|
94
|
+
return ENSURE_IMPLEMENTATIONS[op]({ cwd, kitRoot, dryRun, deps });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return failedOutcome(op, err);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const render = (outcomes, dryRun) => {
|
|
101
|
+
// The banner names the tool + the flag it ran under (both machine tokens the L2 rule exempts);
|
|
102
|
+
// the failure footer is a user-grade sentence — the composed-lines guard scans both.
|
|
103
|
+
const lines = [dryRun ? 'ensure-configs (--reconcile, dry run — nothing written)' : 'ensure-configs (--reconcile)'];
|
|
104
|
+
for (const o of outcomes) {
|
|
105
|
+
lines.push(` ${o.op}: ${o.token}`);
|
|
106
|
+
for (const detail of o.lines) lines.push(` ${detail}`);
|
|
107
|
+
}
|
|
108
|
+
if (outcomes.some((o) => o.failed)) {
|
|
109
|
+
// No blanket claim about what was written: an op that copies file by file can stop PARTWAY, and
|
|
110
|
+
// its own lines are the only accurate account of what landed.
|
|
111
|
+
lines.push('', ' part of this configuration run did NOT complete — the lines above name the cause, and what was and was not written.');
|
|
112
|
+
}
|
|
113
|
+
return lines.join('\n');
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const main = (argv = [], ctx = {}) => {
|
|
117
|
+
try {
|
|
118
|
+
const args = parseArgs(argv);
|
|
119
|
+
if (args.help) return { code: EXIT_OK, stdout: HELP, stderr: '' };
|
|
120
|
+
const cwd = resolve(args.cwd ?? ctx.cwd ?? process.cwd());
|
|
121
|
+
const deps = ctx.deps ?? {};
|
|
122
|
+
// ONE deployment gate for the whole run (see the header): with no docs/ai there is nothing to
|
|
123
|
+
// reconcile, and four identical STOPs would read as four separate problems.
|
|
124
|
+
assertDocsAiDeployment(cwd, deps, { noun: 'the project configuration', rel: 'under docs/ai' });
|
|
125
|
+
const outcomes = runEnsures({ cwd, kitRoot: ctx.kitRoot ?? KIT_ROOT, dryRun: args.dryRun, deps });
|
|
126
|
+
return {
|
|
127
|
+
code: outcomes.some((o) => o.failed) ? EXIT_FAILED : EXIT_OK,
|
|
128
|
+
stdout: render(outcomes, args.dryRun),
|
|
129
|
+
stderr: '',
|
|
130
|
+
};
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return { code: err.exitCode ?? EXIT_FAILED, stdout: '', stderr: `ensure-configs: ${err.message}` };
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (isDirectRun(import.meta.url)) {
|
|
137
|
+
const r = main(process.argv.slice(2));
|
|
138
|
+
if (r.stdout) console.log(r.stdout);
|
|
139
|
+
if (r.stderr) console.error(r.stderr);
|
|
140
|
+
process.exitCode = r.code;
|
|
141
|
+
}
|