@sabaiway/agent-workflow-kit 1.31.0 → 1.33.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 +85 -0
- package/SKILL.md +2 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +13 -0
- package/references/modes/set-recipe.md +1 -0
- package/references/modes/upgrade.md +16 -6
- package/references/modes/velocity.md +18 -3
- package/references/shared/composition-handoff.md +12 -0
- package/references/templates/agent_rules.md +1 -1
- package/tools/delegation.mjs +6 -3
- package/tools/engine-source.mjs +6 -0
- package/tools/family-registry.mjs +8 -4
- package/tools/lens-region.mjs +251 -0
- package/tools/velocity-profile.mjs +253 -32
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,91 @@ 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.33.0 — The agent-rules lens region: render + reconcile from the engine canon (AD-041)
|
|
8
|
+
|
|
9
|
+
A **feature** release (new shipped tool + wiring; deployment-lineage head stays `1.3.0` — the
|
|
10
|
+
refresh is stamp-independent, no migration). The deployed `docs/ai/agent_rules.md` lens section
|
|
11
|
+
is now a RENDER of the engine's canonical fragment, kept current by the kit:
|
|
12
|
+
|
|
13
|
+
- **New `tools/lens-region.mjs`** — the lens reconcile: heading-anchored region (no markers; a
|
|
14
|
+
renamed heading is a natural preserve+advise), render with the file's OWN section number,
|
|
15
|
+
refresh IFF the body matches the engine fragment or a known-prior body (fragment + prior store
|
|
16
|
+
read LIVE from the installed engine — no kit-side prior constants), a customized region
|
|
17
|
+
preserved verbatim + a one-line advisory, cap-guard from the target's frontmatter `maxLines`
|
|
18
|
+
(loud non-fatal refusal; no frontmatter → stated skip), atomic write, document EOL preserved.
|
|
19
|
+
Lazy + fail-loud: an absent/invalid engine is a STOP with the install command; a valid engine
|
|
20
|
+
older than 1.13.0 (no lens pair) is a stated soft skip. CLI:
|
|
21
|
+
`node tools/lens-region.mjs reconcile <path/to/agent_rules.md>`; invariants pinned by
|
|
22
|
+
`tools/lens-region.test.mjs` incl. the canon-change simulation (v1 deploy + v2 engine →
|
|
23
|
+
refreshed; re-run → zero-diff).
|
|
24
|
+
- **`Mode: upgrade` step 3** gains the SEVENTH stamp-independent reconcile (plain-language
|
|
25
|
+
outcomes: refreshed / already current / custom preserved + note / file absent / engine too
|
|
26
|
+
old / over the line cap), reported in both the step-4 and step-8 exit reports; the stale
|
|
27
|
+
"other three reconciles" phrasing went count-free.
|
|
28
|
+
`references/shared/composition-handoff.md` runs the same reconcile in BOTH bootstrap paths
|
|
29
|
+
(its own precondition: after `docs/ai/agent_rules.md` exists) — this is what converges a
|
|
30
|
+
stale-memory seed; `handoffPlan` names the lens region in `kitWrites` for both paths.
|
|
31
|
+
- **`family-registry`**: a distinct plain-language `status` caveat for an engine that does not
|
|
32
|
+
ship the lens canon — keyed on the PAIR (fragment + prior store), so a half-shipped engine
|
|
33
|
+
never reports healthy.
|
|
34
|
+
- **`test/lens-mirror.test.mjs` REWRITTEN** to render-parity vs the known-canonical set (each
|
|
35
|
+
template lens block byte-equals a render of the engine fragment or a prior-store entry; the
|
|
36
|
+
checkout additionally pins the CURRENT render; injected non-vacuity). The 22-token × 4-file
|
|
37
|
+
vocabulary mesh is deleted — token presence now lives in the ENGINE's own lens-fragment guard,
|
|
38
|
+
so a future lens wording change is an engine-only release (no forced kit/memory diffs).
|
|
39
|
+
- `references/templates/agent_rules.md` §2.5 intro carries the provenance clause (the render);
|
|
40
|
+
`tools/engine-source.mjs` exports `LENS_FRAGMENT_REL` + `LENS_PRIORS_REL`. Tarball 115 → 116
|
|
41
|
+
(`lens-region.mjs` reverse-pinned). AD-039 byte budgets hold unchanged.
|
|
42
|
+
- Repo-local release harness: `scripts/release/smoke-init.mjs` gains repeatable
|
|
43
|
+
`--expect-file <sandbox-HOME-relative path>=<substring>` (installed-file content assertions;
|
|
44
|
+
both path dialects fenced to the sandbox HOME).
|
|
45
|
+
|
|
46
|
+
## 1.32.0 — Approval-idle reduction: the opt-in `velocity --kit-tools` tier, an audited core extension, and the standing-consent advisory (AD-040)
|
|
47
|
+
|
|
48
|
+
A **feature** release; packaging-only for deployments (lineage stays `1.3.0` — no `docs/ai`
|
|
49
|
+
structure change, no migration). Routine read-only kit-tool invocations — the session-start
|
|
50
|
+
discovery line, the procedures advisor, the status/backends/gates checks — stop idling on
|
|
51
|
+
approval prompts, opt-in and honestly labeled; nothing that writes, commits, or publishes gets
|
|
52
|
+
any quieter.
|
|
53
|
+
|
|
54
|
+
- **`velocity --kit-tools` (opt-in tier).** On top of the read-only core, seeds 12 entries derived
|
|
55
|
+
from the RUNNING skill's own location at seed time: 8 read-only kit tools as resolved-absolute
|
|
56
|
+
script path + args wildcard (`recipes` / `procedures` / `family-registry` / `detect-backends` /
|
|
57
|
+
`commands` / `review-state` / `manifest/validate` / `release-scan`), `run-gates.mjs` as ONE
|
|
58
|
+
exact byte-string pinned `--cwd <resolved project root>` and advertised **project-exec, never
|
|
59
|
+
"read-only"** (a wildcard would be broader than the AD-037 hook boundary), and the three
|
|
60
|
+
default-dry-run writers' exact arg-free preview byte-strings (`velocity-profile`,
|
|
61
|
+
`cheap-agents`, `gate-hook`) — every `--apply`/`--write`/`--yes` still prompts. Fail-safe by
|
|
62
|
+
construction: a moved skill or stale path simply prompts again; non-POSIX / space- /
|
|
63
|
+
quote-carrying paths are refused up front with a typed error (hand-add fallback). Flagless
|
|
64
|
+
`velocity` behavior is unchanged (validates core-only, never depends on skill paths).
|
|
65
|
+
- **Dead-rule prevention as a test.** The `velocity.md` tier subsection lists the covered dispatch
|
|
66
|
+
line per tool (the documented-invocation source); the new `test/kit-readonly-tools.test.mjs`
|
|
67
|
+
substitutes the resolved skill dir (+ project root for run-gates) into each line and asserts the
|
|
68
|
+
seeded byte-form matches (prefix for wildcard, equality for exact) — plus the tier ↔
|
|
69
|
+
`commands.mjs` catalog-partition guard (run-gates the only project-exec member; the two
|
|
70
|
+
non-mode-backed validators get a writes-nothing source assertion).
|
|
71
|
+
- **Audited read-only core 18 → 31 (the AD-021 empirical method, probe record in AD-040).**
|
|
72
|
+
Survivors: `diff`, `stat`, `du`, `basename`, `dirname`, `realpath`, `git rev-parse`,
|
|
73
|
+
`git blame`, `git shortlog`, `git describe`, and the FIXED forms `git tag --list`,
|
|
74
|
+
`git stash list`, `git worktree list` (their bare forms mutate — probe-proven). FAILED and
|
|
75
|
+
excluded: `file` (`-C -m` compiles a magic FILE WRITE) and `git cat-file` (`--textconv`/
|
|
76
|
+
`--filters` run configured filters; `git show` already covers the reads). The PreToolUse hook's
|
|
77
|
+
`SEEDED_READONLY_CORE` extends in LOCKSTEP (order-sensitive parity guard); an already-placed
|
|
78
|
+
hook keeps the OLD core (a strict subset) until a delete-to-reseed refresh.
|
|
79
|
+
- **Sharper pre-existing advisory.** `node …`-shaped allow entries OUTSIDE the derived tier
|
|
80
|
+
(foreign script path, foreign `run-gates --cwd` root) are now flagged for hand review — the
|
|
81
|
+
tier's shape can never hide arbitrary local JS.
|
|
82
|
+
- **`set-recipe` standing-consent advisory (wording-only).** After a `--write` that names a
|
|
83
|
+
reviewed/council recipe, the mode file now advises the one-time HAND-adds
|
|
84
|
+
(`codex-review` / `agy-review` / `grounding.mjs`) to `settings.local.json` — stating plainly
|
|
85
|
+
that auto-approval spends subscription quota without a per-run prompt, the kit never writes
|
|
86
|
+
that file, and the entry must match the invocation byte-form including quoting. Solo recipes
|
|
87
|
+
get no advisory; the tool echo is untouched.
|
|
88
|
+
- **Honesty floor, twinned.** The velocity residual notice + its `velocity.md` prose mirror now
|
|
89
|
+
both carry the approval floor: every writer apply-class flag still prompts, clobber-protection
|
|
90
|
+
STOPs still stop, the three release asks (commit/push/publish) stay maintainer-owned.
|
|
91
|
+
|
|
7
92
|
## 1.31.0 — Progressive disclosure: SKILL.md becomes a thin router over references/modes + references/shared (AD-039)
|
|
8
93
|
|
|
9
94
|
A **feature** release; packaging-only for deployments (the deployment lineage stays `1.3.0` — no
|
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.33.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
|
@@ -75,7 +75,7 @@ Before acting, read `docs/ai/.workflow-version` (the project's stamp) to decide
|
|
|
75
75
|
|
|
76
76
|
- **absent** → bootstrap (a fresh deployment).
|
|
77
77
|
- **stamp < `1.3.0`** (the deployment-lineage head) → `upgrade`.
|
|
78
|
-
- **stamp == `1.3.0`** → already current; only the stamp-independent reconciles may run — the FULL set lives in `${CLAUDE_SKILL_DIR}/references/modes/upgrade.md` step 3 (pointer slots · hidden-mode footprint · both `.json` config ensures · the enforcement-script ensure · the placed-bridge refresh); run step 3 rather than enumerating from memory.
|
|
78
|
+
- **stamp == `1.3.0`** → already current; only the stamp-independent reconciles may run — the FULL set lives in `${CLAUDE_SKILL_DIR}/references/modes/upgrade.md` step 3 (pointer slots · hidden-mode footprint · both `.json` config ensures · the enforcement-script ensure · the placed-bridge refresh · the agent-rules lens refresh); run step 3 rather than enumerating from memory.
|
|
79
79
|
- **stamp > head / unparseable** → STOP — never-downgrade gate (see `${CLAUDE_SKILL_DIR}/references/modes/upgrade.md` step 2).
|
|
80
80
|
|
|
81
81
|
**Two independent version axes — never conflate them:**
|
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.33.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",
|
|
@@ -71,6 +71,13 @@ export const SEEDED_READONLY_CORE = Object.freeze([
|
|
|
71
71
|
'Bash(git ls-files:*)',
|
|
72
72
|
'Bash(git check-ignore:*)',
|
|
73
73
|
'Bash(git branch --list:*)',
|
|
74
|
+
'Bash(git rev-parse:*)',
|
|
75
|
+
'Bash(git blame:*)',
|
|
76
|
+
'Bash(git shortlog:*)',
|
|
77
|
+
'Bash(git describe:*)',
|
|
78
|
+
'Bash(git tag --list:*)',
|
|
79
|
+
'Bash(git stash list:*)',
|
|
80
|
+
'Bash(git worktree list:*)',
|
|
74
81
|
'Bash(npm view:*)',
|
|
75
82
|
'Bash(npm ls:*)',
|
|
76
83
|
'Bash(npm outdated:*)',
|
|
@@ -82,6 +89,12 @@ export const SEEDED_READONLY_CORE = Object.freeze([
|
|
|
82
89
|
'Bash(readlink:*)',
|
|
83
90
|
'Bash(which:*)',
|
|
84
91
|
'Bash(grep:*)',
|
|
92
|
+
'Bash(diff:*)',
|
|
93
|
+
'Bash(stat:*)',
|
|
94
|
+
'Bash(du:*)',
|
|
95
|
+
'Bash(basename:*)',
|
|
96
|
+
'Bash(dirname:*)',
|
|
97
|
+
'Bash(realpath:*)',
|
|
85
98
|
]);
|
|
86
99
|
|
|
87
100
|
// Baked frozen copy of velocity-profile.mjs RUNTIME_RESIDUAL_FORMS (the documented residual a
|
|
@@ -16,6 +16,7 @@ Run **`node ${CLAUDE_SKILL_DIR}/tools/set-recipe.mjs [--set <activity>.<slot>=<r
|
|
|
16
16
|
1. **Grammar — always fully-qualified `<activity>.<slot>`** (the kit never guesses the activity; a bare `review=council` is rejected). `review` accepts `solo|reviewed|council`; `execute` accepts `solo|delegated`. Activities/slots: `plan-authoring.review`, `plan-execution.execute`, `plan-execution.review`.
|
|
17
17
|
2. **Preview by default** — prints `current → proposed` for the **changed** slots only, plus the **effective recipe resolved against live backend readiness** (degradation stated honestly, e.g. *council requested, 1 ready reviewer → runs reviewed until a 2nd backend is ready*). It writes **nothing**. Re-run with **`--write`** to apply (same effective/degradation note — a direct `--write` is never quieter than the preview). `--unset` returns a slot to its computed default (reverting needs no hand-edit either). A no-op `--set` (slot already equals) writes nothing and never re-seeds the onboarding note.
|
|
18
18
|
3. **`--write`** applies via a hardened, atomic write (deployment-gated — refuses to scatter a config into a repo with no `docs/ai`; exclusive-create temp + rename; symlink/TOCTOU-safe; last-writer-wins). It preserves the onboarding note + every untouched slot, normalizing to canonical 2-space JSON.
|
|
19
|
+
**Standing-consent advisory (after a successful `--write` only, and only when the written config names a `reviewed`/`council` recipe — a solo recipe gets NO advisory):** advise the ONE-TIME **hand-adds** to the maintainer's `.claude/settings.local.json` — `Bash(codex-review:*)` + `Bash(agy-review:*)` + `Bash(node <skill-dir>/tools/grounding.mjs:*)`, with the path your project actually reaches the kit by. State plainly: (a) auto-approving a review wrapper **spends subscription quota without a per-run prompt** — that is exactly what standing consent means here, so it is the maintainer's call; (b) the kit **never writes that file** — these stay hand-adds; (c) `grounding.mjs --out` writes ONE scratch facts file — that write is what the standing consent covers; (d) the entry must match the invocation **byte-form your project actually uses, INCLUDING quoting** — the procedures advisor renders a QUOTED skill path, and a hand-add covering it must use the same spelling (a mismatched spelling is a dead rule that simply prompts).
|
|
19
20
|
4. **Exit codes:** `0` success (an explicit recipe that gracefully degrades is still `0`); `2` usage (a bare/duplicate op, or `--write` with no ops); `1` config error (malformed/unreadable config — the file is left **untouched**, never clobbered) or a write STOP (no deployment / a symlinked config). A `1`/`2` failure is loud; on a malformed config, offer to show the parse error so you can help the user fix the JSON.
|
|
20
21
|
|
|
21
22
|
Output is **English/structured** — **localize it to the user's conversational language** when you narrate. Surface the effective-recipe/degradation note plainly.
|
|
@@ -32,15 +32,25 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
32
32
|
bundled copies and re-links its wrappers; an **absent** bridge is a stated skip, **never a first
|
|
33
33
|
placement** (placement stays the opt-in `${CLAUDE_SKILL_DIR}/references/modes/setup.md` — AD-009/AD-011 honesty intact), and a
|
|
34
34
|
placed bridge **newer** than the bundled copy is a stated skip naming the kit update (**never a
|
|
35
|
-
downgrade**). Like the other
|
|
36
|
-
equal-head one — with no lineage-head bump. A *could not refresh* line is
|
|
37
|
-
plainly with its recovery and continue the upgrade.
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
downgrade**). Like the other stamp-independent reconciles it runs on **every** upgrade —
|
|
36
|
+
including an equal-head one — with no lineage-head bump. A *could not refresh* line is
|
|
37
|
+
non-fatal: relay it plainly with its recovery and continue the upgrade.
|
|
38
|
+
|
|
39
|
+
**Agent-rules lens refresh — stamp-independent, same gate, BEFORE the equal-head short-circuit.**
|
|
40
|
+
Run `node ${CLAUDE_SKILL_DIR}/tools/lens-region.mjs reconcile <project>/docs/ai/agent_rules.md`
|
|
41
|
+
and relay its outcome in plain language: the planning/review lens section was **refreshed** to
|
|
42
|
+
the current canon · was **already current** · carries a **custom edit — preserved verbatim** (+
|
|
43
|
+
its one-line note) · the **file is absent — skipped** · the **engine is too old — skipped**
|
|
44
|
+
(refresh it with `npx @sabaiway/agent-workflow-engine@latest init`, then re-run) · **over the
|
|
45
|
+
file's line cap — refused** (trim the file, re-run). The section is found by its heading — no
|
|
46
|
+
markers; a renamed heading is preserved + noted. A fully absent/invalid engine is the same hard
|
|
47
|
+
STOP as (c). Exit 0 covers every soft outcome; only the STOP is non-zero.
|
|
48
|
+
4. **Equal-head exit — a real successful-exit report, not a bare stop.** If the stamp **equals** the head, the lineage is up to date — but step 3 (the stamp-independent reconciles) ran first and may have changed things, so this is a proper exit report, not a no-op:
|
|
49
|
+
- **Report step 3's outcome in plain language** — for **each** pointer (workflow-methodology and orchestration-recipes) whether it was *added*, was *already present* (nothing changed), or was *skipped because the entry point is over its line limit* (the cap-refusal soft-skip from step 3, with its reason); whether the `docs/ai/orchestration.json` config was *seeded* (created from the template), had its onboarding note *refreshed*, was *already current*, or carried a *customized note that was preserved* (a user edit is never clobbered); whether the `docs/ai/gates.json` gate declaration was *seeded* or was *already present* (preserved byte-for-byte); whether the enforcement-script ensure *added* the `archive-decisions` pair to `scripts/` or found it *already present*; the **placed-bridge refresh** outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / *could not refresh* + recovery); the **agent-rules lens** outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*); and, for a hidden deployment, whether the hidden-mode footprint was *moved to project-local*, was *already project-local* (nothing changed), or needed a question (ambiguous visibility / a leftover machine-wide block). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
|
|
40
50
|
- **Never surface the structure number on this exit.** Whatever step 3 did, do **not** recite the `docs/ai` structure version, the internal versioning vocabulary, or the two-axes note here — the number is inert on an equal-head exit; it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md` (shown at the never-downgrade STOP, the explicit status view, or on an explicit ask). Frame the success itself per the final bullet: if step 3 changed anything, say **what changed** in plain human terms; only a pure zero-diff no-op is *settings already current — no update needed*.
|
|
41
51
|
- **Print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason). The welcome mat closes on **one** caveat-aware next step (a behind member first, else `setup` / `recipes` / `velocity`).
|
|
42
52
|
- **Then ask before committing — never auto-commit.** If step 3 added the slot (or anything else changed), report it and ask. If step 3 was a pure zero-diff no-op and nothing else changed, give the plain **settings already current — no update needed** message (the *Success state* contract in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`) and still print the read-only version block (installed package versions) + backend line — but **no `docs/ai` structure version and no two-axes note** (nothing changed, so the number is inert here).
|
|
43
53
|
5. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` diff (entries newer than the project's stamp).
|
|
44
54
|
6. Apply `${CLAUDE_SKILL_DIR}/migrations/<version>-<slug>.md` in **semver order**, only those newer than the project's stamp. Migrations are **idempotent** — safe to re-run.
|
|
45
55
|
7. Reconcile drift: add any kernel files/scripts the project is missing; never clobber project-authored content (their `decisions.md`, `known_issues.md`, page specs stay). Any user question a migration raises follows the same rule as bootstrap — **structured multiple-choice where supported** (`AskUserQuestion` in Claude Code), otherwise prose. If `AGENTS.md` has no *Communication language* block (pre-1.1.0 deployment), **ask the user their conversational language** and insert the block — see `migrations/1.1.0-communication-language.md`. If it has no *Attribution* block (pre-1.2.0 deployment), **ask whether the agent may attribute work to itself / AI** and insert the block (defaulting to `off`) — see `migrations/1.2.0-agent-attribution.md`.
|
|
46
|
-
8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`1.3.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 **placed-bridge refresh** lines (pasted verbatim) — rather than reciting a version number; **omit the raw structure number**, and do **not** print the two-axes note here (it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, on demand only). Then **print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason; the welcome mat closes on one caveat-aware next step). Then **ask before committing**.
|
|
56
|
+
8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`1.3.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 **placed-bridge refresh** lines (pasted verbatim) and the step-3 **agent-rules lens** outcome (same outcome set as step 4) — rather than reciting a version number; **omit the raw structure number**, and do **not** print the two-axes note here (it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, on demand only). Then **print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason; the welcome mat closes on one caveat-aware next step). Then **ask before committing**.
|
|
@@ -4,15 +4,30 @@ The opt-in onboarding **velocity profile** — it seeds a fixed, audited **read-
|
|
|
4
4
|
|
|
5
5
|
**Version-status routing (like the other writer modes):** read `docs/ai/.workflow-version` first — not-deployed → bootstrap; stamp < `1.3.0` → `upgrade`; stamp > head / unparseable → STOP. The tool enforces this in code too (`--apply` STOPs unless the stamp is the lineage head).
|
|
6
6
|
|
|
7
|
-
Run `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs [--dry-run | --apply] [--accept-edits] [--cwd <dir>]`:
|
|
7
|
+
Run `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs [--dry-run | --apply] [--kit-tools] [--accept-edits] [--cwd <dir>]`:
|
|
8
8
|
|
|
9
9
|
1. **`--dry-run` first, always** (the default — changes nothing). It prints: the fixed read-only core it would add; a **read-only advisory** that lists your `package.json` `scripts` as **unaudited candidates you may add BY HAND** (inspect each first) to `.claude/settings.json` / `settings.local.json` — the tool **never** writes them and flags obviously-mutating names as "do not add"; any **pre-existing non-read-only `Bash(...)` entries** to consider removing by hand; and the honest residual notice (below). It STOPs (zero writes) on a symlinked `.claude` / non-regular `settings.json`, malformed settings JSON, or an unsafe `permissions.defaultMode` — `bypassPermissions` or anything outside `default`/`acceptEdits`/`plan`, present in **either** `settings.json` or `settings.local.json`.
|
|
10
|
-
2. **Ask the
|
|
10
|
+
2. **Ask the opt-ins** via **`AskUserQuestion` where supported** — two separate choices, the safe option FIRST in each. First the **`--kit-tools` tier** (separate from `acceptEdits`; **"core only" remains the default answer** — seed the tier only on an explicit yes, then add `--kit-tools` to the `--apply` re-run). Then the `acceptEdits` opt-in:
|
|
11
11
|
- **"Keep per-edit approval prompts (recommended)"** — seed only the read-only allowlist; file edits still prompt.
|
|
12
|
-
- **"Auto-accept file edits (`defaultMode: acceptEdits`)"** — present the honest FULL posture: it auto-applies Edit/Write AND auto-runs `mkdir`/`touch`/`mv`/`cp` in the working dir, is paired with the read-only allowlist, and — stated plainly — a settings-level allow rule is a **trust posture, not a sandbox**: a read-only entry can still write a file via output redirection, and (Claude Code's allow rules do not inspect command substitution) could in principle run another command via `cmd $(…)`. velocity **never adds `commit`/`push`/`publish` as allow rules** — so a direct `git push` still ASKs — and the residual guard for the seeded core ships as the opt-in PreToolUse hook (`${CLAUDE_SKILL_DIR}/references/modes/hook.md`; probe-proven on the `--output` family — current engine builds already intercept redirection/substitution upstream). Note also that a `defaultMode` in `settings.local.json` would override this project-level write (local > project), since velocity writes only `.claude/settings.json`.
|
|
12
|
+
- **"Auto-accept file edits (`defaultMode: acceptEdits`)"** — present the honest FULL posture: it auto-applies Edit/Write AND auto-runs `mkdir`/`touch`/`mv`/`cp` in the working dir, is paired with the read-only allowlist, and — stated plainly — a settings-level allow rule is a **trust posture, not a sandbox**: a read-only entry can still write a file via output redirection, and (Claude Code's allow rules do not inspect command substitution) could in principle run another command via `cmd $(…)`. velocity **never adds `commit`/`push`/`publish` as allow rules** — so a direct `git push` still ASKs — and the residual guard for the seeded core ships as the opt-in PreToolUse hook (`${CLAUDE_SKILL_DIR}/references/modes/hook.md`; probe-proven on the `--output` family — current engine builds already intercept redirection/substitution upstream). The approval **floor** (never auto-approved, with or without the tier): every writer `--apply`/`--write`/`--yes` still prompts; clobber-protection STOPs still stop; the three release asks (commit/push/publish) stay maintainer-owned. Note also that a `defaultMode` in `settings.local.json` would override this project-level write (local > project), since velocity writes only `.claude/settings.json`.
|
|
13
13
|
3. **Only on an explicit yes**, re-run with `--apply` (add `--accept-edits` only if they chose the second option). It merges-don't-clobber (preserves `includeCoAuthoredBy`, every key, and existing allow entries) and writes **only** `.claude/settings.json`.
|
|
14
14
|
4. **Surface delegation-readiness, read-only.** If they want a step run Delegated, set it with `/agent-workflow-kit set-recipe --set plan-execution.execute=delegated` (`${CLAUDE_SKILL_DIR}/references/modes/set-recipe.md`) or by hand-editing `docs/ai/orchestration.json`; **velocity itself never writes the orchestration config.**
|
|
15
15
|
|
|
16
|
+
**The `--kit-tools` tier (opt-in).** On top of the read-only core, `--kit-tools` seeds an audited kit-tool tier — every path **resolved absolute from the running skill at seed time**, so the posture is **fail-safe**: a moved or reinstalled skill leaves a stale rule that simply **prompts again** (re-run velocity to reseed; nothing widens silently). A seeded entry equals its covered dispatch line below with `${CLAUDE_SKILL_DIR}` replaced by the resolved skill dir and `${PROJECT_ROOT}` by this project's resolved root, **UNQUOTED** — a quoted path is a different byte string and will still prompt (a dead rule, not a hole). The covered dispatch lines (this list IS the documented-invocation source):
|
|
17
|
+
|
|
18
|
+
- `node ${CLAUDE_SKILL_DIR}/tools/recipes.mjs --active-line` — and any other args (wildcard)
|
|
19
|
+
- `node ${CLAUDE_SKILL_DIR}/tools/procedures.mjs <activity>` (wildcard)
|
|
20
|
+
- `node ${CLAUDE_SKILL_DIR}/tools/family-registry.mjs --json` (wildcard)
|
|
21
|
+
- `node ${CLAUDE_SKILL_DIR}/tools/detect-backends.mjs` (wildcard)
|
|
22
|
+
- `node ${CLAUDE_SKILL_DIR}/tools/commands.mjs` (wildcard)
|
|
23
|
+
- `node ${CLAUDE_SKILL_DIR}/tools/review-state.mjs --check` (wildcard)
|
|
24
|
+
- `node ${CLAUDE_SKILL_DIR}/tools/manifest/validate.mjs --strict <skill-dir>` (wildcard)
|
|
25
|
+
- `node ${CLAUDE_SKILL_DIR}/tools/release-scan.mjs <path>` (wildcard)
|
|
26
|
+
- `node ${CLAUDE_SKILL_DIR}/tools/run-gates.mjs --cwd ${PROJECT_ROOT}` — **EXACT byte-string only**, and honestly **project-exec, not read-only**: it runs YOUR declared `docs/ai/gates.json` commands — the same trust boundary the opt-in hook grants byte-exact per-cmd. A wildcard would be BROADER than that boundary (`--cwd <dir>` executes another project's declared gates), so the bare cwd-defaulting form, any other `--cwd`, and `--only` forms all still prompt.
|
|
27
|
+
- Writer previews, **exact arg-free dry-run byte-strings only** (for these three writers the arg-free default IS the preview): `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs` · `node ${CLAUDE_SKILL_DIR}/tools/cheap-agents.mjs` · `node ${CLAUDE_SKILL_DIR}/tools/gate-hook.mjs` — every `--apply`/`--write`/`--yes` still prompts, always.
|
|
28
|
+
|
|
29
|
+
Honesty notes: tier entries get **NO PreToolUse-hook residual coverage** — the opt-in hook's residual ask-net guards only the seeded read-only CORE prefixes, so the tier rides the same settings-level residual posture as the core (redirection / command substitution are not inspectable at the settings layer; see the residual notice). A skill or project path that cannot survive UNQUOTED in a byte-exact rule (spaces, metacharacters, non-POSIX) **STOPs the tier up front with a clear error** — nothing is seeded. Anything you want covered beyond the tier — such paths, this repo's own relative-path spellings, other tools — stays a **BY-HAND add** to your settings, with the path your project actually reaches the kit by. Pre-existing `node …` allow entries that do NOT match the seeded tier byte-forms stay flagged by the advisory for hand review.
|
|
30
|
+
|
|
16
31
|
**Invariants:** creates `.claude/` if absent and writes **only** `.claude/settings.json` (no other file); **never** allowlists commit/push/publish; **never** writes `settings.local.json`; never commits; opt-in `acceptEdits`, never silent.
|
|
17
32
|
|
|
18
33
|
**Exit codes:** `0` done / dry-run; `1` a precondition STOP (stamp not current, unsafe mode, malformed settings, symlinked `.claude` / non-regular target); `2` bad arguments.
|
|
@@ -37,3 +37,15 @@ ordered `start → end` pair; a malformed slot (single, reversed, nested, duplic
|
|
|
37
37
|
duplicate anchor → **STOP with an error**, never edit (the file is left byte-for-byte unchanged). The
|
|
38
38
|
**orchestration pointer is soft-skipped** (reported, never silent) when — and only when — adding it
|
|
39
39
|
would exceed the 100-line cap: the methodology pointer still lands and the upgrade continues.
|
|
40
|
+
|
|
41
|
+
**Agent-rules lens refresh (runs in BOTH paths).** Its precondition is its own target file — run it
|
|
42
|
+
only after `<project>/docs/ai/agent_rules.md` exists: after the substrate deploy in the
|
|
43
|
+
**delegated** path, after the fallback-template copy in the **kit** path (never anchored to the
|
|
44
|
+
`AGENTS.md` step above). ONE command:
|
|
45
|
+
`node ${CLAUDE_SKILL_DIR}/tools/lens-region.mjs reconcile <project>/docs/ai/agent_rules.md`.
|
|
46
|
+
This is what converges a substrate seeded by an older memory — a lens section matching a known
|
|
47
|
+
prior canonical body is refreshed from the installed engine; a current seed reports *already
|
|
48
|
+
current* (zero-diff). Relay its outcome in plain language (*refreshed* / *already current* /
|
|
49
|
+
*custom edit preserved + note* / *file absent — skipped* / *engine too old — skipped* / *over the
|
|
50
|
+
line cap — refused*); a fully absent/invalid engine is the same loud STOP + one-line install
|
|
51
|
+
command as the pointer reconcile.
|
|
@@ -63,7 +63,7 @@ Before proposing changes or committing, review against:
|
|
|
63
63
|
- Always run type-checker, linter, and all tests before committing.
|
|
64
64
|
|
|
65
65
|
### 2.5. Planning, review & process-fidelity invariants
|
|
66
|
-
Apply these when authoring a plan, reviewing, folding a finding, or editing code — the layer read **before any code change**. (Full canon: the project's planning / workflow-methodology + orchestration canon.)
|
|
66
|
+
Apply these when authoring a plan, reviewing, folding a finding, or editing code — the layer read **before any code change**. (Full canon: the project's planning / workflow-methodology + orchestration canon. This section is rendered from that canon and refreshed on upgrade; a custom edit is preserved verbatim, but flagged.)
|
|
67
67
|
- **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.
|
|
68
68
|
- **Right altitude.** Pin intent + invariants + acceptance criteria (named tests); leave fine code-mechanics to Execute, where prose cannot diverge from reality.
|
|
69
69
|
- **No code-mechanics in the plan.** A Step still carries its exact paths + commands (the plan-structure / self-review 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.
|
package/tools/delegation.mjs
CHANGED
|
@@ -83,7 +83,9 @@ export const handoffPlan = (delegate) =>
|
|
|
83
83
|
? {
|
|
84
84
|
mode: 'delegate',
|
|
85
85
|
memoryWrites: ['docs/ai/', 'AGENTS.md', 'docs/ai/.memory-version'],
|
|
86
|
-
|
|
86
|
+
// The lens region runs AFTER the substrate deploy (its own precondition: the file exists)
|
|
87
|
+
// — it converges a stale-memory seed to the installed engine's canon (AD-041).
|
|
88
|
+
kitWrites: ['AGENTS.md methodology slot', 'docs/ai/agent_rules.md lens region', 'docs/ai/.workflow-version'],
|
|
87
89
|
stampsPresent: ['.memory-version', '.workflow-version'],
|
|
88
90
|
memoryRaisesCommitGate: false,
|
|
89
91
|
commitGate: 'kit-only-after-injection',
|
|
@@ -93,8 +95,9 @@ export const handoffPlan = (delegate) =>
|
|
|
93
95
|
memoryWrites: [],
|
|
94
96
|
// Fallback now ships the kit's OWN AGENTS.md carrying the EMPTY methodology slot (Plan 2);
|
|
95
97
|
// the kit reconciles it (ensure-slot + inject-because-empty) exactly like the delegate
|
|
96
|
-
// path — so both paths end with a FILLED slot, not inline methodology.
|
|
97
|
-
|
|
98
|
+
// path — so both paths end with a FILLED slot, not inline methodology. The lens region
|
|
99
|
+
// runs after the fallback-template copy of docs/ai (same reconcile, both paths — AD-041).
|
|
100
|
+
kitWrites: ['docs/ai/', 'AGENTS.md', 'AGENTS.md methodology slot', 'docs/ai/agent_rules.md lens region', 'docs/ai/.workflow-version'],
|
|
98
101
|
stampsPresent: ['.workflow-version'],
|
|
99
102
|
memoryRaisesCommitGate: false,
|
|
100
103
|
commitGate: 'kit-only-after-injection',
|
package/tools/engine-source.mjs
CHANGED
|
@@ -31,6 +31,12 @@ export const ORCHESTRATION_FRAGMENT_REL = 'references/orchestration-slot.md';
|
|
|
31
31
|
// CLI fail loudly on — an engine too old to ship it. readEngineFragment accepts an arbitrary `rel`
|
|
32
32
|
// (no whitelist), so no further plumbing is needed beyond this constant.
|
|
33
33
|
export const PROCEDURES_FRAGMENT_REL = 'references/procedures.md';
|
|
34
|
+
// The agent-rules lens pair — the canonical lens block + its append-only prior store (engine
|
|
35
|
+
// >= 1.13.0; an older engine ships neither). The kit's lens-region reconcile live-reads BOTH via
|
|
36
|
+
// readEngineFragment({ rel }); detectEngine({ rel }) lets it soft-skip a too-old engine (and
|
|
37
|
+
// `status` caveat it) instead of failing the whole reconcile.
|
|
38
|
+
export const LENS_FRAGMENT_REL = 'references/agent-rules-lens.md';
|
|
39
|
+
export const LENS_PRIORS_REL = 'references/agent-rules-lens-priors.md';
|
|
34
40
|
const ENGINE_DEFAULT_REL = '.claude/skills/agent-workflow-engine';
|
|
35
41
|
|
|
36
42
|
const defaultStatType = (path) => {
|
|
@@ -26,7 +26,7 @@ import { resolveDir, detectBackends, findOnPath } from './detect-backends.mjs';
|
|
|
26
26
|
import { parseSemver, compareSemver } from './semver-lite.mjs';
|
|
27
27
|
import { validateManifest, readAuthoritativeVersion, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
|
|
28
28
|
import { START_MARKER, excludePath, inferVisibility } from './hide-footprint.mjs';
|
|
29
|
-
import { readEngineFragment, ORCHESTRATION_FRAGMENT_REL, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
|
|
29
|
+
import { readEngineFragment, ORCHESTRATION_FRAGMENT_REL, PROCEDURES_FRAGMENT_REL, LENS_FRAGMENT_REL, LENS_PRIORS_REL } from './engine-source.mjs';
|
|
30
30
|
import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
|
|
31
31
|
// The config reader lives in orchestration-config.mjs (the single config contract). The read-only status
|
|
32
32
|
// settings-survey reuses THIS reader (one strict-JSON + loud-on-malformed contract), not a second copy.
|
|
@@ -171,9 +171,13 @@ export const classifyMember = (member, deps = {}) => {
|
|
|
171
171
|
// the manifest AND reads the fragment — so an absent, non-file, OR present-but-unreadable fragment all
|
|
172
172
|
// surface (status never claims "ok" for a fragment a reconcile / the procedures CLI would STOP on), and
|
|
173
173
|
// a current, readable fragment never gets the caveat. Read-only, best-effort.
|
|
174
|
+
// Each caveat names the rel(s) its consumer actually needs: the lens reconcile requires the
|
|
175
|
+
// fragment AND its prior store as a PAIR (lens-region soft-skips when either is missing), so its
|
|
176
|
+
// caveat keys on both — an engine missing only the prior store must never report healthy.
|
|
174
177
|
const ENGINE_FRAGMENT_CAVEATS = [
|
|
175
|
-
{
|
|
176
|
-
{
|
|
178
|
+
{ rels: [ORCHESTRATION_FRAGMENT_REL], caveat: 'engine present but does not supply the recipes pointer (too old / incomplete) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
|
|
179
|
+
{ rels: [PROCEDURES_FRAGMENT_REL], caveat: 'engine present but does not ship the activity-procedures canon (too old / incomplete) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
|
|
180
|
+
{ rels: [LENS_FRAGMENT_REL, LENS_PRIORS_REL], caveat: 'engine present but does not ship the agent-rules lens canon (the fragment + its prior store; too old / incomplete — engine >= 1.13.0) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
|
|
177
181
|
];
|
|
178
182
|
|
|
179
183
|
// The orchestration-config TEMPLATE a current memory ships (added in memory 1.2.0; absent in older
|
|
@@ -244,7 +248,7 @@ export const surveyFamily = (deps = {}) =>
|
|
|
244
248
|
return false; // absent / non-file / unreadable fragment → the engine can't supply it
|
|
245
249
|
}
|
|
246
250
|
};
|
|
247
|
-
const caveats = ENGINE_FRAGMENT_CAVEATS.filter((f) => !
|
|
251
|
+
const caveats = ENGINE_FRAGMENT_CAVEATS.filter((f) => !f.rels.every(fragmentUsable)).map((f) => f.caveat);
|
|
248
252
|
if (caveats.length) row.caveats = caveats;
|
|
249
253
|
}
|
|
250
254
|
// Memory offline caveat (Step 2.2): a distinct probe — the orchestration TEMPLATE file's existence.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Heading-anchored lens-region reconcile — the composition root's only mutation of a deployed
|
|
3
|
+
// `docs/ai/agent_rules.md`. The planning/review/process-fidelity lens block (`### 2.x. Planning,
|
|
4
|
+
// review & process-fidelity invariants`) has ONE canonical home — the installed engine's
|
|
5
|
+
// `references/agent-rules-lens.md` — and every deployed/template copy is a RENDER of it (the
|
|
6
|
+
// file's own section number substituted into the number-neutral heading). This tool refreshes a
|
|
7
|
+
// deployed region to the current canon under the AD-025 discipline: refresh IFF the region's
|
|
8
|
+
// normalized body matches the current fragment (already current → zero-diff) or a KNOWN PRIOR
|
|
9
|
+
// canonical body (the engine's append-only `references/agent-rules-lens-priors.md`, read live
|
|
10
|
+
// beside the fragment — no kit-side prior constants, so a canon wording change is an engine-only
|
|
11
|
+
// release). Anything else is preserved byte-for-byte + a one-line advisory.
|
|
12
|
+
//
|
|
13
|
+
// The region has NO markers (unlike the AGENTS.md pointer slots): it is located by the heading
|
|
14
|
+
// through the next structural boundary (`---` / `##` / `###`) or EOF — the extraction rule the
|
|
15
|
+
// lens-mirror guard pioneered, promoted here as the shipped implementation. A user-renamed
|
|
16
|
+
// heading is therefore a natural preserve+advise; bytes outside the region are never touched.
|
|
17
|
+
//
|
|
18
|
+
// The live read is lazy + fail-loud: the engine is consulted only when a present region must be
|
|
19
|
+
// classified; a fully absent/invalid engine is a loud STOP (never a silent fallback), a valid
|
|
20
|
+
// engine that merely predates the lens pair (<1.13.0) is a stated soft skip. Cap-guard: a refresh
|
|
21
|
+
// that would push the file over its own frontmatter `maxLines` is a loud, non-fatal refusal —
|
|
22
|
+
// never a silent truncate; a file without frontmatter/`maxLines` skips the guard with a stated
|
|
23
|
+
// note (such a file is outside the docs cap gate anyway).
|
|
24
|
+
//
|
|
25
|
+
// Pure string functions (fs only in the CLI); dependency-free, Node >= 18.
|
|
26
|
+
|
|
27
|
+
import { normalizeCanonical } from './orchestration-config.mjs';
|
|
28
|
+
|
|
29
|
+
// The deployed-heading matcher (prefix, like the historical extractLensBlock: robust to a future
|
|
30
|
+
// heading-tail tweak) and the number-neutral form the engine fragment carries.
|
|
31
|
+
export const LENS_HEADING_RE = /^### 2\.(\d+)\. Planning, review & process-fidelity/;
|
|
32
|
+
const NEUTRAL_HEADING_RE = /^### 2\.x\. Planning, review & process-fidelity/;
|
|
33
|
+
const HEADING_LABEL = '### 2.x. Planning, review & process-fidelity invariants';
|
|
34
|
+
|
|
35
|
+
const stripCr = (line) => (line.endsWith('\r') ? line.slice(0, -1) : line);
|
|
36
|
+
const isBoundary = (bareLine) => bareLine === '---' || /^#{2,3} /.test(bareLine);
|
|
37
|
+
|
|
38
|
+
// Count lines independent of a trailing newline (the inject-methodology contract).
|
|
39
|
+
const lineCount = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
40
|
+
|
|
41
|
+
// ── the frozen prior-store format (documented in the store file's own header) ─────
|
|
42
|
+
// A delimiter is a line that starts with `<!-- prior` and ends with `-->`; an entry body is
|
|
43
|
+
// everything after it up to the next delimiter / EOF, trimmed. The pre-delimiter header is
|
|
44
|
+
// ignored. APPEND-ONLY on the engine side — this parser must keep reading a newer engine's file.
|
|
45
|
+
export const parseLensPriors = (text) => {
|
|
46
|
+
const entries = [];
|
|
47
|
+
let current = null;
|
|
48
|
+
for (const line of String(text).split('\n')) {
|
|
49
|
+
const bare = stripCr(line);
|
|
50
|
+
if (bare.startsWith('<!-- prior') && bare.endsWith('-->')) {
|
|
51
|
+
if (current) entries.push(current.join('\n'));
|
|
52
|
+
current = [];
|
|
53
|
+
} else if (current) current.push(line);
|
|
54
|
+
}
|
|
55
|
+
if (current) entries.push(current.join('\n'));
|
|
56
|
+
return entries.map((e) => normalizeCanonical(e)).filter((e) => e !== '');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// renderLens(fragment, number) → the number-neutral canonical block bound to the file's OWN
|
|
60
|
+
// section number (memory-seeded files say 2.6, kit-fallback files 2.5 — the fragment never
|
|
61
|
+
// hardcodes one). LF-canonical; the CLI converts to the document's EOL style on write.
|
|
62
|
+
export const renderLens = (fragment, number) =>
|
|
63
|
+
normalizeCanonical(fragment).replace(NEUTRAL_HEADING_RE, (m) => m.replace('2.x', `2.${number}`));
|
|
64
|
+
|
|
65
|
+
// normalizeLensBody(body) → the number-neutral, whitespace/EOL-normalized comparison form
|
|
66
|
+
// (heading number → `2.x`, trim, CRLF→LF) every known-set match uses.
|
|
67
|
+
export const normalizeLensBody = (body) =>
|
|
68
|
+
normalizeCanonical(String(body).replace(/^### 2\.\d+\./, '### 2.x.'));
|
|
69
|
+
|
|
70
|
+
// extractLensRegion(text) → { found: false } | { found, start, end, number, body }.
|
|
71
|
+
// `start`/`end` are line indices over text.split('\n') — heading line through (exclusive) the
|
|
72
|
+
// next structural boundary; EOF is a valid region end (no following boundary required). `body`
|
|
73
|
+
// is the CR-stripped block with trailing blank lines dropped (the comparison form); the raw
|
|
74
|
+
// region lines (including trailing blanks) are what replaceLensRegion preserves around a render.
|
|
75
|
+
export const extractLensRegion = (text) => {
|
|
76
|
+
const lines = String(text).split('\n');
|
|
77
|
+
const start = lines.findIndex((line) => LENS_HEADING_RE.test(stripCr(line)));
|
|
78
|
+
if (start === -1) return { found: false };
|
|
79
|
+
let end = lines.length;
|
|
80
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
81
|
+
if (isBoundary(stripCr(lines[i]))) {
|
|
82
|
+
end = i;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const number = stripCr(lines[start]).match(LENS_HEADING_RE)[1];
|
|
87
|
+
const regionLines = lines.slice(start, end);
|
|
88
|
+
let bodyEnd = regionLines.length;
|
|
89
|
+
while (bodyEnd > 0 && stripCr(regionLines[bodyEnd - 1]).trim() === '') bodyEnd -= 1;
|
|
90
|
+
const body = regionLines.slice(0, bodyEnd).map(stripCr).join('\n');
|
|
91
|
+
return { found: true, start, end, number, body };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// replaceLensRegion(text, region, renderedBody) → the document with ONLY the region's block
|
|
95
|
+
// lines replaced; trailing blank lines inside the region and every byte outside it are
|
|
96
|
+
// preserved verbatim. Output keeps the document's EOL style (CRLF documents stay CRLF).
|
|
97
|
+
export const replaceLensRegion = (text, region, renderedBody) => {
|
|
98
|
+
const lines = String(text).split('\n');
|
|
99
|
+
const crlf = String(text).includes('\r\n');
|
|
100
|
+
const regionLines = lines.slice(region.start, region.end);
|
|
101
|
+
let bodyEnd = regionLines.length;
|
|
102
|
+
while (bodyEnd > 0 && stripCr(regionLines[bodyEnd - 1]).trim() === '') bodyEnd -= 1;
|
|
103
|
+
const trailing = regionLines.slice(bodyEnd); // preserved verbatim (their own CR bytes intact)
|
|
104
|
+
const newBody = renderedBody.split('\n').map((l) => (crlf ? `${l}\r` : l));
|
|
105
|
+
const out = [...lines.slice(0, region.start), ...newBody, ...trailing, ...lines.slice(region.end)];
|
|
106
|
+
// In a CRLF document, only a line FOLLOWED by another line carries the CR byte; when the new
|
|
107
|
+
// body's last line is the final line of the whole document (EOF region, no trailing newline),
|
|
108
|
+
// strip the CR we just added so no stray byte lands at EOF.
|
|
109
|
+
if (crlf && trailing.length === 0 && region.end === lines.length) {
|
|
110
|
+
out[out.length - 1] = stripCr(out[out.length - 1]);
|
|
111
|
+
}
|
|
112
|
+
return out.join('\n');
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// reconcileLensText(text, fragment, priors) — the PURE policy decision:
|
|
116
|
+
// { status: 'no-region' } — heading absent/renamed → preserve + advise (caller words it).
|
|
117
|
+
// { status: 'current' } — the region already renders the current canon → zero-diff.
|
|
118
|
+
// { status: 'refreshed', text } — the region matched the canon or a known prior → re-rendered
|
|
119
|
+
// with the file's OWN number (cap-guard is the caller's, so the
|
|
120
|
+
// decision stays pure).
|
|
121
|
+
// { status: 'custom' } — anything else → preserved byte-for-byte + advisory.
|
|
122
|
+
export const reconcileLensText = (text, fragment, priors) => {
|
|
123
|
+
const region = extractLensRegion(text);
|
|
124
|
+
if (!region.found) return { status: 'no-region', text };
|
|
125
|
+
const current = normalizeLensBody(region.body);
|
|
126
|
+
const canon = normalizeLensBody(fragment);
|
|
127
|
+
if (current === canon) return { status: 'current', text };
|
|
128
|
+
const known = priors.map((p) => normalizeLensBody(p));
|
|
129
|
+
if (!known.includes(current)) return { status: 'custom', text };
|
|
130
|
+
return { status: 'refreshed', text: replaceLensRegion(text, region, renderLens(fragment, region.number)) };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// frontmatterMaxLines(text) → the file's own `maxLines:` frontmatter value, or null when the
|
|
134
|
+
// file has no frontmatter block or the block carries no maxLines (→ the cap-guard is skipped
|
|
135
|
+
// with a stated note, never a throw — such a file is outside the docs cap gate anyway).
|
|
136
|
+
export const frontmatterMaxLines = (text) => {
|
|
137
|
+
const lines = String(text).split('\n').map(stripCr);
|
|
138
|
+
if (lines[0] !== '---') return null;
|
|
139
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
140
|
+
if (lines[i] === '---') return null;
|
|
141
|
+
const m = lines[i].match(/^maxLines:\s*(\d+)\s*$/);
|
|
142
|
+
if (m) return Number(m[1]);
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// ── CLI: `lens-region.mjs reconcile <path/to/agent_rules.md>` ─────────────────────
|
|
148
|
+
// Outcome lines are the contract the upgrade/bootstrap prose relays in plain language; exit 0 on
|
|
149
|
+
// every classified outcome (including the soft skips and the cap refusal), exit 1 ONLY on the
|
|
150
|
+
// hard engine STOP or an unexpected fs error, exit 2 on usage.
|
|
151
|
+
export const runCli = async (argv, deps = {}) => {
|
|
152
|
+
const log = deps.log ?? console.log;
|
|
153
|
+
const logError = deps.logError ?? console.error;
|
|
154
|
+
const env = deps.env ?? process.env;
|
|
155
|
+
const fs = deps.fs ?? (await import('node:fs/promises'));
|
|
156
|
+
const { dirname, basename, join, resolve } = await import('node:path');
|
|
157
|
+
const { homedir } = await import('node:os');
|
|
158
|
+
const { resolveEngineDir, detectEngine, readEngineFragment, LENS_FRAGMENT_REL, LENS_PRIORS_REL } = await import('./engine-source.mjs');
|
|
159
|
+
|
|
160
|
+
if (argv[0] !== 'reconcile' || !argv[1] || argv.length > 2) {
|
|
161
|
+
logError('usage: lens-region.mjs reconcile <path/to/agent_rules.md>');
|
|
162
|
+
return 2;
|
|
163
|
+
}
|
|
164
|
+
const targetPath = resolve(argv[1]);
|
|
165
|
+
|
|
166
|
+
// 1. Absent file → a stated skip (the memory substrate owns seeding; nothing to reconcile).
|
|
167
|
+
const text = await (async () => {
|
|
168
|
+
try {
|
|
169
|
+
return await fs.readFile(targetPath, 'utf8');
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err.code === 'ENOENT') return null;
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
})();
|
|
175
|
+
if (text === null) {
|
|
176
|
+
log(`[lens-region] ${argv[1]} is absent — skipped (nothing to reconcile; the substrate seeds it at bootstrap).`);
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 2. No matching heading → preserve + advise, engine never consulted (the outcome is preserve
|
|
181
|
+
// regardless, so the lazy contract holds).
|
|
182
|
+
if (!extractLensRegion(text).found) {
|
|
183
|
+
log(`[lens-region] no "${HEADING_LABEL}" section in ${argv[1]} — left untouched.`);
|
|
184
|
+
log('[lens-region] note: the planning/review lens section is missing or renamed — it cannot be auto-refreshed; restore the canonical heading to re-enable refresh.');
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 3. A present region must be classified → live-read the engine's fragment + prior store.
|
|
189
|
+
// Fully absent/invalid engine → loud STOP; valid-but-pre-lens engine → stated soft skip.
|
|
190
|
+
const { dir, source } = resolveEngineDir({ env, home: deps.home ?? homedir() });
|
|
191
|
+
const lensPairPresent =
|
|
192
|
+
detectEngine(dir, { source, rel: LENS_FRAGMENT_REL }).ok && detectEngine(dir, { source, rel: LENS_PRIORS_REL }).ok;
|
|
193
|
+
if (!lensPairPresent) {
|
|
194
|
+
if (detectEngine(dir, { source }).ok) {
|
|
195
|
+
log('[lens-region] skipped — the installed engine is too old (or incomplete) to supply the lens canon; refresh it with `npx @sabaiway/agent-workflow-engine@latest init`, then re-run.');
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL }); // throws the canonical install-me error
|
|
200
|
+
return 1; // defensive: the pair is unusable — never proceed to a read
|
|
201
|
+
} catch (err) {
|
|
202
|
+
logError(`[lens-region] reconcile STOP — ${err.message}`);
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// TOCTOU guard: a fragment that vanishes between detect and read is a corruption STOP, never a
|
|
207
|
+
// silent proceed — readEngineFragment's own throw carries the install command.
|
|
208
|
+
let fragment;
|
|
209
|
+
let priors;
|
|
210
|
+
try {
|
|
211
|
+
fragment = readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL });
|
|
212
|
+
priors = parseLensPriors(readEngineFragment(dir, { source, rel: LENS_PRIORS_REL }));
|
|
213
|
+
} catch (err) {
|
|
214
|
+
logError(`[lens-region] reconcile STOP — ${err.message}`);
|
|
215
|
+
return 1;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 4. The pure decision + the cap-guard + one atomic write.
|
|
219
|
+
const result = reconcileLensText(text, fragment, priors);
|
|
220
|
+
if (result.status === 'current') {
|
|
221
|
+
log('[lens-region] lens section already current — nothing to do (zero-diff).');
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
if (result.status === 'custom') {
|
|
225
|
+
log('[lens-region] lens section carries a custom edit — preserved verbatim.');
|
|
226
|
+
log('[lens-region] note: the canonical planning/review lens has changed since this section was edited — compare it with the project methodology canon when convenient; your wording is never overwritten.');
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
// refreshed → cap-guard from the TARGET's own frontmatter, then atomic write.
|
|
230
|
+
const maxLines = frontmatterMaxLines(text);
|
|
231
|
+
if (maxLines === null) {
|
|
232
|
+
log('[lens-region] note: no `maxLines` frontmatter on the target — the line-cap guard is skipped.');
|
|
233
|
+
} else if (lineCount(result.text) > maxLines) {
|
|
234
|
+
log(`[lens-region] refused — refreshing would push ${argv[1]} to ${lineCount(result.text)} lines (cap ${maxLines}); trim the file and re-run. Nothing was changed.`);
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
const tmp = join(dirname(targetPath), `.${basename(targetPath)}.tmp-${process.pid}-${Date.now()}`);
|
|
238
|
+
try {
|
|
239
|
+
await fs.writeFile(tmp, result.text, 'utf8');
|
|
240
|
+
await fs.rename(tmp, targetPath);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
log('[lens-region] refreshed the planning/review lens section to the current canon.');
|
|
246
|
+
return 0;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const { pathToFileURL } = await import('node:url');
|
|
250
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
251
|
+
if (isDirectRun) process.exitCode = await runCli(process.argv.slice(2));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { join, relative } from 'node:path';
|
|
3
|
-
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { join, relative, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
4
|
|
|
5
5
|
// Velocity-profile core + writer: a fixed, audited read-only allowlist that an onboarding step seeds
|
|
6
6
|
// into `.claude/settings.json` so routine read-only commands stop idling on approval prompts.
|
|
@@ -40,6 +40,13 @@ export const UNIVERSAL_READONLY_ALLOWLIST = Object.freeze([
|
|
|
40
40
|
'Bash(git ls-files:*)',
|
|
41
41
|
'Bash(git check-ignore:*)',
|
|
42
42
|
'Bash(git branch --list:*)',
|
|
43
|
+
'Bash(git rev-parse:*)',
|
|
44
|
+
'Bash(git blame:*)',
|
|
45
|
+
'Bash(git shortlog:*)',
|
|
46
|
+
'Bash(git describe:*)',
|
|
47
|
+
'Bash(git tag --list:*)',
|
|
48
|
+
'Bash(git stash list:*)',
|
|
49
|
+
'Bash(git worktree list:*)',
|
|
43
50
|
'Bash(npm view:*)',
|
|
44
51
|
'Bash(npm ls:*)',
|
|
45
52
|
'Bash(npm outdated:*)',
|
|
@@ -51,6 +58,12 @@ export const UNIVERSAL_READONLY_ALLOWLIST = Object.freeze([
|
|
|
51
58
|
'Bash(readlink:*)',
|
|
52
59
|
'Bash(which:*)',
|
|
53
60
|
'Bash(grep:*)',
|
|
61
|
+
'Bash(diff:*)',
|
|
62
|
+
'Bash(stat:*)',
|
|
63
|
+
'Bash(du:*)',
|
|
64
|
+
'Bash(basename:*)',
|
|
65
|
+
'Bash(dirname:*)',
|
|
66
|
+
'Bash(realpath:*)',
|
|
54
67
|
]);
|
|
55
68
|
|
|
56
69
|
// Per-tool POSITIVE allowlists, as frozen arrays. NOTE: `Object.freeze` on a Set does NOT prevent
|
|
@@ -64,6 +77,18 @@ export const GIT_READONLY_SUBCOMMANDS = Object.freeze([
|
|
|
64
77
|
'ls-files',
|
|
65
78
|
'check-ignore',
|
|
66
79
|
'branch --list',
|
|
80
|
+
'rev-parse',
|
|
81
|
+
'blame',
|
|
82
|
+
'shortlog',
|
|
83
|
+
'describe',
|
|
84
|
+
// `git cat-file` is deliberately ABSENT (diff-council fold, AD-040): `--textconv`/`--filters`
|
|
85
|
+
// activate CONFIGURED external filters under an auto-approved command, and its read utility is
|
|
86
|
+
// marginal next to the kept `git show`.
|
|
87
|
+
// Fixed read-only forms ONLY — the bare `git tag` / `git stash` / `git worktree` forms mutate
|
|
88
|
+
// (probe-proven, AD-040), the same multi-token precedent as 'branch --list'.
|
|
89
|
+
'tag --list',
|
|
90
|
+
'stash list',
|
|
91
|
+
'worktree list',
|
|
67
92
|
]);
|
|
68
93
|
export const NPM_READONLY_SUBCOMMANDS = Object.freeze(['view', 'ls', 'outdated']);
|
|
69
94
|
export const SHELL_READONLY = Object.freeze([
|
|
@@ -75,7 +100,56 @@ export const SHELL_READONLY = Object.freeze([
|
|
|
75
100
|
'readlink',
|
|
76
101
|
'which',
|
|
77
102
|
'grep',
|
|
103
|
+
// AD-040 audit survivors. `file` deliberately FAILED the audit (`-C -m <magic>` compiles a magic
|
|
104
|
+
// FILE WRITE — probe-proven) and stays a hand-add candidate only. `diff -l/--paginate` execs `pr`
|
|
105
|
+
// by a build-time FIXED absolute path (probed NOT PATH-resolved) — not arbitrary exec, kept.
|
|
106
|
+
'diff',
|
|
107
|
+
'stat',
|
|
108
|
+
'du',
|
|
109
|
+
'basename',
|
|
110
|
+
'dirname',
|
|
111
|
+
'realpath',
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
// ── the opt-in --kit-tools tier (AD-040) ────────────────────────────────────────────────
|
|
115
|
+
// A SEPARATE frozen tier, never an extension of UNIVERSAL_READONLY_ALLOWLIST: the core stays the
|
|
116
|
+
// hook-parity surface (test/gate-hook-core-parity.test.mjs), while tier entries are derived at
|
|
117
|
+
// seed time from the RUNNING tool's own location — resolved-absolute, so a moved or reinstalled
|
|
118
|
+
// skill leaves a stale rule that FAIL-SAFE prompts again (never a silent widening).
|
|
119
|
+
//
|
|
120
|
+
// Membership (9, frozen): the read-only kit tools plus run-gates.mjs — which is NOT read-only but
|
|
121
|
+
// project-exec (it runs the project's OWN declared gates.json commands), so it seeds as ONE exact
|
|
122
|
+
// byte-string pinned to this project root (`--cwd <resolved root>`): a wildcard would be BROADER
|
|
123
|
+
// than the AD-037 hook boundary (`--cwd <dir>` executes an arbitrary OTHER project's gates.json)
|
|
124
|
+
// and a cwd-defaulting entry would follow the shell's current directory into subdirs. NEVER the
|
|
125
|
+
// writers. review-state.mjs's read-only git spawns are in-scope read-only.
|
|
126
|
+
export const KIT_RUN_GATES_TOOL = 'tools/run-gates.mjs';
|
|
127
|
+
export const KIT_READONLY_TOOLS = Object.freeze([
|
|
128
|
+
'tools/recipes.mjs',
|
|
129
|
+
'tools/procedures.mjs',
|
|
130
|
+
'tools/family-registry.mjs',
|
|
131
|
+
'tools/detect-backends.mjs',
|
|
132
|
+
'tools/commands.mjs',
|
|
133
|
+
'tools/review-state.mjs',
|
|
134
|
+
KIT_RUN_GATES_TOOL,
|
|
135
|
+
'tools/manifest/validate.mjs',
|
|
136
|
+
'tools/release-scan.mjs',
|
|
137
|
+
]);
|
|
138
|
+
// Writer previews: ONLY writers whose ARG-FREE invocation is a documented dry-run ("Default is
|
|
139
|
+
// --dry-run" in their usage) seed an EXACT preview byte-string — every --apply/--write/--yes keeps
|
|
140
|
+
// its prompt. set-recipe is excluded (its preview takes variable --set ops); setup-backends and
|
|
141
|
+
// hide-footprint are excluded (their arg-free forms APPLY); uninstall is excluded (a guarded
|
|
142
|
+
// teardown documents --dry-run explicitly — its bare form is not its preview contract).
|
|
143
|
+
export const KIT_WRITER_PREVIEW_TOOLS = Object.freeze([
|
|
144
|
+
'tools/velocity-profile.mjs',
|
|
145
|
+
'tools/cheap-agents.mjs',
|
|
146
|
+
'tools/gate-hook.mjs',
|
|
78
147
|
]);
|
|
148
|
+
const KIT_WILDCARD_TOOLS = Object.freeze(KIT_READONLY_TOOLS.filter((rel) => rel !== KIT_RUN_GATES_TOOL));
|
|
149
|
+
// The kit root this tool runs from (tools/..) — the tier's seed-time path anchor.
|
|
150
|
+
const KIT_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
151
|
+
const KIT_TOOL_INVOKER = 'node';
|
|
152
|
+
const RUN_GATES_CWD_FLAG = '--cwd';
|
|
79
153
|
|
|
80
154
|
// Characters that make a pattern NOT a single read-only command, so the screen rejects them.
|
|
81
155
|
// Per current Claude Code permission semantics: the recognized command SEPARATORS are
|
|
@@ -125,6 +199,9 @@ export const VELOCITY_SYMLINK = 'VELOCITY_SYMLINK';
|
|
|
125
199
|
const VELOCITY_ERROR_NAME = 'VelocityProfileError';
|
|
126
200
|
const ERROR_PREFIX = '[agent-workflow-kit]';
|
|
127
201
|
const BASH_ALLOW_PATTERN = /^Bash\((.+):\*\)$/u;
|
|
202
|
+
// The EXACT (non-wildcard) allow form `Bash(cmd)` — tier-only (run-gates + writer previews); the
|
|
203
|
+
// git/npm/shell core classes stay wildcard-only so the pre-existing advisory behavior never shifts.
|
|
204
|
+
const BASH_EXACT_ALLOW_PATTERN = /^Bash\((.+)\)$/u;
|
|
128
205
|
const BASH_PERMISSION_PATTERN = /^Bash\(.+\)$/u;
|
|
129
206
|
const WHITESPACE_PATTERN = /\s+/u;
|
|
130
207
|
const GIT_COMMAND = 'git';
|
|
@@ -143,6 +220,7 @@ const JSON_NEWLINE_PATTERN = /\n/gu;
|
|
|
143
220
|
const FLAG_DRY_RUN = '--dry-run';
|
|
144
221
|
const FLAG_APPLY = '--apply';
|
|
145
222
|
const FLAG_ACCEPT_EDITS = '--accept-edits';
|
|
223
|
+
const FLAG_KIT_TOOLS = '--kit-tools';
|
|
146
224
|
const FLAG_CWD = '--cwd';
|
|
147
225
|
const FLAG_HELP = '--help';
|
|
148
226
|
const SHORT_FLAG_HELP = '-h';
|
|
@@ -155,12 +233,16 @@ const MUTATING_SCRIPT_HOOK_PATTERN = /^(pre|post)/iu;
|
|
|
155
233
|
// refusal is named, tested, and produces a clear message).
|
|
156
234
|
const MUTATING_ALLOW_COMMAND_PATTERN = /^(?:git\s+(?:commit|push)|npm\s+publish)(?:\s|$)/iu;
|
|
157
235
|
const RESIDUAL_NOTICE =
|
|
158
|
-
'residual: seeded read-only allow entries are a trust-posture convenience, NOT a sandbox; settings-level rules cannot inspect runtime redirection/command-substitution/--output writes; commit/push/publish are never allowlisted (a DIRECT invocation still ASKs, but the runtime residual is not closed here); the residual guard ships as the opt-in PreToolUse hook — Mode: hook (/agent-workflow-kit hook).';
|
|
236
|
+
'residual: seeded read-only allow entries are a trust-posture convenience, NOT a sandbox; settings-level rules cannot inspect runtime redirection/command-substitution/--output writes; commit/push/publish are never allowlisted (a DIRECT invocation still ASKs, but the runtime residual is not closed here); the residual guard ships as the opt-in PreToolUse hook — Mode: hook (/agent-workflow-kit hook). floor (never auto-approved, with or without the tier): every writer --apply/--write/--yes still prompts; clobber-protection STOPs still stop; the three release asks (commit/push/publish) stay maintainer-owned.';
|
|
159
237
|
|
|
160
|
-
const USAGE = `usage: velocity-profile [--dry-run | --apply] [--accept-edits] [--cwd <dir>] [--help]
|
|
238
|
+
const USAGE = `usage: velocity-profile [--dry-run | --apply] [--kit-tools] [--accept-edits] [--cwd <dir>] [--help]
|
|
161
239
|
|
|
162
240
|
Seeds the fixed read-only Claude Code allowlist into .claude/settings.json.
|
|
163
|
-
Default is --dry-run. --apply writes; --accept-edits only sets defaultMode when applying
|
|
241
|
+
Default is --dry-run. --apply writes; --accept-edits only sets defaultMode when applying.
|
|
242
|
+
--kit-tools additionally seeds the audited kit-tool tier: 8 read-only kit tools by resolved
|
|
243
|
+
absolute path (args wildcard), run-gates.mjs as ONE exact project-root-pinned byte-string
|
|
244
|
+
(project-exec - it runs YOUR declared gates.json), and the writers' exact arg-free dry-run
|
|
245
|
+
preview byte-strings. Never touches settings.local.json.`;
|
|
164
246
|
|
|
165
247
|
const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
166
248
|
|
|
@@ -245,6 +327,36 @@ const getBashAllowCommand = (pattern) => {
|
|
|
245
327
|
return match ? match[1] : undefined;
|
|
246
328
|
};
|
|
247
329
|
|
|
330
|
+
// Parse the exact (non-wildcard) form ONLY when the wildcard form does not match — `Bash(cmd:*)`
|
|
331
|
+
// would otherwise parse here as `cmd:*`.
|
|
332
|
+
const getBashExactCommand = (pattern) => {
|
|
333
|
+
if (typeof pattern !== 'string' || BASH_ALLOW_PATTERN.test(pattern)) return undefined;
|
|
334
|
+
const match = pattern.match(BASH_EXACT_ALLOW_PATTERN);
|
|
335
|
+
return match ? match[1] : undefined;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// Characters that survive whitespace tokenization but break an UNQUOTED byte-exact path rule:
|
|
339
|
+
// shell quoting syntax and glob brackets (SHELL_METACHARACTERS owns the command-level separators/
|
|
340
|
+
// redirections/expansions — `*`/`?` globs included — but not these four).
|
|
341
|
+
const PATH_BREAKING_CHARACTERS = Object.freeze(["'", '"', '[', ']']);
|
|
342
|
+
|
|
343
|
+
// A path token that can be seeded UNQUOTED into a byte-exact allow rule: POSIX-absolute, no
|
|
344
|
+
// whitespace, no shell metacharacter, no quoting/glob syntax.
|
|
345
|
+
const isSeedablePathToken = (token) =>
|
|
346
|
+
typeof token === 'string' &&
|
|
347
|
+
token.startsWith('/') &&
|
|
348
|
+
!/\s/u.test(token) &&
|
|
349
|
+
!hasShellMetacharacter(token) &&
|
|
350
|
+
!PATH_BREAKING_CHARACTERS.some((ch) => token.includes(ch));
|
|
351
|
+
|
|
352
|
+
// A tier path token must be SEEDABLE (a relative or shell-syntax-carrying spelling is a dead rule
|
|
353
|
+
// the screen refuses to bless) and end on a known tier-tool tail. Any seedable absolute prefix is
|
|
354
|
+
// accepted — the user's own kit copy elsewhere is legitimate shape-wise; entries OUTSIDE the
|
|
355
|
+
// derived tier are still flagged by the pre-existing advisory, and membership in the SEEDED set
|
|
356
|
+
// stays enforced separately by validateProfile's audited-set check.
|
|
357
|
+
const isKitToolPathToken = (token, relPaths) =>
|
|
358
|
+
isSeedablePathToken(token) && relPaths.some((rel) => token.endsWith(`/${rel}`));
|
|
359
|
+
|
|
248
360
|
const isSingleShellToken = (cmd, tokens) => tokens.length === 1 && cmd === tokens[0];
|
|
249
361
|
|
|
250
362
|
const isScriptMap = (scripts) => Boolean(scripts) && typeof scripts === 'object' && !Array.isArray(scripts);
|
|
@@ -309,11 +421,23 @@ const assertClaudeDirSafe = (cwd, deps = {}) => {
|
|
|
309
421
|
return { claudeDirAbsent: false };
|
|
310
422
|
};
|
|
311
423
|
|
|
312
|
-
|
|
424
|
+
// A kit-tool-SHAPED entry (any `node …` allow entry, wildcard or exact) passes the shape screen on
|
|
425
|
+
// any absolute path — but only the entries the tier itself derives for THIS kit + THIS root are
|
|
426
|
+
// audited. Everything else `node`-shaped is arbitrary local JS / foreign project-exec and must
|
|
427
|
+
// stay flagged for hand review (diff-council fold, AD-040).
|
|
428
|
+
const isKitToolShapedCommand = (entry) => {
|
|
429
|
+
const cmd = getBashAllowCommand(entry) ?? getBashExactCommand(entry);
|
|
430
|
+
return cmd !== undefined && tokenizeCommand(cmd)[0] === KIT_TOOL_INVOKER;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const collectPreExistingNonReadonly = (sources, derivedTier = []) =>
|
|
313
434
|
sources.flatMap(({ source, data }) =>
|
|
314
435
|
getAllowEntries(data)
|
|
315
436
|
.filter((entry) => typeof entry === 'string' && BASH_PERMISSION_PATTERN.test(entry))
|
|
316
|
-
.filter(
|
|
437
|
+
.filter(
|
|
438
|
+
(entry) =>
|
|
439
|
+
!screenAllowlistEntry(entry) || (isKitToolShapedCommand(entry) && !derivedTier.includes(entry)),
|
|
440
|
+
)
|
|
317
441
|
.map((entry) => ({ source, entry })),
|
|
318
442
|
);
|
|
319
443
|
|
|
@@ -351,6 +475,21 @@ const formatAllowlist = (result) => [
|
|
|
351
475
|
`already present: ${result.alreadyPresent.length}`,
|
|
352
476
|
];
|
|
353
477
|
|
|
478
|
+
// The tier's honest posture, printed on every --kit-tools run: run-gates is project-exec (never
|
|
479
|
+
// "read-only"), previews stay dry-run-only, and the tier gets none of the hook's residual ask-net.
|
|
480
|
+
const KIT_TIER_NOTICE =
|
|
481
|
+
'kit-tools tier: paths are resolved absolute at seed time (fail-safe - a moved skill or stale path simply prompts again); run-gates.mjs is seeded as ONE exact byte-string pinned to this project root and is project-exec - it runs YOUR declared gates.json commands, never "read-only"; writer previews are exact dry-run byte-strings - every --apply/--write/--yes still prompts; tier entries get NO PreToolUse-hook residual coverage (settings-level posture only - see the velocity mode notes).';
|
|
482
|
+
|
|
483
|
+
const formatKitTier = (result) =>
|
|
484
|
+
result.kitTools
|
|
485
|
+
? [
|
|
486
|
+
`${result.wrote ? 'added' : 'would add'} kit-tools tier entries: ${result.tierToAdd.length}`,
|
|
487
|
+
...formatEntryList(result.tierToAdd),
|
|
488
|
+
`already present (tier): ${result.tierAlreadyPresent.length}`,
|
|
489
|
+
KIT_TIER_NOTICE,
|
|
490
|
+
]
|
|
491
|
+
: [];
|
|
492
|
+
|
|
354
493
|
const formatGateAdvisory = (gateCandidates) => [
|
|
355
494
|
'gate advisory: candidates you may add BY HAND to .claude/settings.json or settings.local.json; this tool will NOT add them',
|
|
356
495
|
...(gateCandidates.length
|
|
@@ -375,6 +514,7 @@ const formatVelocityProfileResult = (result) =>
|
|
|
375
514
|
[
|
|
376
515
|
result.dryRun ? 'agent-workflow velocity profile - DRY RUN (no changes)' : 'agent-workflow velocity profile - APPLY',
|
|
377
516
|
...formatAllowlist(result),
|
|
517
|
+
...formatKitTier(result),
|
|
378
518
|
formatDefaultMode(result),
|
|
379
519
|
...formatGateAdvisory(result.gateCandidates),
|
|
380
520
|
...formatPreExistingAdvisory(result.preExistingNonReadonly),
|
|
@@ -386,16 +526,69 @@ const formatVelocityProfileResult = (result) =>
|
|
|
386
526
|
const exitCodeFor = (err) => err?.exitCode ?? EXIT_PRECONDITION;
|
|
387
527
|
|
|
388
528
|
// `:*` is the trailing word-boundary wildcard: equivalent to a trailing ` *`, recognized only at the
|
|
389
|
-
// end of a pattern per Claude Code semantics.
|
|
529
|
+
// end of a pattern per Claude Code semantics. Two forms are recognized: the wildcard form (the
|
|
530
|
+
// git/npm/shell core + the kit-tool wildcard class) and the EXACT form (tier-only: the root-pinned
|
|
531
|
+
// run-gates byte-string + the writer-preview dry-run byte-strings).
|
|
390
532
|
export const screenAllowlistEntry = (pattern) => {
|
|
391
|
-
const
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
533
|
+
const wildcardCmd = getBashAllowCommand(pattern);
|
|
534
|
+
if (wildcardCmd !== undefined) {
|
|
535
|
+
if (hasShellMetacharacter(wildcardCmd)) return false;
|
|
536
|
+
const tokens = tokenizeCommand(wildcardCmd);
|
|
537
|
+
if (tokens[0] === GIT_COMMAND) return GIT_READONLY_SUBCOMMANDS.includes(getSubcommand(tokens));
|
|
538
|
+
if (tokens[0] === NPM_COMMAND) return NPM_READONLY_SUBCOMMANDS.includes(getSubcommand(tokens));
|
|
539
|
+
// Kit-tool wildcard class: `node <abs kit tool>` + args wildcard. run-gates is deliberately NOT
|
|
540
|
+
// here (Decision 3: only its exact root-pinned form below — a wildcard would cover `--cwd <any>`).
|
|
541
|
+
if (tokens[0] === KIT_TOOL_INVOKER) return tokens.length === 2 && isKitToolPathToken(tokens[1], KIT_WILDCARD_TOOLS);
|
|
542
|
+
return isSingleShellToken(wildcardCmd, tokens) && SHELL_READONLY.includes(wildcardCmd);
|
|
543
|
+
}
|
|
544
|
+
const exactCmd = getBashExactCommand(pattern);
|
|
545
|
+
if (exactCmd === undefined || hasShellMetacharacter(exactCmd)) return false;
|
|
546
|
+
const tokens = tokenizeCommand(exactCmd);
|
|
547
|
+
if (tokens[0] !== KIT_TOOL_INVOKER) return false;
|
|
548
|
+
// Writer preview: the arg-free dry-run byte-string of a preview-class writer.
|
|
549
|
+
if (tokens.length === 2) return isKitToolPathToken(tokens[1], KIT_WRITER_PREVIEW_TOOLS);
|
|
550
|
+
// run-gates: EXACTLY `node <abs run-gates> --cwd <abs root>` — any other form keeps prompting.
|
|
551
|
+
return (
|
|
552
|
+
tokens.length === 4 &&
|
|
553
|
+
isKitToolPathToken(tokens[1], [KIT_RUN_GATES_TOOL]) &&
|
|
554
|
+
tokens[2] === RUN_GATES_CWD_FLAG &&
|
|
555
|
+
isSeedablePathToken(tokens[3])
|
|
556
|
+
);
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Derive the opt-in kit-tools tier for a project: 8 wildcard entries (resolved-absolute script
|
|
561
|
+
* path + args wildcard), ONE exact run-gates entry pinned to the resolved project root, and the
|
|
562
|
+
* writer-preview exact dry-run byte-strings. Pure derivation — a stale path simply prompts again.
|
|
563
|
+
*/
|
|
564
|
+
// A seedable path must survive UNQUOTED inside a byte-exact allow rule: POSIX-absolute, no
|
|
565
|
+
// whitespace, no shell metacharacter, no quoting/glob syntax (isSeedablePathToken). Anything else
|
|
566
|
+
// is refused UP FRONT with a clear error (a Windows-shaped, space- or quote-carrying path would
|
|
567
|
+
// otherwise die downstream as a confusing VELOCITY_NON_READONLY, or seed dead/shell-reinterpreted
|
|
568
|
+
// byte strings) — the hand-add advisory is the fallback.
|
|
569
|
+
const assertSeedablePath = (label, absPath) => {
|
|
570
|
+
if (isSeedablePathToken(absPath)) return;
|
|
571
|
+
throw makeVelocityProfileError(
|
|
572
|
+
VELOCITY_INVALID_ARGUMENT,
|
|
573
|
+
`kit-tools tier: the ${label} "${absPath}" is not a POSIX absolute path free of spaces/metacharacters/quoting - byte-exact allow rules cannot be seeded for it; add hand-picked entries to your settings instead (hand-add)`,
|
|
574
|
+
{ entry: absPath },
|
|
575
|
+
);
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
export const deriveKitToolsAllowlist = ({ projectDir } = {}) => {
|
|
579
|
+
if (typeof projectDir !== 'string' || projectDir === '') {
|
|
580
|
+
throw makeVelocityProfileError(VELOCITY_INVALID_ARGUMENT, 'kit-tools derivation needs a project directory', {
|
|
581
|
+
entry: projectDir,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
const projectRoot = resolve(projectDir);
|
|
585
|
+
assertSeedablePath('resolved project root', projectRoot);
|
|
586
|
+
assertSeedablePath('resolved skill dir', join(KIT_ROOT, '.'));
|
|
587
|
+
return Object.freeze([
|
|
588
|
+
...KIT_WILDCARD_TOOLS.map((rel) => `Bash(${KIT_TOOL_INVOKER} ${join(KIT_ROOT, rel)}:*)`),
|
|
589
|
+
`Bash(${KIT_TOOL_INVOKER} ${join(KIT_ROOT, KIT_RUN_GATES_TOOL)} ${RUN_GATES_CWD_FLAG} ${projectRoot})`,
|
|
590
|
+
...KIT_WRITER_PREVIEW_TOOLS.map((rel) => `Bash(${KIT_TOOL_INVOKER} ${join(KIT_ROOT, rel)})`),
|
|
591
|
+
]);
|
|
399
592
|
};
|
|
400
593
|
|
|
401
594
|
export const discoverGateCandidates = (packageJson) => {
|
|
@@ -405,12 +598,14 @@ export const discoverGateCandidates = (packageJson) => {
|
|
|
405
598
|
};
|
|
406
599
|
|
|
407
600
|
/**
|
|
408
|
-
* Validate that what we are about to write is a subset of the audited
|
|
601
|
+
* Validate that what we are about to write is a subset of the SELECTED audited set: every entry
|
|
409
602
|
* must (a) NOT be a commit/push/publish allow entry, (b) pass the read-only screen, and (c) be a
|
|
410
|
-
* member of
|
|
411
|
-
*
|
|
603
|
+
* member of the audited set (a guard against the constants drifting). The argument-less call keeps
|
|
604
|
+
* its exact historical semantics — the full core validated against itself; a --kit-tools run
|
|
605
|
+
* additionally validates the derived tier against core + tier. Pure; owns no exit codes — a CLI
|
|
606
|
+
* maps the typed `.code` to a process exit.
|
|
412
607
|
*/
|
|
413
|
-
export const validateProfile = (allowEntries = UNIVERSAL_READONLY_ALLOWLIST) => {
|
|
608
|
+
export const validateProfile = (allowEntries = UNIVERSAL_READONLY_ALLOWLIST, auditedSet = UNIVERSAL_READONLY_ALLOWLIST) => {
|
|
414
609
|
if (!Array.isArray(allowEntries)) {
|
|
415
610
|
throw makeVelocityProfileError(VELOCITY_INVALID_ARGUMENT, 'allow entries must be an array', { entry: allowEntries });
|
|
416
611
|
}
|
|
@@ -422,7 +617,7 @@ export const validateProfile = (allowEntries = UNIVERSAL_READONLY_ALLOWLIST) =>
|
|
|
422
617
|
if (!screenAllowlistEntry(entry)) {
|
|
423
618
|
throw makeVelocityProfileError(VELOCITY_NON_READONLY, `not a read-only allow entry: ${entry}`, { entry });
|
|
424
619
|
}
|
|
425
|
-
if (!
|
|
620
|
+
if (!auditedSet.includes(entry)) {
|
|
426
621
|
throw makeVelocityProfileError(VELOCITY_OFFCORE, `allow entry is outside the audited core: ${entry}`, { entry });
|
|
427
622
|
}
|
|
428
623
|
}
|
|
@@ -480,10 +675,25 @@ export const preflightVelocityProfile = ({ cwd }, deps = {}) => {
|
|
|
480
675
|
);
|
|
481
676
|
}
|
|
482
677
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
678
|
+
// The advisory compares kit-tool-shaped entries against the tier derived for THIS kit + root.
|
|
679
|
+
// Defensive derive: on a machine whose paths cannot seed (space/metacharacter — the tier itself
|
|
680
|
+
// refuses loudly on --kit-tools), the flagless advisory falls back to flagging EVERY node-shaped
|
|
681
|
+
// entry — over-flagging is the safe direction, and flagless behavior never gains a new failure
|
|
682
|
+
// mode from local skill paths.
|
|
683
|
+
const derivedTier = (() => {
|
|
684
|
+
try {
|
|
685
|
+
return deriveKitToolsAllowlist({ projectDir });
|
|
686
|
+
} catch {
|
|
687
|
+
return [];
|
|
688
|
+
}
|
|
689
|
+
})();
|
|
690
|
+
const preExistingNonReadonly = collectPreExistingNonReadonly(
|
|
691
|
+
[
|
|
692
|
+
{ source: SETTINGS_FILE, data: projectSettings.data },
|
|
693
|
+
{ source: SETTINGS_LOCAL_FILE, data: localSettings.data },
|
|
694
|
+
],
|
|
695
|
+
derivedTier,
|
|
696
|
+
);
|
|
487
697
|
const gateCandidates = discoverGateCandidates(readPackageJson(projectDir, deps));
|
|
488
698
|
|
|
489
699
|
return {
|
|
@@ -500,21 +710,30 @@ export const preflightVelocityProfile = ({ cwd }, deps = {}) => {
|
|
|
500
710
|
};
|
|
501
711
|
};
|
|
502
712
|
|
|
503
|
-
export const planVelocityProfile = (preflight, { acceptEdits } = {}) => {
|
|
713
|
+
export const planVelocityProfile = (preflight, { acceptEdits, kitTools } = {}) => {
|
|
504
714
|
const projectAllow = getAllowEntries(preflight.projectSettings?.data);
|
|
505
715
|
const toAdd = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => !projectAllow.includes(entry));
|
|
506
716
|
const alreadyPresent = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => projectAllow.includes(entry));
|
|
507
|
-
|
|
717
|
+
const tier = kitTools === true ? deriveKitToolsAllowlist({ projectDir: preflight.cwd }) : [];
|
|
718
|
+
const tierToAdd = tier.filter((entry) => !projectAllow.includes(entry));
|
|
719
|
+
const tierAlreadyPresent = tier.filter((entry) => projectAllow.includes(entry));
|
|
720
|
+
return { toAdd, alreadyPresent, tierToAdd, tierAlreadyPresent, kitTools: kitTools === true, setsDefaultMode: acceptEdits === true };
|
|
508
721
|
};
|
|
509
722
|
|
|
510
|
-
export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true } = {}, deps = {}) => {
|
|
723
|
+
export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true, kitTools = false } = {}, deps = {}) => {
|
|
511
724
|
const projectDir = cwd ?? deps.cwd ?? process.cwd();
|
|
512
725
|
const preflight = preflightVelocityProfile({ cwd: projectDir }, deps);
|
|
513
|
-
const plan = planVelocityProfile(preflight, { acceptEdits });
|
|
726
|
+
const plan = planVelocityProfile(preflight, { acceptEdits, kitTools });
|
|
514
727
|
// Drift guard runs on BOTH dry-run and apply (so a dry-run faithfully predicts the apply) and
|
|
515
728
|
// validates the FULL audited core, not just the to-add delta — a drifted core entry is caught even
|
|
516
729
|
// when it is already present in the user's allow list (and toAdd is a subset of the core anyway).
|
|
517
730
|
validateProfile();
|
|
731
|
+
// A --kit-tools run additionally validates the SELECTED tier against core + tier — flagless
|
|
732
|
+
// behavior never depends on local skill paths (the derivation does not even run without the flag).
|
|
733
|
+
if (kitTools === true) {
|
|
734
|
+
const tier = deriveKitToolsAllowlist({ projectDir });
|
|
735
|
+
validateProfile(tier, [...UNIVERSAL_READONLY_ALLOWLIST, ...tier]);
|
|
736
|
+
}
|
|
518
737
|
const resultBase = { ...preflight, ...plan };
|
|
519
738
|
if (dryRun) return { wrote: false, dryRun: true, ...resultBase };
|
|
520
739
|
|
|
@@ -528,7 +747,7 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true }
|
|
|
528
747
|
const fs = fsDeps(deps);
|
|
529
748
|
const settingsPath = join(projectDir, SETTINGS_FILE);
|
|
530
749
|
if (preflight.claudeDirAbsent) fs.mkdir(join(projectDir, CLAUDE_DIR), { recursive: true });
|
|
531
|
-
const merged = mergeProjectSettings(preflight.projectSettings.data, plan.toAdd, acceptEdits);
|
|
750
|
+
const merged = mergeProjectSettings(preflight.projectSettings.data, [...plan.toAdd, ...plan.tierToAdd], acceptEdits);
|
|
532
751
|
fs.writeFile(settingsPath, formatJson(merged, preflight.projectSettings.eol ?? LF), UTF8);
|
|
533
752
|
return { wrote: true, dryRun: false, settingsPath, ...resultBase };
|
|
534
753
|
};
|
|
@@ -541,6 +760,7 @@ export const parseArgs = (argv) => {
|
|
|
541
760
|
if (arg === FLAG_DRY_RUN) return { ...state, dryRunFlag: true };
|
|
542
761
|
if (arg === FLAG_APPLY) return { ...state, apply: true };
|
|
543
762
|
if (arg === FLAG_ACCEPT_EDITS) return { ...state, acceptEdits: true };
|
|
763
|
+
if (arg === FLAG_KIT_TOOLS) return { ...state, kitTools: true };
|
|
544
764
|
if (arg === FLAG_CWD) {
|
|
545
765
|
const next = allArgs[index + 1];
|
|
546
766
|
if (next === undefined || next.startsWith('-')) throw fail(EXIT_USAGE, `${FLAG_CWD} needs a directory argument`);
|
|
@@ -549,7 +769,7 @@ export const parseArgs = (argv) => {
|
|
|
549
769
|
if (arg.startsWith('-')) throw fail(EXIT_USAGE, `unknown flag: ${arg}`);
|
|
550
770
|
throw fail(EXIT_USAGE, `unexpected argument: ${arg}`);
|
|
551
771
|
},
|
|
552
|
-
{ help: false, dryRunFlag: false, apply: false, acceptEdits: false, cwd: undefined, skipNext: false },
|
|
772
|
+
{ help: false, dryRunFlag: false, apply: false, acceptEdits: false, kitTools: false, cwd: undefined, skipNext: false },
|
|
553
773
|
);
|
|
554
774
|
|
|
555
775
|
if (parsed.dryRunFlag && parsed.apply) throw fail(EXIT_USAGE, `${FLAG_DRY_RUN} and ${FLAG_APPLY} cannot be used together`);
|
|
@@ -558,6 +778,7 @@ export const parseArgs = (argv) => {
|
|
|
558
778
|
dryRun: parsed.apply ? false : true,
|
|
559
779
|
apply: parsed.apply,
|
|
560
780
|
acceptEdits: parsed.acceptEdits,
|
|
781
|
+
kitTools: parsed.kitTools,
|
|
561
782
|
cwd: parsed.cwd,
|
|
562
783
|
};
|
|
563
784
|
};
|
|
@@ -572,7 +793,7 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
|
572
793
|
return EXIT_OK;
|
|
573
794
|
}
|
|
574
795
|
const result = writeVelocityProfile(
|
|
575
|
-
{ cwd: args.cwd ?? deps.cwd ?? process.cwd(), acceptEdits: args.acceptEdits, dryRun: args.dryRun },
|
|
796
|
+
{ cwd: args.cwd ?? deps.cwd ?? process.cwd(), acceptEdits: args.acceptEdits, dryRun: args.dryRun, kitTools: args.kitTools },
|
|
576
797
|
deps,
|
|
577
798
|
);
|
|
578
799
|
log(formatVelocityProfileResult(result));
|