@sabaiway/agent-workflow-kit 1.42.0 → 1.43.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 +35 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/gates.md +1 -1
- package/tools/atomic-write.mjs +13 -0
- package/tools/seed-gates.mjs +160 -55
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,41 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 1.43.0 — Closed-world gate seeding: lifecycle hooks die by construction (AD-052)
|
|
8
|
+
|
|
9
|
+
A **feature** release (kit-only; memory/engine/bridges unchanged, lineage head stays `2.0.0`). The
|
|
10
|
+
consent-gated `gates.json` seeder (`seed-gates.mjs`) moves from BLOCKLIST screening to a
|
|
11
|
+
**closed-world** offer derivation — the structural fix for **Issue-011**, whose three residuals the
|
|
12
|
+
AD-042 council could only ever push one gap further, never close. Since a declared gate is
|
|
13
|
+
hook-auto-approvable, the offer is now conservative BY CONSTRUCTION: the worst case is a legit
|
|
14
|
+
command not offered (add it by hand), never a dangerous one offered.
|
|
15
|
+
|
|
16
|
+
- **Uniform hook-free exec form.** The seeded cmd is
|
|
17
|
+
`COREPACK_ENABLE_NETWORK=0 <pm> exec -- <allowlisted-body>` for the detected package manager.
|
|
18
|
+
`exec` runs a command, not a named script, so no `pre<name>`/`post<name>` lifecycle hook can fire
|
|
19
|
+
— structurally and uniformly across **npm, pnpm, and yarn** (classic + berry). Never `<pm> run
|
|
20
|
+
<name>`, which would re-expose hooks and let a later `package.json` edit change what a byte-exact
|
|
21
|
+
approved gate runs.
|
|
22
|
+
- **Body allowlist, not blocklist.** The script body must be a string member of a 9-entry literal
|
|
23
|
+
runner allowlist (`node --test`, `vitest run`, `jest`, `jest --ci`, `eslint .`,
|
|
24
|
+
`prettier --check .`, `tsc --noEmit`, `tsc -p . --noEmit`, `vite build`) after a pinned
|
|
25
|
+
ASCII-only normalization; anything else — an injected `curl … | sh`, a `release:npm` alias, an
|
|
26
|
+
env/path body — is not offered, by non-membership. Editing the allowlist ADDS a test-guarded
|
|
27
|
+
entry; it can never weaken a filter.
|
|
28
|
+
- **Per-PM fail-closed floor.** npm is pinned `--offline --script-shell /bin/sh` (no registry fetch
|
|
29
|
+
of a missing runner; a hostile `.npmrc script-shell` loses); the `COREPACK_ENABLE_NETWORK=0`
|
|
30
|
+
prefix blocks a Corepack-shimmed PM from fetching a hostile `packageManager` pin before exec;
|
|
31
|
+
pnpm/yarn fail closed natively; a package manager whose exec contract cannot be verified is
|
|
32
|
+
WITHHELD with a loud note. Screened-out gate-class scripts are counted and named — never silently
|
|
33
|
+
absent (preview and apply alike).
|
|
34
|
+
- **Preflight parent-chain guard.** `assertDocsAiDeployment` (`atomic-write.mjs`) now walks the
|
|
35
|
+
`docs` parent chain (refusing a symlinked `docs` parent or cwd root) before any read, closing the
|
|
36
|
+
seeder's preview-path escape; all four write consumers inherit it. ENOENT-safe — a brand-new
|
|
37
|
+
project still gets the normal no-deployment stop.
|
|
38
|
+
- **Scoped safety claim.** Safe-by-construction is the OFFER DERIVATION, not a runtime sandbox: a
|
|
39
|
+
script gate runs the project's own tooling (project-controlled code), the disclosed residual
|
|
40
|
+
bounded by the two-consent trust chain. The preview discloses it.
|
|
41
|
+
|
|
7
42
|
## 1.42.0 — Opt-in ADR-store migration mode + old-layout detection (AD-051)
|
|
8
43
|
|
|
9
44
|
A **feature** release, co-released with **memory 2.0.0 (MAJOR)** — the one-file-per-ADR store that
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '1.
|
|
6
|
+
version: '1.43.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.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",
|
|
@@ -16,6 +16,6 @@ Declared gates can also be **auto-approved** (no permission prompt on a byte-exa
|
|
|
16
16
|
|
|
17
17
|
**Candidate line — the review-receipt gate (opt-in, never auto-seeded; AD-021).** Projects that configure a reviewed/council `plan-execution.review` recipe can declare the AD-038 review-state check as one more gate — the exact candidate `{ id, title, cmd }` line and its contract live under `${CLAUDE_SKILL_DIR}/references/modes/review-state.md` (step 3). The template `gates.json` stays EMPTY; adding the line is the maintainer's explicit consent — by hand, or through the consent-gated seeder below (AD-042).
|
|
18
18
|
|
|
19
|
-
**Consent-gated seeding — a separate WRITER CLI, not part of the runner (AD-042).** Bootstrap recon already records the project's own daily commands; the seeder turns them into a preview of `{ id, title, cmd }` entries. Protocol: run `node ${CLAUDE_SKILL_DIR}/tools/seed-gates.mjs --cwd <project>` (dry-run by default — prints the derived entries and **writes NOTHING**; declining leaves the file byte-identical), show the user the EXACT entries (`AskUserQuestion` preview where supported, prose otherwise), and only on an explicit yes run it again with `--apply [--only <id>]…` — it **appends exactly the consented entries** (append-only: existing entries are never modified or removed; an id collision is refused loudly; a malformed declaration is never written over).
|
|
19
|
+
**Consent-gated seeding — a separate WRITER CLI, not part of the runner (AD-042).** Bootstrap recon already records the project's own daily commands; the seeder turns them into a preview of `{ id, title, cmd }` entries. Protocol: run `node ${CLAUDE_SKILL_DIR}/tools/seed-gates.mjs --cwd <project>` (dry-run by default — prints the derived entries and **writes NOTHING**; declining leaves the file byte-identical), show the user the EXACT entries (`AskUserQuestion` preview where supported, prose otherwise), and only on an explicit yes run it again with `--apply [--only <id>]…` — it **appends exactly the consented entries** (append-only: existing entries are never modified or removed; an id collision is refused loudly; a malformed declaration is never written over). The offer is **closed-world** (AD-052): only a terminating-class script NAME (test / lint / type-check / build — never dev/watch/serve, never a write-mode or release/publish/deploy variant) whose BODY is a member of the seeder's literal runner allowlist is offered — membership, never blocklist screening: the worst case is a legit command not offered, never a dangerous one offered. The seeded cmd is the uniform hook-free **`COREPACK_ENABLE_NETWORK=0 <pm> exec -- <allowlisted-body>`** — `exec` runs a command, not a named script, so no pre/post hook can fire (npm/pnpm/yarn alike; never `<pm> run <name>`, which re-exposes hooks), and the Corepack env prefix blocks a hostile `packageManager` pin from fetching the PM binary before exec. npm is pinned `--offline --script-shell /bin/sh` (no fetch of a missing runner; a hostile `.npmrc script-shell` loses); pnpm/yarn refuse an absent runner without network (a user-installed cache/global/PATH runner executing is user machine state — part of the disclosed residual); a family without a verified fail-closed exec contract is WITHHELD loudly. A gate-class script screened out on its BODY is counted and named in a note (add it by hand if you trust it); the review-state candidate above joins automatically on a reviewed/council `plan-execution.review`. **Disclose before the yes** (the preview prints it): the wired hook auto-approves byte-exact declared commands — two separate consents — and a script gate runs project-controlled tooling the seeder does not sandbox (safe-by-construction = the OFFER DERIVATION).
|
|
20
20
|
|
|
21
21
|
**Invariants:** the runner writes nothing by default (`--record` delegates its ONE ledger write to the review-ledger sole writer, `recordGateRun` — the runner itself never opens the ledger) · never commits · never runs a subscription CLI · executes only the project's OWN declared commands (never a kit-invented one) · the bash contract fails loud, never reinterprets · the seeder is a separate consent-per-run CLI — preview-first, append-only, never pre-approved by any velocity tier.
|
package/tools/atomic-write.mjs
CHANGED
|
@@ -53,6 +53,19 @@ export const assertDocsAiDeployment = (cwd, deps = {}, opts = {}) => {
|
|
|
53
53
|
const noun = opts.noun ?? 'a file';
|
|
54
54
|
const rel = opts.rel ?? 'under docs/ai';
|
|
55
55
|
const docsAi = join(cwd, 'docs', 'ai');
|
|
56
|
+
// Parent-chain preflight (AD-052, Issue-011 residual 3): refuse a symlinked cwd ROOT or a
|
|
57
|
+
// symlinked `docs` PARENT before any read — the walk the write path already enforces
|
|
58
|
+
// (writeContainedFileAtomic), aligned onto the gate every consumer runs first. The walk covers
|
|
59
|
+
// the PARENT chain only (the linkManaged precedent, fs-safe.mjs): the docs/ai LEAF keeps the
|
|
60
|
+
// dedicated checks below, whose exact STOP messages each consumer's tests pin. ENOENT-safe —
|
|
61
|
+
// an absent component is a no-op walk, so a brand-new project still reaches the normal
|
|
62
|
+
// "no deployment" STOP below. The walk throws a plain Error; re-throw as the CALLER's typed
|
|
63
|
+
// stop so every consumer's `.code` contract holds.
|
|
64
|
+
try {
|
|
65
|
+
assertContainedRealPath(cwd, join(cwd, 'docs'), { lstat });
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw stop(String(err?.message ?? err).replace(/^\[agent-workflow-kit\] /, ''));
|
|
68
|
+
}
|
|
56
69
|
const docsAiStat = lstatNoFollow(docsAi, lstat);
|
|
57
70
|
if (docsAiStat === null) {
|
|
58
71
|
throw stop(`no agent-workflow deployment here (docs/ai is absent) — run init/bootstrap before writing ${rel}`);
|
package/tools/seed-gates.mjs
CHANGED
|
@@ -6,14 +6,28 @@
|
|
|
6
6
|
// EMPTY (AD-021/AD-038): a populated declaration is per-entry maintainer consent recorded through
|
|
7
7
|
// this preview, never auto-seeding.
|
|
8
8
|
//
|
|
9
|
-
// What it offers (the derivation invariants, test-pinned):
|
|
9
|
+
// What it offers (the derivation invariants, test-pinned) — CLOSED-WORLD since AD-052: an entry
|
|
10
|
+
// is offered only when every axis is proven safe by MEMBERSHIP in a finite, test-guarded set,
|
|
11
|
+
// never by absence from a blocklist (Issue-011 killed the blocklist model — one more gap per
|
|
12
|
+
// review round, unprovable):
|
|
10
13
|
// • sources: discoverGateCandidates over package.json scripts (velocity-profile.mjs stays
|
|
11
14
|
// read-only; THIS module owns the offer mapping) + the conditional review-state candidate;
|
|
12
|
-
// • warn-flagged candidates (release/publish/
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// never
|
|
16
|
-
// •
|
|
15
|
+
// • NAME screens shape WHICH entries are offered: warn-flagged candidates (release/publish/
|
|
16
|
+
// deploy/push/version/commit/tag, pre*/post*) never enter; only terminating verification
|
|
17
|
+
// class NAMES (test / lint / type-check / build) pass, never dev/start/watch/serve/preview,
|
|
18
|
+
// never a mutating-variant name; a shell-unsafe name is screened first;
|
|
19
|
+
// • the BODY must be a string member of the literal BODY_ALLOWLIST after a pinned ASCII-only
|
|
20
|
+
// normalization — anything else is screened out with a LOUD note (never silently absent);
|
|
21
|
+
// • the cmd is the per-PM HOOK-FREE form `COREPACK_ENABLE_NETWORK=0 <pm> exec -- <allowlisted-body>`
|
|
22
|
+
// (packageManager field, else lockfile probe, else npm): `exec` runs a command, not a named
|
|
23
|
+
// script, so the pre/post lifecycle-hook class dies structurally on npm/pnpm/yarn alike, and
|
|
24
|
+
// the Corepack env prefix blocks a hostile packageManager pin from fetching the PM binary; a
|
|
25
|
+
// family with no verified fail-closed exec contract is WITHHELD with a loud note. NEVER
|
|
26
|
+
// `<pm> run <name>` — that re-exposes hooks and lets a later package.json edit change a
|
|
27
|
+
// byte-exact-approved gate;
|
|
28
|
+
// • the safety claim is scoped to the OFFER DERIVATION, not a runtime sandbox: a gate still
|
|
29
|
+
// executes project-controlled tooling (a node_modules/.bin PATH shim intercepts under every
|
|
30
|
+
// form) — the documented residual, disclosed in the preview, bounded by the two consents;
|
|
17
31
|
// • ids derive kebab-case from script names (build:prod → build-prod) and every offered entry
|
|
18
32
|
// passes the runner's validateDeclaration (the seeder imports the validator — NEVER the
|
|
19
33
|
// reverse: run-gates.mjs stays a runner that writes nothing);
|
|
@@ -62,33 +76,35 @@ const usageFail = (message) =>
|
|
|
62
76
|
export const TRUST_CHAIN_DISCLOSURE =
|
|
63
77
|
'Disclosure: once the optional approval hook is wired (/agent-workflow-kit hook), it ' +
|
|
64
78
|
'auto-approves byte-exact declared gate commands from the project root — seeding (this consent) ' +
|
|
65
|
-
'and hook wiring (its own consent) are two separate yeses.'
|
|
79
|
+
'and hook wiring (its own consent) are two separate yeses. A script gate runs the project\'s ' +
|
|
80
|
+
'own tooling, which executes project-controlled code (the seeder does not sandbox it; the two ' +
|
|
81
|
+
'consents bound it).';
|
|
66
82
|
|
|
67
83
|
const USAGE = `usage: seed-gates [--dry-run | --apply] [--only <id>]... [--cwd <dir>] [--help]
|
|
68
84
|
|
|
69
85
|
Consent-gated seeder for the project's own docs/ai/gates.json. Default is --dry-run: prints the
|
|
70
86
|
derived { id, title, cmd } entries and writes NOTHING. --apply APPENDS exactly the consented
|
|
71
87
|
entries (--only <id> selects a subset; append-only — existing entries are never modified or
|
|
72
|
-
removed, an id collision is refused).
|
|
73
|
-
(test / lint / type-check / build
|
|
74
|
-
|
|
88
|
+
removed, an id collision is refused). The offer is CLOSED-WORLD: only a terminating-class script
|
|
89
|
+
name (test / lint / type-check / build — never release/publish/deploy, never watch/serve, never a
|
|
90
|
+
write-mode variant) whose BODY is a member of the literal runner allowlist is offered, as the
|
|
91
|
+
hook-free \`COREPACK_ENABLE_NETWORK=0 <pm> exec -- <body>\` form for the detected package manager. A gate-class script whose
|
|
92
|
+
body is NOT in the allowlist is screened out with a note naming it (a command you trust can still
|
|
93
|
+
be declared by hand); non-gate-class names are excluded silently, as always.
|
|
94
|
+
${TRUST_CHAIN_DISCLOSURE}`;
|
|
75
95
|
|
|
76
|
-
// ── candidate classification (the LOCKED derivation invariants)
|
|
77
|
-
//
|
|
96
|
+
// ── candidate classification: the NAME screens (the LOCKED derivation invariants) ──────
|
|
97
|
+
// NAME screens shape WHICH safe entries are offered; the danger axis (the BODY) is closed-world
|
|
98
|
+
// below (AD-052). The derived cmd is bash-interpolated by the gate runner and can become
|
|
78
99
|
// hook-auto-approvable, so only shell-SAFE script names ever enter the offer: a name carrying
|
|
79
100
|
// whitespace or any shell metacharacter (`test:ci && echo pwn`) is screened out entirely.
|
|
80
101
|
const SAFE_SCRIPT_NAME_PATTERN = /^[A-Za-z0-9:_.-]+$/;
|
|
81
102
|
const TERMINATING_CLASS_PATTERN = /^(test|lint|type-?check|types|tsc|build)([:._-]|$)/i;
|
|
82
|
-
// Non-terminating screening is TOKEN-set based, position-independent (never an anchored
|
|
83
|
-
// the anchored form missed `build:preview`
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// can still be declared by hand; a wrongly-included one would become hook-auto-approvable.
|
|
103
|
+
// Non-terminating NAME screening is TOKEN-set based, position-independent (never an anchored
|
|
104
|
+
// regex — the anchored form missed `build:preview`): a non-terminating token in ANY name segment
|
|
105
|
+
// disqualifies; `watch` disqualifies as a substring anywhere (watchAll). Conservative on purpose —
|
|
106
|
+
// a screened-out script can still be declared by hand.
|
|
87
107
|
const NON_TERMINATING_TOKENS = new Set(['dev', 'start', 'serve', 'watch', 'preview']);
|
|
88
|
-
// A terminating-looking NAME can still hide release work in its BODY (`"test": "npm publish"`):
|
|
89
|
-
// the same token classes the warn-name screen rejects are rejected as bare body words too — an
|
|
90
|
-
// offered cmd is hook-auto-approvable, so a dangerous body must never ride a clean name.
|
|
91
|
-
const DANGEROUS_BODY_TOKENS = new Set(['release', 'publish', 'deploy', 'push', 'version', 'commit', 'tag']);
|
|
92
108
|
const WATCH_ANYWHERE_PATTERN = /watch/i;
|
|
93
109
|
const wordOf = (raw) => raw.toLowerCase().replace(/^-+/, '').split('=')[0];
|
|
94
110
|
const hasTokenIn = (text, splitter, tokens) =>
|
|
@@ -97,16 +113,82 @@ const hasTokenIn = (text, splitter, tokens) =>
|
|
|
97
113
|
.some((part) => tokens.has(wordOf(part)));
|
|
98
114
|
const isNonTerminatingName = (name) =>
|
|
99
115
|
hasTokenIn(name, /[:._-]/, NON_TERMINATING_TOKENS) || WATCH_ANYWHERE_PATTERN.test(name);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// A MUTATING VARIANT of a terminating class never enters the offer — a hook-auto-approvable gate
|
|
104
|
-
// must never be a writer. Screened on BOTH axes: the script NAME (lint:fix, test:update,
|
|
105
|
-
// build:write, test:snapshot) and the script BODY's write-mode flags (eslint --fix,
|
|
106
|
-
// prettier --write / -w, jest -u / --updateSnapshot, tsc -w). Conservative by design: an excluded
|
|
107
|
-
// candidate can still be declared by hand — a wrongly-included one would be silently auto-approved.
|
|
116
|
+
// A MUTATING VARIANT NAME of a terminating class never enters the offer (lint:fix, test:update,
|
|
117
|
+
// build:write, test:snapshot) — a hook-auto-approvable gate must never be a writer. Write-mode
|
|
118
|
+
// BODIES need no screen: the closed-world allowlist simply has no write-mode member.
|
|
108
119
|
const MUTATING_VARIANT_NAME_PATTERN = /(^|[:._-])(fix|write|update|snapshot)([:._-]|$)/i;
|
|
109
|
-
|
|
120
|
+
|
|
121
|
+
// ── the CLOSED-WORLD body contract (AD-052 — the Issue-011 structural fix) ─────────────
|
|
122
|
+
// The body axis is ALLOWLIST MEMBERSHIP, never blocklist screening. The failure direction flips
|
|
123
|
+
// by construction: worst case = a legit command not offered (mild, add-by-hand) — never a
|
|
124
|
+
// dangerous one offered. Extension contract (edit-safety): adding an entry = adding the literal
|
|
125
|
+
// here + classifying its stem in the partition below + its own test case; the self-safety test
|
|
126
|
+
// pins every entry against shell metacharacters, write-mode flags, and unrecognized runner stems.
|
|
127
|
+
// Deliberately EXCLUDED: `tsc -p .` without --noEmit — it EMITS compiled .js into the tree by
|
|
128
|
+
// default, a write side-effect a verification gate must not have.
|
|
129
|
+
export const BODY_ALLOWLIST = Object.freeze([
|
|
130
|
+
'node --test',
|
|
131
|
+
'vitest run',
|
|
132
|
+
'jest',
|
|
133
|
+
'jest --ci',
|
|
134
|
+
'eslint .',
|
|
135
|
+
'prettier --check .',
|
|
136
|
+
'tsc --noEmit',
|
|
137
|
+
'tsc -p . --noEmit',
|
|
138
|
+
'vite build',
|
|
139
|
+
]);
|
|
140
|
+
// The FIXED executable-source partition of the allowlist stems (test-pinned, never a runtime
|
|
141
|
+
// probe): a host-runtime stem (`node`) is always on PATH and never a node_modules package — no
|
|
142
|
+
// local-bin resolution applies to it; a package-runner stem resolves from the project's own
|
|
143
|
+
// node_modules (or Berry .pnp.cjs) via `<pm> exec`, under the per-PM no-network-fetch rule below.
|
|
144
|
+
export const HOST_RUNTIME_STEMS = Object.freeze(['node']);
|
|
145
|
+
export const PACKAGE_RUNNER_STEMS = Object.freeze(['vitest', 'jest', 'eslint', 'prettier', 'tsc', 'vite']);
|
|
146
|
+
|
|
147
|
+
// Decision-3 order, test-pinned: (1) string-typed, else not offered; (2) reject ANY char outside
|
|
148
|
+
// printable ASCII + space/tab BEFORE trimming — String.trim() strips \n/NBSP/BOM at the EDGES and
|
|
149
|
+
// would mask a leading/trailing forbidden char; (3) trim + collapse ASCII space/tab runs to one
|
|
150
|
+
// space; (4) literal membership. No case folding, no arg reordering, no separate env/path reject
|
|
151
|
+
// axis — `FOO=bar node --test` and `./scripts/x.sh` are already rejected by NON-MEMBERSHIP.
|
|
152
|
+
const PRINTABLE_ASCII_BODY_PATTERN = /^[\x20-\x7E\t]*$/;
|
|
153
|
+
const allowlistedBodyOf = (body) => {
|
|
154
|
+
if (typeof body !== 'string') return null;
|
|
155
|
+
if (!PRINTABLE_ASCII_BODY_PATTERN.test(body)) return null;
|
|
156
|
+
const normalized = body.replace(/^[ \t]+|[ \t]+$/g, '').replace(/[ \t]+/g, ' ');
|
|
157
|
+
return BODY_ALLOWLIST.includes(normalized) ? normalized : null;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// The per-PM HOOK-FREE exec form (Decision 1, host-proven npm 12 / pnpm 10 / yarn 1 + berry 4):
|
|
161
|
+
// `exec` runs a COMMAND, not a named script — no pre/post lifecycle exists to fire, uniformly.
|
|
162
|
+
// The `--` keeps a body flag (`jest --ci`) from being absorbed by the PM. Per-PM hardening — the
|
|
163
|
+
// pinned invariant is NO NETWORK FETCH of an absent runner, not "no user-machine fallback":
|
|
164
|
+
// npm — `--script-shell /bin/sh` beats a hostile per-project `.npmrc script-shell` (proven the
|
|
165
|
+
// flag wins) and `--offline` refuses an absent runner as a cache miss, never a registry
|
|
166
|
+
// fetch; a hit on a previously-installed real package (the npm cache OR the host's
|
|
167
|
+
// global tree — both user machine state, not project content) is the documented runtime
|
|
168
|
+
// residual (Decision 2 ii / 6), not a separate guard.
|
|
169
|
+
// pnpm — no network fetch; an absent runner fails closed when no project-local or
|
|
170
|
+
// PATH-resolvable runner exists (a user-installed one executing is the same
|
|
171
|
+
// user-machine-state residual). Reads no npm `.npmrc script-shell`.
|
|
172
|
+
// yarn — same contract, uniform for classic + berry (berry resolves via .pnp.cjs; proven).
|
|
173
|
+
// The `COREPACK_ENABLE_NETWORK=0` prefix closes ONE more attacker-reachable fetch axis: when the
|
|
174
|
+
// PM binary is a Corepack shim, a hostile `packageManager: "<pm>@<uncached-version>"` field makes
|
|
175
|
+
// Corepack DOWNLOAD that PM release from the registry BEFORE exec even resolves the runner. The env
|
|
176
|
+
// var disables that provisioning (proven: it fails closed "Network access disabled" on an uncached
|
|
177
|
+
// pin, and is inert when the PM is a real install or already provisioned) — so the no-network
|
|
178
|
+
// contract holds for the PM-provision step too, not just the runner. Applies to all three (Corepack
|
|
179
|
+
// can shim npm/pnpm/yarn alike). An UNKNOWN family has no verified fail-closed exec contract → cmd
|
|
180
|
+
// null (withheld, loud note) — the mild worst case, never a hole. detectPackageManager can only
|
|
181
|
+
// yield npm/pnpm/yarn today (bun collapses to npm — characterized), so this is the builder floor.
|
|
182
|
+
const COREPACK_NO_NETWORK = 'COREPACK_ENABLE_NETWORK=0';
|
|
183
|
+
export const execCmdFor = (pm, body) => {
|
|
184
|
+
if (pm === 'npm') return { cmd: `${COREPACK_NO_NETWORK} npm exec --offline --script-shell /bin/sh -- ${body}`, note: null };
|
|
185
|
+
if (pm === 'pnpm') return { cmd: `${COREPACK_NO_NETWORK} pnpm exec -- ${body}`, note: null };
|
|
186
|
+
if (pm === 'yarn') return { cmd: `${COREPACK_NO_NETWORK} yarn exec -- ${body}`, note: null };
|
|
187
|
+
return {
|
|
188
|
+
cmd: null,
|
|
189
|
+
note: `no fail-closed exec form is verified for this package manager (${pm}) — add the gate by hand`,
|
|
190
|
+
};
|
|
191
|
+
};
|
|
110
192
|
|
|
111
193
|
export const kebabIdOf = (name) =>
|
|
112
194
|
String(name)
|
|
@@ -133,8 +215,11 @@ export const detectPackageManager = (cwd, deps = {}) => {
|
|
|
133
215
|
return 'npm';
|
|
134
216
|
};
|
|
135
217
|
|
|
136
|
-
// The script-derived offer entries
|
|
137
|
-
|
|
218
|
+
// The script-derived offer: entries + loud honesty notes (a gate-class script screened out on its
|
|
219
|
+
// body, or a whole family withheld, is COUNTED and NAMED — never silently absent). Order =
|
|
220
|
+
// package.json scripts order (the offer the user reads). `deps.packageManager` injects a family
|
|
221
|
+
// token past the detector for the builder-boundary fail-closed proof (T4b).
|
|
222
|
+
const deriveScripts = (cwd, deps = {}) => {
|
|
138
223
|
const read = deps.readFile ?? readFileSync;
|
|
139
224
|
const pkg = (() => {
|
|
140
225
|
try {
|
|
@@ -143,29 +228,48 @@ export const deriveScriptEntries = (cwd, deps = {}) => {
|
|
|
143
228
|
return null; // no/unreadable package.json → no script candidates (an honest empty offer)
|
|
144
229
|
}
|
|
145
230
|
})();
|
|
146
|
-
const pm = detectPackageManager(cwd, deps);
|
|
147
|
-
const
|
|
148
|
-
const bodyOf = (name) => String(pkg?.scripts?.[name] ?? '');
|
|
149
|
-
return discoverGateCandidates(pkg ?? {})
|
|
231
|
+
const pm = deps.packageManager ?? detectPackageManager(cwd, deps);
|
|
232
|
+
const named = discoverGateCandidates(pkg ?? {})
|
|
150
233
|
.filter((c) => !c.warn) // warn-flagged NEVER enter the offer
|
|
151
234
|
.filter((c) => SAFE_SCRIPT_NAME_PATTERN.test(c.scriptName)) // shell-safe names only, FIRST
|
|
152
235
|
.filter((c) => TERMINATING_CLASS_PATTERN.test(c.scriptName))
|
|
153
236
|
.filter((c) => !isNonTerminatingName(c.scriptName))
|
|
154
|
-
.filter((c) => !MUTATING_VARIANT_NAME_PATTERN.test(c.scriptName)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
237
|
+
.filter((c) => !MUTATING_VARIANT_NAME_PATTERN.test(c.scriptName));
|
|
238
|
+
const seen = new Set(); // first occurrence of a derived id wins, whatever its outcome (conservative)
|
|
239
|
+
const entries = [];
|
|
240
|
+
const screenedIds = [];
|
|
241
|
+
const withheld = [];
|
|
242
|
+
for (const c of named) {
|
|
243
|
+
const id = kebabIdOf(c.scriptName);
|
|
244
|
+
if (!id || seen.has(id)) continue; // an empty or duplicate derived id never enters the offer
|
|
245
|
+
seen.add(id);
|
|
246
|
+
const body = allowlistedBodyOf(pkg?.scripts?.[c.scriptName]);
|
|
247
|
+
if (body === null) {
|
|
248
|
+
screenedIds.push(id);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const { cmd, note } = execCmdFor(pm, body);
|
|
252
|
+
if (cmd === null) {
|
|
253
|
+
withheld.push({ id, note });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
entries.push({ id, title: `Project script ${c.scriptName}: ${body}`, cmd });
|
|
257
|
+
}
|
|
258
|
+
const notes = [];
|
|
259
|
+
if (screenedIds.length) {
|
|
260
|
+
notes.push(
|
|
261
|
+
`${screenedIds.length} gate-class script(s) screened out — body not in the closed-world ` +
|
|
262
|
+
`allowlist: ${screenedIds.join(', ')} — a command you trust can still be declared by hand in ${GATES_REL}`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
if (withheld.length) {
|
|
266
|
+
notes.push(`${withheld.length} script gate(s) withheld: ${withheld.map((w) => w.id).join(', ')} — ${withheld[0].note}`);
|
|
267
|
+
}
|
|
268
|
+
return { entries, notes };
|
|
167
269
|
};
|
|
168
270
|
|
|
271
|
+
export const deriveScriptEntries = (cwd, deps = {}) => deriveScripts(cwd, deps).entries;
|
|
272
|
+
|
|
169
273
|
// The conditional review-state candidate — keyed on the SLOT the checker enforces
|
|
170
274
|
// (plan-execution.review, tools/review-state.mjs), read via the shared config reader. Offered only
|
|
171
275
|
// when the config DECLARES reviewed/council there; solo configs and a council-on-plan-authoring-only
|
|
@@ -256,14 +360,13 @@ const assertOnlyIdsOffered = (offer, onlyIds = []) => {
|
|
|
256
360
|
// plus loud notes. Both review candidates key on the same slot (plan-execution.review reviewed/council)
|
|
257
361
|
// but gate distinct axes (receipt presence vs review-round convergence) — offered together.
|
|
258
362
|
export const buildOffer = (cwd, deps = {}) => {
|
|
259
|
-
const
|
|
363
|
+
const scripts = deriveScripts(cwd, deps);
|
|
260
364
|
const rs = reviewStateCandidate(cwd, deps);
|
|
261
365
|
const rl = reviewLedgerCandidate(cwd, deps);
|
|
262
366
|
const candidates = [rs.candidate, rl.candidate].filter(Boolean);
|
|
263
|
-
const notes = [rs.note, rl.note].filter(Boolean);
|
|
264
367
|
return {
|
|
265
|
-
entries: [...entries, ...candidates],
|
|
266
|
-
notes,
|
|
368
|
+
entries: [...scripts.entries, ...candidates],
|
|
369
|
+
notes: [...scripts.notes, rs.note, rl.note].filter(Boolean),
|
|
267
370
|
};
|
|
268
371
|
};
|
|
269
372
|
|
|
@@ -361,7 +464,7 @@ export const applySeed = ({ cwd, onlyIds = [] }, deps = {}) => {
|
|
|
361
464
|
}
|
|
362
465
|
const offer = buildOffer(cwd, deps);
|
|
363
466
|
assertOnlyIdsOffered(offer, onlyIds); // BEFORE the empty-offer return — a typo is never masked
|
|
364
|
-
if (!offer.entries.length) return { outcome: 'nothing' };
|
|
467
|
+
if (!offer.entries.length) return { outcome: 'nothing', notes: offer.notes };
|
|
365
468
|
const selected = onlyIds.length ? offer.entries.filter((e) => onlyIds.includes(e.id)) : offer.entries;
|
|
366
469
|
|
|
367
470
|
const existing = loadExistingDeclaration(cwd, deps);
|
|
@@ -382,7 +485,7 @@ export const applySeed = ({ cwd, onlyIds = [] }, deps = {}) => {
|
|
|
382
485
|
validateDeclaration(merged); // every written declaration passes the runner's validator, always
|
|
383
486
|
const body = `${JSON.stringify(merged, null, 2)}\n`;
|
|
384
487
|
const { writtenPath } = writeDocsAiFileAtomic(cwd, GATES_REL, body, deps, { stop, noun: 'a gate declaration' });
|
|
385
|
-
return { outcome: 'written', writtenPath, appended: selected.map((e) => e.id) };
|
|
488
|
+
return { outcome: 'written', writtenPath, appended: selected.map((e) => e.id), notes: offer.notes };
|
|
386
489
|
};
|
|
387
490
|
|
|
388
491
|
// ── CLI ────────────────────────────────────────────────────────────────────────────────
|
|
@@ -439,9 +542,11 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
|
439
542
|
const result = applySeed({ cwd, onlyIds: args.only }, deps);
|
|
440
543
|
if (result.outcome === 'nothing') {
|
|
441
544
|
log('[agent-workflow-kit] nothing to offer — no seedable terminating verification scripts were found; wrote nothing.');
|
|
545
|
+
for (const note of result.notes) log(` note: ${note}`); // the user learns WHY (same note as the preview)
|
|
442
546
|
return EXIT_OK;
|
|
443
547
|
}
|
|
444
548
|
log(`[agent-workflow-kit] appended ${result.appended.length} consented gate(s) to ${GATES_REL}: ${result.appended.join(', ')}`);
|
|
549
|
+
for (const note of result.notes) log(` note: ${note}`); // a mixed offer never silently omits what was screened
|
|
445
550
|
log(`[agent-workflow-kit] ${TRUST_CHAIN_DISCLOSURE}`);
|
|
446
551
|
return EXIT_OK;
|
|
447
552
|
} catch (err) {
|