@sabaiway/agent-workflow-kit 14.10.0 → 14.11.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 +45 -0
- package/SKILL.md +4 -4
- package/capability.json +1 -1
- package/migrations/4.0.0-full-flow-offer.md +33 -0
- package/migrations/README.md +2 -2
- package/package.json +1 -1
- package/references/modes/bootstrap.md +1 -1
- package/references/modes/hook.md +1 -1
- package/references/modes/upgrade.md +18 -6
- package/references/modes/velocity.md +1 -1
- package/tools/cheap-agents-read.mjs +1 -1
- package/tools/direct-run.mjs +2 -0
- package/tools/profile-gaps.mjs +86 -0
- package/tools/reference-profile.mjs +69 -0
- package/tools/tier-preview.mjs +248 -0
- package/tools/upgrade-runlist.mjs +6 -0
- package/tools/velocity-profile.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,51 @@ 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
|
+
## 14.11.0 — the upgrade offers the epic and task settings and the epic store in one preview, and remembers a no (AD-157)
|
|
8
|
+
|
|
9
|
+
**An upgraded project is now offered the epic tier's settings and store instead of being left to find them.** Up to
|
|
10
|
+
14.10.0 an upgrade seeded no epic or task setting in `docs/ai/orchestration.json` and no epic store, so a project
|
|
11
|
+
reached the epic, story and task tier only by hand. 14.11.0 moves the deployment-lineage head from `3.0.0` to
|
|
12
|
+
`4.0.0`: every project stamped below it takes the upgrade route once more, and the upgrade's step 3 gains the item
|
|
13
|
+
`tier`, which shows every profile gap the kit can detect — the Story sessions section, the epic and task settings
|
|
14
|
+
and the epic store — in one preview and asks once. Story S3 of the epic `CONSUMERS-MOVE-ONTO-THE-FULL-FLOW`;
|
|
15
|
+
contract `docs/ai/specs/kit/tier/tier-offer/` (new).
|
|
16
|
+
|
|
17
|
+
- **`tools/tier-preview.mjs --cwd <project> [--apply | --decline]`.** Without a flag it writes nothing and prints
|
|
18
|
+
one line per profile gap the kit knows — `present`, `offered`, `declined` with the lineage it was declined at, or
|
|
19
|
+
`undecidable` with its reason — and under each offered gap the preview command that closes it when re-run with
|
|
20
|
+
`--apply`. Under an offered epic and task settings gap it prints one line per setting: `present`, or the value it
|
|
21
|
+
would write, with the reason for every better value it skipped. With `--apply` it writes only its own two items:
|
|
22
|
+
the settings that are absent (with the `_README` note refresh `set-recipe --write` also makes), and
|
|
23
|
+
`docs/ai/epics/.gitkeep`. With `--decline` it records every offered gap as declined. Exit 0 done, 1 one named
|
|
24
|
+
refusal on stderr, 2 usage, raised before anything is read.
|
|
25
|
+
- **The value written is the best one this environment can run.** `epic.author` tries subagent then solo,
|
|
26
|
+
`epic.review` reviewed then solo, `task.author` and `task.execute` delegated, subagent, then solo; the first one
|
|
27
|
+
the kit's own recipe check does not degrade wins, and each skipped one is shown with the kit's own reason (a
|
|
28
|
+
missing executor names `/agent-workflow-kit agents`). A setting you already have, whatever its value, is reported
|
|
29
|
+
present and never changed; the file is written by the same chain as `set-recipe --set … --write`, so the bytes are
|
|
30
|
+
the ones that command would write.
|
|
31
|
+
- **A no is remembered.** `--decline` writes `docs/ai/profile-declines.json`, each gap with the shipped profile's
|
|
32
|
+
lineage (`4.0.0`). Later runs report it `declined` and do not offer it again until a later lineage step changes
|
|
33
|
+
the shipped profile's lineage. A record the tool cannot read or parse is refused by name — never overwritten and
|
|
34
|
+
never ignored. In a hidden deployment the record is local to this machine, like the rest of `docs/ai/`.
|
|
35
|
+
- **Nothing is guessed about a broken config.** The config is checked without following a symlink before it is
|
|
36
|
+
loaded; a missing, symlinked, unreadable, unparseable or invalid file is reported by one name, and nothing is
|
|
37
|
+
written to it. The epic store is judged by itself: a regular file or a symlink at `docs/ai/epics` is reported,
|
|
38
|
+
never replaced.
|
|
39
|
+
- **The offer asks once.** The new `tier` item runs after the agent-rules lens item. The ask is put only when a
|
|
40
|
+
gap is `offered`; a yes runs the offer and every other offered command with `--apply`, a no runs `--decline`. The
|
|
41
|
+
lens step's own ask for the Story sessions section folds into it while that section is offered; a Communication
|
|
42
|
+
section missing while the Story sessions section is present or declined keeps its own ask, shown with the insert's
|
|
43
|
+
full preview, since one insert adds every missing section. A refusal, or a failed command the ask runs, stops the
|
|
44
|
+
upgrade before the re-stamp.
|
|
45
|
+
- **The note.** `migrations/4.0.0-full-flow-offer.md` says why the step exists, how to check its outcome and how to
|
|
46
|
+
undo it, and tells a project that came from kit 14.1.0 to close its open task threads before opening a new one.
|
|
47
|
+
`migrations/README.md` now says a note's version is the lineage step, not a kit release.
|
|
48
|
+
- **Two new gaps in the registry.** `tools/profile-gaps.mjs` gains `epic-task-slots` and `epic-store-seeded`, each
|
|
49
|
+
answering present, absent or undecidable from the project's own files, never from a recorded no; the new read-only
|
|
50
|
+
`tools/reference-profile.mjs` reads the shipped profile and the decline record.
|
|
51
|
+
|
|
7
52
|
## 14.10.0 — every place a user first looks names the epic, story and task tier and the guide that explains it (AD-156)
|
|
8
53
|
|
|
9
54
|
**A kit user now meets the tier without running the help.** Until 14.10.0 only the help named the tier and its
|
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". New to the epic, story and task tier? Run `/agent-workflow-kit tier`.
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '14.
|
|
6
|
+
version: '14.11.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
|
@@ -71,14 +71,14 @@ Pick the mode from the user's invocation — the mapping is pinned by `tools/com
|
|
|
71
71
|
Before acting, read `docs/ai/.workflow-version` (the project's stamp) — via the host's file-read tool, else ONE plain undecorated command (no compounds/redirects/pipes) — to decide the route — an **internal** routing decision, **not** a line you print (version display: *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`). Route:
|
|
72
72
|
|
|
73
73
|
- **absent** → bootstrap (a fresh deployment).
|
|
74
|
-
- **stamp < `
|
|
75
|
-
- **stamp == `
|
|
74
|
+
- **stamp < `4.0.0`** (the deployment-lineage head) → `upgrade`.
|
|
75
|
+
- **stamp == `4.0.0`** → already current; only the stamp-independent reconciles may run — the FULL set lives in `${CLAUDE_SKILL_DIR}/references/modes/upgrade.md` step 3; run step 3 (never enumerate the reconciles from memory).
|
|
76
76
|
- **stamp > head / unparseable** → STOP — never-downgrade gate (see `${CLAUDE_SKILL_DIR}/references/modes/upgrade.md` step 2).
|
|
77
77
|
- **Exempt:** `autonomy-doctor` provisions the OS, not the deployment — stamp-independent (stated in its mode file).
|
|
78
78
|
|
|
79
79
|
**Two independent version axes — never conflate them:**
|
|
80
80
|
|
|
81
|
-
1. **Project deployment** — `docs/ai/.workflow-version` vs the lineage head (`
|
|
81
|
+
1. **Project deployment** — `docs/ai/.workflow-version` vs the lineage head (`4.0.0`). This is the **only** axis this skill compares.
|
|
82
82
|
2. **Kit freshness** — this skill's own files vs the published npm package. That is the **npx installer's** job: `npx @sabaiway/agent-workflow-kit@latest init` (it refuses a stale-cache downgrade by comparing the version on disk — **no network**). This skill never checks npm, and the package version is **not** the lineage head.
|
|
83
83
|
|
|
84
84
|
**Refreshed the kit but nothing changed?** After the npx installer updates `~/.claude/skills/agent-workflow-kit/`, **restart the session** so the agent reloads the new skill files.
|
package/capability.json
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Migration 4.0.0-full-flow-offer
|
|
2
|
+
|
|
3
|
+
**From:** versions < 4.0.0 **To:** 4.0.0
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
The full flow is recommended to every kit consumer (AD-151), and the reference profile
|
|
8
|
+
`full-flow` announces it. This lineage step is the one that offers it: the epic and task
|
|
9
|
+
settings, resolved to what this environment can run or to solo with the reason, and the epic
|
|
10
|
+
store `docs/ai/epics/`.
|
|
11
|
+
|
|
12
|
+
## Steps
|
|
13
|
+
|
|
14
|
+
1. **Task threads (a project that came from kit 14.1.0):** close every task thread that was open
|
|
15
|
+
when this upgrade started before opening a new one (AD-146). Undoing a stray change follows the
|
|
16
|
+
"Before you upgrade" paragraph of the installed kit's `CHANGELOG.md` entry 14.2.0.
|
|
17
|
+
2. **The offer:** it already ran as this upgrade's step-3 `tier` item. Re-run the preview
|
|
18
|
+
`node <installed-kit>/tools/tier-preview.mjs --cwd <project>` to verify, and relay its lines;
|
|
19
|
+
never ask again. An item still `offered` waits for the next upgrade.
|
|
20
|
+
3. **Re-stamp:** the upgrade re-stamps `docs/ai/.workflow-version` to 4.0.0.
|
|
21
|
+
|
|
22
|
+
## Verification
|
|
23
|
+
|
|
24
|
+
The preview reports each of `epic-task-slots` and `epic-store-seeded` `present`, `declined`, or
|
|
25
|
+
`offered` when the step-3 ask got no answer; an `undecidable` item names its reason.
|
|
26
|
+
|
|
27
|
+
## Rollback
|
|
28
|
+
|
|
29
|
+
Only what this upgrade changed: remove each setting it added with
|
|
30
|
+
`node <installed-kit>/tools/set-recipe.mjs --unset <activity>.<slot> --write`; remove
|
|
31
|
+
`docs/ai/epics/.gitkeep`, then the `docs/ai/epics` directory when it holds nothing else; restore
|
|
32
|
+
`docs/ai/profile-declines.json` to its bytes before this upgrade, or delete it when this upgrade
|
|
33
|
+
created it.
|
package/migrations/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Migrations
|
|
2
2
|
|
|
3
3
|
Each upgrade step is one file: `migrations/<version>-<slug>.md`, where `<version>` is the
|
|
4
|
-
|
|
4
|
+
deployment-lineage step the note moves a project to (not a skill release) and `<slug>`
|
|
5
5
|
is a short kebab-case name. Empty until the first change that needs migrating — most
|
|
6
6
|
releases add files/templates, which `upgrade` reconciles without a migration.
|
|
7
7
|
|
|
@@ -15,7 +15,7 @@ releases add files/templates, which `upgrade` reconciles without a migration.
|
|
|
15
15
|
The upgrade must relay those stdout lines verbatim and apply exactly the listed notes.
|
|
16
16
|
A named refusal stops the upgrade, except `stamp-absent`, which routes it back to the upgrade's step 1.
|
|
17
17
|
3. Apply them in the order the tool listed them (**ascending semver order**).
|
|
18
|
-
4. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`
|
|
18
|
+
4. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`4.0.0` today — the
|
|
19
19
|
shared lineage, **not** this skill's npm package version). A stamp greater than the head → STOP.
|
|
20
20
|
|
|
21
21
|
## Authoring rules
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.11.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",
|
|
@@ -37,7 +37,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
37
37
|
template copy do (the agent-rules lens,
|
|
38
38
|
`${CLAUDE_SKILL_DIR}/references/shared/composition-handoff.md`); it is idempotent, so an
|
|
39
39
|
untouched tree simply reports `already-current`. Then write the **deployment-lineage head** into
|
|
40
|
-
`docs/ai/.workflow-version` (one semver line). The lineage head is **`
|
|
40
|
+
`docs/ai/.workflow-version` (one semver line). The lineage head is **`4.0.0`** — the shared
|
|
41
41
|
`agent-workflow` deployment lineage, **NOT** this kit's npm package version (see
|
|
42
42
|
`package.json` / `CHANGELOG.md`). The two are
|
|
43
43
|
independent axes: a packaging-only release bumps the package but leaves the lineage head until a
|
package/references/modes/hook.md
CHANGED
|
@@ -24,7 +24,7 @@ The opt-in **gate-approval PreToolUse hook** — the family's third `.claude/` w
|
|
|
24
24
|
|
|
25
25
|
**The read-lane (opt-in, AD-055 Part II).** The toggle lives in `docs/ai/lanes.json` — a **SEPARATE kit-owned strict-JSON file** (`{ "readLane": true }`); `docs/ai/gates.json`, both its validators and the byte-mirrored template are **untouched** (the gates schema has no lane/model/routing fields — that claim stays true). The hook reads `lanes.json` LIVE per call, **fail-closed**: an absent / malformed / non-object file, or a `readLane` that is not the boolean `true`, leaves the lane dark (gate auto-approval and the residual ask are unaffected). `lanes.json` becomes a **privileged file** exactly like `gates.json` — an auto-approved read chain runs **unattended** and can read any file you can (the same trust boundary as the audited read-only core velocity seeds, extended to compounds (and singles) of that core; **prompt-bypass only, never a sandbox bypass**). Enable it with `--read-lane` (below), which runs a **currency check** first: it refuses unless the PLACED hook is byte-identical to the current bundle — a **pre-1.48 hook never reads `lanes.json`**, so enabling the lane against a stale placed copy would be a silent no-op the user paid consent for. The stale-hook silent-blackout class is dead **by construction** (an old hook merely lacks the rung; nothing existing changes behavior); the currency refusal names the **delete-to-reseed** recovery (`rm .claude/hooks/agent-workflow-gates.mjs` + the `--apply` one-liner) — there is **no refresh-only hook lane** this release (a diverged wired copy stays preserved, unchanged).
|
|
26
26
|
|
|
27
|
-
**Version-status routing** like the other writer modes (stamp head `
|
|
27
|
+
**Version-status routing** like the other writer modes (stamp head `4.0.0`; `--apply` enforces it in code).
|
|
28
28
|
|
|
29
29
|
Run `node ${CLAUDE_SKILL_DIR}/tools/gate-hook.mjs [--dry-run | --apply] [--cwd <dir>]`:
|
|
30
30
|
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKILL_DIR}/references/shared/composition-handoff.md · ${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md · ${CLAUDE_SKILL_DIR}/references/shared/command-shapes.md
|
|
7
7
|
|
|
8
8
|
1. Read `docs/ai/.workflow-version` (the project's stamped lineage). If missing, treat as a pre-versioned deployment and offer to re-bootstrap conservatively.
|
|
9
|
-
2. **Never-downgrade gate — FIRST, before any write.** Compare the stamp to the **deployment-lineage head** (`
|
|
10
|
-
3. **The step-3 run-list — stamp-independent reconciles, BEFORE the equal-head short-circuit.** Reached only when the stamp **≤ head**. Run the **
|
|
9
|
+
2. **Never-downgrade gate — FIRST, before any write.** Compare the stamp to the **deployment-lineage head** (`4.0.0` — NOT this kit's package version). If the stamp is **greater than the head** or unparseable → **STOP and report**; do not touch a newer / unknown deployment at all (not even the methodology slot). This STOP is one of the few places the number is actionable (*Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`): show the user **the `docs/ai` structure version** their deployment carries versus the one this kit expects, plus the plain one-line two-axes note — naming it the structure version, **never** "lineage head".
|
|
10
|
+
3. **The step-3 run-list — stamp-independent reconciles, BEFORE the equal-head short-circuit.** Reached only when the stamp **≤ head**. Run the **eight operations below in order, on every upgrade** (equal-head too) — no lineage-head bump, no migration file. Each backticked id anchors its rationale block below the checklist; a consent marker is a CONDITIONAL gate — an explicit yes is required exactly where the row's clause states one:
|
|
11
11
|
|
|
12
12
|
1. `pointers` — `node ${CLAUDE_SKILL_DIR}/tools/inject-methodology.mjs reconcile <project>/AGENTS.md` → per pointer: added · already present · skipped (reported) · a hard STOP.
|
|
13
13
|
2. `footprint` — `node ${CLAUDE_SKILL_DIR}/tools/hide-footprint.mjs --dir <project> --reconcile --dry-run` → visibility: visible · ambiguous · hidden — consent (conditional): ambiguous → ask which it is BEFORE anything; hidden → the conditional re-run without `--dry-run` (its surfaced paths ask per bootstrap step 9).
|
|
@@ -15,7 +15,8 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
15
15
|
4. `gates-migration` — `node ${CLAUDE_SKILL_DIR}/references/scripts/migrate-gates.mjs --kit-tools ${CLAUDE_SKILL_DIR}/tools --cwd <project>` → the preview plan · INERT checker · CUSTOMIZED entries, each named — consent: apply only on an explicit yes, re-run with `--apply`.
|
|
16
16
|
5. `bridges` — `node ${CLAUDE_SKILL_DIR}/tools/setup-backends.mjs --refresh-placed` → per-bridge lines: refreshed · already current · skipped — with its stated reason (not placed / newer than the bundle / unsupported host) · `skipped-readonly` · could not refresh.
|
|
17
17
|
6. `lens` — `node ${CLAUDE_SKILL_DIR}/tools/lens-region.mjs reconcile <project>/docs/ai/agent_rules.md` → per section: refreshed · already current · custom edit preserved · file absent / engine too old — skipped · over the line cap — refused · section absent — noted · a fully absent/invalid engine → hard STOP.
|
|
18
|
-
7. `
|
|
18
|
+
7. `tier` — `node ${CLAUDE_SKILL_DIR}/tools/tier-preview.mjs --cwd <project>` → per gap: present · offered · declined · undecidable · applied · recorded · refused — consent: one ask, only when offered.
|
|
19
|
+
8. `bridge-settings` — `node ${CLAUDE_SKILL_DIR}/tools/bridge-settings.mjs --reconcile` → outcome line(s), pasted verbatim: ok · absent · flagged · duplicates · unusable.
|
|
19
20
|
|
|
20
21
|
**`pointers` — the bounded pointers.** ONE call reconciles **three** pointers — the **workflow-methodology** pointer, the **orchestration-recipes** pointer (Solo / Reviewed / Council / Delegated / Subagent, routing to `/agent-workflow-kit recipes`) right below it, and the **autonomy-policy** pointer (the `docs/ai/autonomy.json` read contract, routing to `/agent-workflow-kit set-autonomy`) below that — and is filled on **every** upgrade, idempotently (zero-diff when all are already present + filled), so even a legacy or current equal-head deployment gains them **without a lineage-head bump or a migration** (the `agent-workflow-memory` **package** template did get docs-only headroom trims for the chained pointers, but no deployed-`docs/ai` structure changed). Per slot it inserts an empty pair at its anchor if absent, preserves a customized pair verbatim, and STOPs (never edits) on a malformed pair, a missing / duplicate anchor, or **when a fill is needed but the installed `agent-workflow-engine` is absent/invalid** (the fragments are read live from it — see the distinct outcomes below).
|
|
21
22
|
|
|
@@ -87,7 +88,18 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
87
88
|
A doubled heading in any of the three sections is preserved with a note and no write.
|
|
88
89
|
The **Communication and story-sessions** absent-section notes already carry the preview
|
|
89
90
|
command: relay the printed note verbatim as the offer.
|
|
90
|
-
Run the insert with `--apply` only on the user's explicit yes
|
|
91
|
+
Run the insert with `--apply` only on the user's explicit yes (the `tier` ask while `tier`
|
|
92
|
+
offers `story-sessions-section`).
|
|
93
|
+
|
|
94
|
+
**`tier` — the full-flow offer.** Relay every preview line. Ask ONCE, only when an entry is
|
|
95
|
+
`offered`: yes re-runs it and every other offered `apply:` line with `--apply`; no re-runs it
|
|
96
|
+
with `--decline`. `story-sessions-section` `offered`: run its preview line and relay its lines
|
|
97
|
+
(Communication `planned` too) before the ask; `declined`: relay the lens story-sessions absent note
|
|
98
|
+
as the recorded decline, without its preview command, and put no ask for it. A Communication absent
|
|
99
|
+
note beside a `present` or `declined` one keeps its own ask, after the insert's full preview
|
|
100
|
+
(the insert is indivisible). In an upgrade this governs the composition handoff's lens ask. A
|
|
101
|
+
`refused` item, or a non-zero exit of any command the ask runs, STOPs the upgrade before the
|
|
102
|
+
re-stamp.
|
|
91
103
|
|
|
92
104
|
**`bridge-settings` — bridge settings reconcile.** **Paste its outcome line(s)
|
|
93
105
|
verbatim**: it validates the deployed host settings file's keys against the bundled manifests and
|
|
@@ -96,7 +108,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
96
108
|
**The LATE navigator finalizer — the AUTHORITATIVE run, after the last `docs/ai` mutation.** `lens` above rewrites `docs/ai/agent_rules.md`, so the step-3 index ensure is already behind by the time the run-list ends. Re-run that ONE op here, before the step-4 report: `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --only index --cwd <project>`. Relay **this** line in the report (it supersedes the early one; an untouched tree reports `already-current`, a failure STOPs the upgrade like any other ensure). On the migrated path the same rung runs again at the END of step 7 — after the migrations, before the step-8 re-stamp.
|
|
97
109
|
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:
|
|
98
110
|
- **A skip line that contradicts the observed tree is a FINDING, never pasted as neutral.** Every ensure skip names the fact it proved (the probes that answered absent, the layout it read); if the tree you can see disproves that reason — a "no Node" skip beside deployed Node scripts, a "not deployed" skip beside a stamped `docs/ai` — do not relay the line as an outcome: raise it in the report as a defect of the tool, with the contradicting fact named, and stop short of any step that would build on the skip.
|
|
99
|
-
- **Report step 3's outcome in plain language** — for **each** `pointers` slot (workflow-methodology, orchestration-recipes and autonomy-policy) whether it was *added*, was *already present* (nothing changed), or was *skipped* (the soft-skip from step 3, with its reason — over the line limit / engine too old / the autonomy pointer's anchor absent); the **eight project-configuration ensure** (`configs`) lines exactly as the one ensure run composed them (the ensures and their outcome tokens are enumerated in step 3), each rendered in plain language: what was created, what was left exactly as the user wrote it, and — for a `failed` line — what stopped it; for the navigator, relay the **late** `--only index` line, not the early one; the **`gates-migration`** result — *nothing to migrate*, the shown plan *applied* on your explicit yes, or the plan *left unapplied* (consent not given), with any INERT checker or CUSTOMIZED entries named; the **placed-bridge refresh** (`bridges`) outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / `skipped-readonly` with its re-scan verdict / *could not refresh* + recovery); the **agent-rules lens** (`lens`) outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*) and the **Communication-section** outcome (its own set: refreshed / already current / custom preserved + note / section absent — noted / over the cap — refused) and the **story-sessions section** outcome (its own set: refreshed / already current / custom edit preserved + note / section absent — noted / over the file's line cap — refused); the **bridge-settings reconcile** (`bridge-settings`) outcome (paste the tool's line(s) verbatim); and, for a hidden deployment, whether the hidden-mode footprint (`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`).
|
|
111
|
+
- **Report step 3's outcome in plain language** — for **each** `pointers` slot (workflow-methodology, orchestration-recipes and autonomy-policy) whether it was *added*, was *already present* (nothing changed), or was *skipped* (the soft-skip from step 3, with its reason — over the line limit / engine too old / the autonomy pointer's anchor absent); the **eight project-configuration ensure** (`configs`) lines exactly as the one ensure run composed them (the ensures and their outcome tokens are enumerated in step 3), each rendered in plain language: what was created, what was left exactly as the user wrote it, and — for a `failed` line — what stopped it; for the navigator, relay the **late** `--only index` line, not the early one; the **`gates-migration`** result — *nothing to migrate*, the shown plan *applied* on your explicit yes, or the plan *left unapplied* (consent not given), with any INERT checker or CUSTOMIZED entries named; the **placed-bridge refresh** (`bridges`) outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / `skipped-readonly` with its re-scan verdict / *could not refresh* + recovery); the **agent-rules lens** (`lens`) outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*) and the **Communication-section** outcome (its own set: refreshed / already current / custom preserved + note / section absent — noted / over the cap — refused) and the **story-sessions section** outcome (its own set: refreshed / already current / custom edit preserved + note / section absent — noted / over the file's line cap — refused); the **full-flow offer** (`tier`) lines; the **bridge-settings reconcile** (`bridge-settings`) outcome (paste the tool's line(s) verbatim); and, for a hidden deployment, whether the hidden-mode footprint (`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`).
|
|
100
112
|
- **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*.
|
|
101
113
|
- **Render the mandatory Recommendations section — on this exit too, BEFORE the footer.** Run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root>` and PRESENT its output — from the `## Recommendations (agent-workflow)` header — in the user's conversational language: every fact, count and item from the tool, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; show the raw tool block on request. The section is present-even-when-empty (with everything optimal the body is exactly `no recommendations — flow optimal.`) and VERDICT-FIRST — the composed verdict line renders from the frozen templates `{K} item(s) need attention` / `nothing is broken` / `{N} optional recommendation(s), apply any you want` / `optimality NOT attested — {M} probe check(s) skipped`. Then OFFER the consent-gated applies: the user picks items in plain language; surface each picked item's posture note, get the explicit confirm, then run EXACTLY the rendered one-liners (a HAND-APPLY item is never run by you) — the full lane in `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`. Pinned order on this exit: Recommendations block → optional applies → report footer → the commit ask (the advisor/apply lane never lands after the commit ask).
|
|
102
114
|
- **Live host/session facts are tool-composed only.** Any claim this report makes about the current host or session state — prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts — must trace to **live tool output** from **this session** (the lines you just composed, or a probe you ran this run); a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection. Full clause: *Live host/session facts* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`.
|
|
@@ -105,4 +117,4 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
105
117
|
5. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` diff (entries newer than the project's stamp).
|
|
106
118
|
6. **Collect the migration answers FIRST, then apply.** Run `node ${CLAUDE_SKILL_DIR}/tools/migration-notes.mjs --cwd <project>` and relay its stdout lines verbatim, in order; a non-zero exit relays its one named line and STOPs, except `stamp-absent`, which routes back to step 1. Then run the preview `node ${CLAUDE_SKILL_DIR}/tools/migration-blocks.mjs --cwd <project>`. If its lines say both blocks are missing (pre-1.1.0), ask the two questions as ONE structured multi-question prompt; record each answer individually, write nothing until ALL are answered, and carry the answers into the migrations below: a migration whose answer was already collected never re-asks; a single missing block keeps its single ask (step 7). Then apply exactly the notes the tool listed, in its order ("only those newer than the project's stamp" is why, never a second selection). Migrations are **idempotent** — safe to re-run.
|
|
107
119
|
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. For a block the preview names as needing its answer, **ask the user their conversational language** (`--language`) or **whether the agent may attribute work to itself / AI** (`--attribution`, defaulting to `off`), never re-asking a step-6 answer; then re-run the preview with `--apply` and the answers, relaying its lines. Exit 1 `answer-missing`: ask exactly what its lines name and re-run `--apply` once; a second one STOPs. Any other exit 1 is a named refusal: relay it with its recovery (`cap`: trim `AGENTS.md`, re-run) and STOP before the finalizer and the stamp. **Then, before the stamp, re-run the LATE navigator finalizer:** the migrations of steps 6–7 may have changed `docs/ai` files, so run the one op again — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --only index --cwd <project>` — and relay THAT line in the step-8 report (idempotent: an untouched tree reports `already-current`; a failure STOPs this upgrade like any other ensure).
|
|
108
|
-
8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`
|
|
120
|
+
8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`4.0.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 run-list outcomes, each as step 4 words it: the `pointers` slots, the `footprint` visibility verdict (and what a consented re-run migrated), the **eight project-configuration ensure** (`configs`) lines (enumerated in step 3) — the navigator one being the late `--only index` run at the end of step 7, the **`gates-migration`** result, the **placed-bridge refresh** (`bridges`) lines (pasted verbatim), the **agent-rules lens** (`lens`) + **Communication-section** + **story-sessions section** outcomes (same outcome sets as step 4), the **full-flow offer** (`tier`) outcome, and the **bridge-settings reconcile** (`bridge-settings`) outcome (line(s) 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 **render the mandatory Recommendations section**: run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root>` and PRESENT its output — from the `## Recommendations (agent-workflow)` header — in the user's conversational language (every fact, count and item, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request; present-even-when-empty: `no recommendations — flow optimal.`), then OFFER the consent-gated applies (per picked item: posture note → explicit confirm → run EXACTLY the rendered one-liner; a HAND-APPLY item is never run by you — `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`). **Every current host/session claim in this report is tool-composed only** — prompts fired, sandbox scope, whether a bypass was needed, network reachability and approval counts must trace to **live tool output** from **this session**, a memory/handover snapshot is **context, never report facts**, and an unbacked claim is **omitted or explicitly marked unverified** (full clause: *Live host/session facts* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`). 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** — the pinned order on this exit is: Recommendations block → optional applies → report footer → the commit ask.
|
|
@@ -11,7 +11,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/command-shapes.md
|
|
|
11
11
|
|
|
12
12
|
The opt-in onboarding **velocity profile** — it seeds a fixed, audited **read-only** Claude Code allowlist into `.claude/settings.json` so an agent stops idling on approval prompts for routine read-only commands while the maintainer is away. It is the family's **first programmatic `.claude/settings.json` writer** (attribution stayed an agent-driven prose merge). **In-agent, opt-in, writes only `.claude/settings.json`**, on one hard rule: **it never allowlists `commit`/`push`/`publish`** — so a direct commit/push/publish still ASKs; the only caveat is the trust-posture residual (below) — its closure is **shipped, opt-in: `${CLAUDE_SKILL_DIR}/references/modes/hook.md`**.
|
|
13
13
|
|
|
14
|
-
**Version-status routing (like the other writer modes):** read `docs/ai/.workflow-version` first — not-deployed → bootstrap; stamp < `
|
|
14
|
+
**Version-status routing (like the other writer modes):** read `docs/ai/.workflow-version` first — not-deployed → bootstrap; stamp < `4.0.0` → `upgrade`; stamp > head / unparseable → STOP. The tool enforces this in code too (`--apply` STOPs unless the stamp is the lineage head).
|
|
15
15
|
|
|
16
16
|
Run `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs [--dry-run | --apply] [--kit-tools] [--bridge-tier] [--accept-edits] [--cwd <dir>]`:
|
|
17
17
|
|
|
@@ -15,7 +15,7 @@ const HERE = dirname(fileURLToPath(import.meta.url));
|
|
|
15
15
|
export const AGENTS_DIR = '.claude/agents';
|
|
16
16
|
export const CLAUDE_DIR = '.claude';
|
|
17
17
|
export const WORKFLOW_STAMP = 'docs/ai/.workflow-version';
|
|
18
|
-
export const EXPECTED_WORKFLOW_VERSION = '
|
|
18
|
+
export const EXPECTED_WORKFLOW_VERSION = '4.0.0';
|
|
19
19
|
export const BUNDLED_AGENTS_DIR = resolve(HERE, '..', 'references', 'agents');
|
|
20
20
|
|
|
21
21
|
export const UTF8 = 'utf8';
|
package/tools/direct-run.mjs
CHANGED
|
@@ -65,6 +65,8 @@ export const LIBRARY_ONLY_MODULES = Object.freeze({
|
|
|
65
65
|
'review-roster.mjs': '/agent-workflow-kit recipes',
|
|
66
66
|
'review-roster-resolve.mjs': '/agent-workflow-kit recipes',
|
|
67
67
|
'set-recipe-roster.mjs': '/agent-workflow-kit set-recipe',
|
|
68
|
+
// The read half of the tier offer: the upgrade's step-3 `tier` item runs the offer that uses it.
|
|
69
|
+
'reference-profile.mjs': '/agent-workflow-kit upgrade',
|
|
68
70
|
});
|
|
69
71
|
|
|
70
72
|
// The frozen refusal line. One line, names the module, names the command.
|
package/tools/profile-gaps.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { CONFIG_REL, validateConfig } from './orchestration-config.mjs';
|
|
4
|
+
import { TIER_TARGETS } from './reference-profile.mjs';
|
|
3
5
|
import {
|
|
4
6
|
RULES_REGIONS,
|
|
5
7
|
extractRegionBy,
|
|
@@ -11,6 +13,11 @@ import {
|
|
|
11
13
|
} from './rules-regions.mjs';
|
|
12
14
|
|
|
13
15
|
const GAP_ID = 'story-sessions-section';
|
|
16
|
+
const SLOTS_ID = 'epic-task-slots';
|
|
17
|
+
const STORE_ID = 'epic-store-seeded';
|
|
18
|
+
const STORE_PATH = 'docs/ai/epics';
|
|
19
|
+
const DEPLOYMENT_CHAIN = ['.', 'docs', 'docs/ai'];
|
|
20
|
+
const TIER_PREVIEW_PATH = fileURLToPath(new URL('./tier-preview.mjs', import.meta.url));
|
|
14
21
|
const STORY_ID = 'story-sessions';
|
|
15
22
|
const TEMPLATE_CANON = 'template';
|
|
16
23
|
const ENCODING = 'utf8';
|
|
@@ -28,6 +35,15 @@ const REASONS = Object.freeze({
|
|
|
28
35
|
fileAbsent: 'file-absent',
|
|
29
36
|
fileSymlink: 'file-symlink',
|
|
30
37
|
fileUnreadable: 'file-unreadable',
|
|
38
|
+
noDeployment: 'no-deployment',
|
|
39
|
+
configAbsent: 'config-absent',
|
|
40
|
+
configSymlink: 'config-symlink',
|
|
41
|
+
configUnreadable: 'config-unreadable',
|
|
42
|
+
configMalformed: 'config-malformed',
|
|
43
|
+
configInvalid: 'config-invalid',
|
|
44
|
+
storeNotDirectory: 'store-not-directory',
|
|
45
|
+
storeSymlink: 'store-symlink',
|
|
46
|
+
storeUnreadable: 'store-unreadable',
|
|
31
47
|
});
|
|
32
48
|
const TEMPLATE_REGIONS = RULES_REGIONS.filter(({ canon }) => canon === TEMPLATE_CANON);
|
|
33
49
|
const STORY_REGION = RULES_REGIONS.find(({ id }) => id === STORY_ID);
|
|
@@ -81,6 +97,76 @@ const detectStorySessions = ({ root, deps }) => {
|
|
|
81
97
|
return { verdict: VERDICTS.absent };
|
|
82
98
|
};
|
|
83
99
|
|
|
100
|
+
const isDeployed = (root, deps) => {
|
|
101
|
+
try {
|
|
102
|
+
return DEPLOYMENT_CHAIN.every((path, index) => {
|
|
103
|
+
const stat = deps.lstatSync(resolve(root, path));
|
|
104
|
+
if (stat.isSymbolicLink()) return false;
|
|
105
|
+
return index < DEPLOYMENT_CHAIN.length - 1 || stat.isDirectory();
|
|
106
|
+
});
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const parseConfig = (bytes) => {
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(decodeUtf8Strict(bytes));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return error instanceof SyntaxError ? { reason: REASONS.configMalformed } : { reason: REASONS.configUnreadable };
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
return { config: validateConfig(parsed) };
|
|
121
|
+
} catch {
|
|
122
|
+
return { reason: REASONS.configInvalid };
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const readTierConfig = (root, deps) => {
|
|
127
|
+
const path = resolve(root, CONFIG_REL);
|
|
128
|
+
try {
|
|
129
|
+
const stat = deps.lstatSync(path);
|
|
130
|
+
if (stat.isSymbolicLink()) return { reason: REASONS.configSymlink };
|
|
131
|
+
if (!stat.isFile()) return { reason: REASONS.configUnreadable };
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return { reason: error?.code === MISSING_FILE_CODE ? REASONS.configAbsent : REASONS.configUnreadable };
|
|
134
|
+
}
|
|
135
|
+
let bytes;
|
|
136
|
+
try {
|
|
137
|
+
bytes = deps.readFileSync(path);
|
|
138
|
+
} catch {
|
|
139
|
+
return { reason: REASONS.configUnreadable };
|
|
140
|
+
}
|
|
141
|
+
return parseConfig(bytes);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const detectSlots = ({ root, deps }) => {
|
|
145
|
+
if (!isDeployed(root, deps)) return reportUndecidable(REASONS.noDeployment);
|
|
146
|
+
const read = readTierConfig(root, deps);
|
|
147
|
+
if (read.reason) return reportUndecidable(read.reason);
|
|
148
|
+
const declared = TIER_TARGETS.every(({ activity, slot }) => Object.hasOwn(read.config[activity] ?? {}, slot));
|
|
149
|
+
return { verdict: declared ? VERDICTS.present : VERDICTS.absent };
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const detectStore = ({ root, deps }) => {
|
|
153
|
+
if (!isDeployed(root, deps)) return reportUndecidable(REASONS.noDeployment);
|
|
154
|
+
let stat;
|
|
155
|
+
try {
|
|
156
|
+
stat = deps.lstatSync(resolve(root, STORE_PATH));
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error?.code === MISSING_FILE_CODE) return { verdict: VERDICTS.absent };
|
|
159
|
+
return reportUndecidable(REASONS.storeUnreadable);
|
|
160
|
+
}
|
|
161
|
+
if (stat.isSymbolicLink()) return reportUndecidable(REASONS.storeSymlink);
|
|
162
|
+
if (!stat.isDirectory()) return reportUndecidable(REASONS.storeNotDirectory);
|
|
163
|
+
return { verdict: VERDICTS.present };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const tierPreview = (root) => buildInsertPreview(root, TIER_PREVIEW_PATH);
|
|
167
|
+
|
|
84
168
|
export const PROFILE_GAPS = Object.freeze([
|
|
85
169
|
Object.freeze({ id: GAP_ID, detect: detectStorySessions, apply: (root) => buildInsertPreview(root) }),
|
|
170
|
+
Object.freeze({ id: SLOTS_ID, detect: detectSlots, apply: tierPreview }),
|
|
171
|
+
Object.freeze({ id: STORE_ID, detect: detectStore, apply: tierPreview }),
|
|
86
172
|
]);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// reference-profile.mjs — the READ-ONLY leaf over the running kit's reference profile and a
|
|
2
|
+
// project's decline record: the frozen epic/task target table, the shipped profile reader, the
|
|
3
|
+
// decline record reader and the currency rule. Governing contract: docs/ai/specs/kit/tier/tier-offer/.
|
|
4
|
+
// No CLI (refuseDirectRun); every read goes through injected deps; nothing here writes.
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
8
|
+
import { refuseDirectRun } from './direct-run.mjs';
|
|
9
|
+
|
|
10
|
+
const PROFILE_PATH = fileURLToPath(new URL('../references/reference-profile.json', import.meta.url));
|
|
11
|
+
const DECLINES_REL = 'docs/ai/profile-declines.json';
|
|
12
|
+
const PROFILE_KEYS = ['items', 'lineage', 'name', 'schema'];
|
|
13
|
+
const ITEM_KEYS = ['has', 'id', 'story'];
|
|
14
|
+
const RECORD_KEYS = ['declined', 'schema'];
|
|
15
|
+
const SCHEMA = 1;
|
|
16
|
+
const OK = 'ok';
|
|
17
|
+
const ABSENT = 'absent';
|
|
18
|
+
const SYMLINK_CLASS = 'symlink';
|
|
19
|
+
|
|
20
|
+
const target = (activity, slot, candidates) => Object.freeze({ activity, slot, candidates: Object.freeze(candidates) });
|
|
21
|
+
|
|
22
|
+
export const TIER_TARGETS = Object.freeze([
|
|
23
|
+
target('epic', 'author', ['subagent', 'solo']),
|
|
24
|
+
target('epic', 'review', ['reviewed', 'solo']),
|
|
25
|
+
target('task', 'author', ['delegated', 'subagent', 'solo']),
|
|
26
|
+
target('task', 'execute', ['delegated', 'subagent', 'solo']),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
30
|
+
const hasKeys = (value, keys) => isPlainObject(value) && Object.keys(value).sort().join() === keys.join();
|
|
31
|
+
const isText = (value) => typeof value === 'string' && value.length > 0;
|
|
32
|
+
const parseJson = (text) => {
|
|
33
|
+
try {
|
|
34
|
+
return { value: JSON.parse(text) };
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const isProfile = (value) => hasKeys(value, PROFILE_KEYS) && value.schema === SCHEMA
|
|
41
|
+
&& isText(value.name) && isText(value.lineage) && Array.isArray(value.items)
|
|
42
|
+
&& value.items.every((item) => hasKeys(item, ITEM_KEYS) && isText(item.id) && isText(item.story) && isText(item.has));
|
|
43
|
+
|
|
44
|
+
const isRecord = (value) => hasKeys(value, RECORD_KEYS) && value.schema === SCHEMA && isPlainObject(value.declined)
|
|
45
|
+
&& Object.entries(value.declined).every(([id, lineage]) => id.length > 0 && typeof lineage === 'string');
|
|
46
|
+
|
|
47
|
+
export const readShippedProfile = (deps = {}) => {
|
|
48
|
+
const read = readRegularFileNoFollow(deps.profilePath ?? PROFILE_PATH, deps);
|
|
49
|
+
if (read.outcome !== OK) return { refusal: 'profile-unreadable' };
|
|
50
|
+
const parsed = parseJson(read.content);
|
|
51
|
+
if (parsed === null || !isProfile(parsed.value)) return { refusal: 'profile-malformed' };
|
|
52
|
+
return { profile: parsed.value };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const readDeclines = (root, deps = {}) => {
|
|
56
|
+
const read = readRegularFileNoFollow(join(root, DECLINES_REL), deps);
|
|
57
|
+
if (read.outcome === ABSENT) return { absent: true };
|
|
58
|
+
if (read.outcome !== OK) {
|
|
59
|
+
return { refusal: read.className === SYMLINK_CLASS ? 'declines-symlink' : 'declines-unreadable' };
|
|
60
|
+
}
|
|
61
|
+
const parsed = parseJson(read.content);
|
|
62
|
+
if (parsed === null || !isRecord(parsed.value)) return { refusal: 'declines-malformed' };
|
|
63
|
+
return { declined: parsed.value.declined };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const isDeclineCurrent = (declined, id, lineage) =>
|
|
67
|
+
isPlainObject(declined) && Object.hasOwn(declined, id) && declined[id] === lineage;
|
|
68
|
+
|
|
69
|
+
refuseDirectRun(import.meta.url);
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tier-preview.mjs — the offer of lineage step 4.0.0: one preview over the whole profile-gap
|
|
3
|
+
// registry; on an explicit yes (--apply) the absent epic and task slots and the epic store seed;
|
|
4
|
+
// on an explicit no (--decline) the decline record. Governing contract:
|
|
5
|
+
// docs/ai/specs/kit/tier/tier-offer/ (part tier-preview). main(argv, deps) returns, never exits,
|
|
6
|
+
// never asks; every read and write goes through deps.
|
|
7
|
+
import { linkSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import { assertDocsAiDeployment, writeDocsAiFileAtomic, writeProjectFileCreateOnly } from './atomic-write.mjs';
|
|
10
|
+
import { safeLine } from './carriers.mjs';
|
|
11
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
12
|
+
import { tmpNote } from './ensure-ops.mjs';
|
|
13
|
+
import { CANON_README, applySetOps, refreshReadme } from './orchestration-config.mjs';
|
|
14
|
+
import { writeConfig } from './orchestration-write.mjs';
|
|
15
|
+
import { PROFILE_GAPS, readTierConfig } from './profile-gaps.mjs';
|
|
16
|
+
import { composeReadiness, planRecipe } from './recipes.mjs';
|
|
17
|
+
import { TIER_TARGETS, isDeclineCurrent, readDeclines, readShippedProfile } from './reference-profile.mjs';
|
|
18
|
+
|
|
19
|
+
const LF = String.fromCharCode(10);
|
|
20
|
+
const ARGV_OFFSET = 2;
|
|
21
|
+
const EXIT_SUCCESS = 0;
|
|
22
|
+
const EXIT_REFUSAL = 1;
|
|
23
|
+
const EXIT_USAGE = 2;
|
|
24
|
+
const CWD_FLAG = '--cwd';
|
|
25
|
+
const APPLY_FLAG = '--apply';
|
|
26
|
+
const DECLINE_FLAG = '--decline';
|
|
27
|
+
const HELP_FLAGS = new Set(['--help', '-h']);
|
|
28
|
+
const MODE_FLAGS = new Set([APPLY_FLAG, DECLINE_FLAG]);
|
|
29
|
+
const SLOTS_ID = 'epic-task-slots';
|
|
30
|
+
const STORE_ID = 'epic-store-seeded';
|
|
31
|
+
const STORE_DIR = 'docs/ai/epics';
|
|
32
|
+
const STORE_SEED = 'docs/ai/epics/.gitkeep';
|
|
33
|
+
const CONFIG_WRITTEN = 'docs/ai/orchestration.json';
|
|
34
|
+
const DECLINES_REL = 'docs/ai/profile-declines.json';
|
|
35
|
+
const SCHEMA = 1;
|
|
36
|
+
const INDENT = ' ';
|
|
37
|
+
const WORDS = Object.freeze({
|
|
38
|
+
present: 'present',
|
|
39
|
+
offered: 'offered',
|
|
40
|
+
declined: 'declined',
|
|
41
|
+
undecidable: 'undecidable',
|
|
42
|
+
applied: 'applied',
|
|
43
|
+
recorded: 'recorded',
|
|
44
|
+
});
|
|
45
|
+
const ABSENT_VERDICT = 'absent';
|
|
46
|
+
const PRESENT_VERDICT = 'present';
|
|
47
|
+
|
|
48
|
+
export const OUTCOME_LINES = Object.freeze({
|
|
49
|
+
entry: (id, word, detail) => `${id}: ${word}${detail ? ` — ${detail}` : ''}`,
|
|
50
|
+
declinedAt: (lineage) => `at lineage ${lineage}`,
|
|
51
|
+
applyLine: (command) => `${INDENT}apply: ${command}`,
|
|
52
|
+
noCommand: () => `${INDENT}apply: no runnable command — the root or the kit path carries a control byte or a backtick`,
|
|
53
|
+
slotValue: (activity, slot, value, skipped = []) => `${INDENT}${activity}.${slot} = ${value}`
|
|
54
|
+
+ skipped.map(({ candidate, reason }) => ` — ${candidate} skipped: ${reason}`).join(''),
|
|
55
|
+
slotPresent: (activity, slot) => `${INDENT}${activity}.${slot}: present`,
|
|
56
|
+
tmpLeft: (tmp) => tmpNote(STORE_SEED, tmp)[0],
|
|
57
|
+
nothingOffered: () => 'nothing offered — nothing recorded',
|
|
58
|
+
detectFailed: (cause) => `backend detection failed (${safeLine(cause)}) — every bridge is treated as not ready`,
|
|
59
|
+
refusal: (name, detail) => `tier-preview: refused — ${name}${detail ? `: ${safeLine(detail)}` : ''}`,
|
|
60
|
+
usage: (problem) => `usage: tier-preview.mjs --cwd DIR [--apply | --decline] — ${safeLine(problem)}`,
|
|
61
|
+
help: () => [
|
|
62
|
+
'tier-preview.mjs --cwd DIR [--apply | --decline]',
|
|
63
|
+
'Previews every profile gap of the project in DIR; writes nothing by default.',
|
|
64
|
+
'--apply writes the offered items: the absent epic and task settings and the epic store seed.',
|
|
65
|
+
'--decline records every offered item as declined at the shipped profile lineage.',
|
|
66
|
+
'Exit codes: 0 done, 1 one named refusal, 2 usage.',
|
|
67
|
+
].join(LF),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const result = (code, stdout = '', stderr = '') => ({ code, stdout, stderr });
|
|
71
|
+
const refuse = (name, detail) => result(EXIT_REFUSAL, '', OUTCOME_LINES.refusal(name, detail));
|
|
72
|
+
const causeOf = (error) => String(error?.message ?? error);
|
|
73
|
+
|
|
74
|
+
const parseArgs = (argv) => {
|
|
75
|
+
if (argv.length === 1 && HELP_FLAGS.has(argv[0])) return { help: true };
|
|
76
|
+
const options = {};
|
|
77
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
78
|
+
const flag = argv[index];
|
|
79
|
+
if (flag === CWD_FLAG) {
|
|
80
|
+
if (options.cwd !== undefined) return { problem: `${CWD_FLAG} given twice` };
|
|
81
|
+
const value = argv[index + 1];
|
|
82
|
+
if (value === undefined || value.startsWith('-')) return { problem: `${CWD_FLAG} needs a directory` };
|
|
83
|
+
options.cwd = value;
|
|
84
|
+
index += 1;
|
|
85
|
+
} else if (MODE_FLAGS.has(flag)) {
|
|
86
|
+
if (options[flag]) return { problem: `${flag} given twice` };
|
|
87
|
+
options[flag] = true;
|
|
88
|
+
} else {
|
|
89
|
+
return { problem: `unknown argument ${flag}` };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (options.cwd === undefined) return { problem: `${CWD_FLAG} is required` };
|
|
93
|
+
if (options[APPLY_FLAG] && options[DECLINE_FLAG]) return { problem: `${APPLY_FLAG} and ${DECLINE_FLAG} exclude each other` };
|
|
94
|
+
return { cwd: options.cwd, apply: Boolean(options[APPLY_FLAG]), decline: Boolean(options[DECLINE_FLAG]) };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const withFs = (deps) => ({
|
|
98
|
+
lstatSync, readFileSync, lstat: lstatSync, writeFile: writeFileSync, rename: renameSync, link: linkSync,
|
|
99
|
+
rm: (path) => rmSync(path, { force: true }), mkdir: (path) => mkdirSync(path, { recursive: true }), ...deps,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// The resolution walk: the first candidate planRecipe reports not degraded wins; every candidate
|
|
103
|
+
// before it carries that plan's own first degrade reason.
|
|
104
|
+
// solo, every target's last candidate, is always satisfiable, so the find always answers.
|
|
105
|
+
const resolveTarget = ({ candidates }, readiness) => {
|
|
106
|
+
const skipped = [];
|
|
107
|
+
const value = candidates.find((candidate) => {
|
|
108
|
+
const plan = planRecipe(candidate, readiness);
|
|
109
|
+
if (plan.degraded) skipped.push({ candidate, reason: plan.degradation[0].reason });
|
|
110
|
+
return !plan.degraded;
|
|
111
|
+
});
|
|
112
|
+
return { value, skipped };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const planSlots = (config, readiness) => TIER_TARGETS.map((target) => {
|
|
116
|
+
const declared = Object.hasOwn(config[target.activity] ?? {}, target.slot);
|
|
117
|
+
return declared ? { target, declared } : { target, declared, ...resolveTarget(target, readiness) };
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const slotLines = (slots) => slots.map(({ target, declared, value, skipped }) => (declared
|
|
121
|
+
? OUTCOME_LINES.slotPresent(target.activity, target.slot)
|
|
122
|
+
: OUTCOME_LINES.slotValue(target.activity, target.slot, value, skipped)));
|
|
123
|
+
|
|
124
|
+
const judgeEntries = (root, deps, profile, declined) => PROFILE_GAPS.map((entry) => {
|
|
125
|
+
const detected = entry.detect({ root, deps });
|
|
126
|
+
if (detected.verdict === PRESENT_VERDICT) return { entry, word: WORDS.present };
|
|
127
|
+
if (detected.verdict !== ABSENT_VERDICT) return { entry, word: WORDS.undecidable, detail: detected.reason };
|
|
128
|
+
if (isDeclineCurrent(declined, entry.id, profile.lineage)) {
|
|
129
|
+
return { entry, word: WORDS.declined, detail: OUTCOME_LINES.declinedAt(declined[entry.id]) };
|
|
130
|
+
}
|
|
131
|
+
return { entry, word: WORDS.offered };
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const renderEntry = ({ entry, word, detail }, root, slots) => {
|
|
135
|
+
const lines = [OUTCOME_LINES.entry(entry.id, word, detail)];
|
|
136
|
+
if (word === WORDS.offered) {
|
|
137
|
+
const command = entry.apply(root);
|
|
138
|
+
lines.push(command ? OUTCOME_LINES.applyLine(command) : OUTCOME_LINES.noCommand());
|
|
139
|
+
}
|
|
140
|
+
if (entry.id === SLOTS_ID && (word === WORDS.offered || word === WORDS.applied)) lines.push(...slotLines(slots));
|
|
141
|
+
return lines;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const writeSlots = (root, config, slots, deps) => {
|
|
145
|
+
const ops = slots.filter(({ declared }) => !declared).map(({ target, value }) => ({
|
|
146
|
+
kind: 'set', activity: target.activity, slot: target.slot, recipe: value,
|
|
147
|
+
}));
|
|
148
|
+
const merged = applySetOps(config, ops, { seedReadme: CANON_README });
|
|
149
|
+
writeConfig(root, refreshReadme(merged).config, deps);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const storeCreated = (root, deps) => {
|
|
153
|
+
try {
|
|
154
|
+
return deps.lstat(resolve(root, STORE_DIR)).isDirectory();
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const runApply = (root, deps, judged, slots, config) => {
|
|
161
|
+
const byId = new Map(judged.map((item) => [item.entry.id, item]));
|
|
162
|
+
const extra = [];
|
|
163
|
+
const made = [];
|
|
164
|
+
if (byId.get(SLOTS_ID)?.word === WORDS.offered) {
|
|
165
|
+
try {
|
|
166
|
+
writeSlots(root, config, slots, deps);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return { refusal: refuse('write-failed', causeOf(error)) };
|
|
169
|
+
}
|
|
170
|
+
made.push(`${CONFIG_WRITTEN} written`);
|
|
171
|
+
byId.set(SLOTS_ID, { ...byId.get(SLOTS_ID), word: WORDS.applied });
|
|
172
|
+
}
|
|
173
|
+
if (byId.get(STORE_ID)?.word === WORDS.offered) {
|
|
174
|
+
let seeded;
|
|
175
|
+
try {
|
|
176
|
+
seeded = writeProjectFileCreateOnly(root, STORE_SEED, '', deps, { noun: 'the epic store seed' });
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (storeCreated(root, deps)) made.push(`${STORE_DIR} created`);
|
|
179
|
+
return { refusal: refuse('write-failed', [...made, causeOf(error)].join('; ')) };
|
|
180
|
+
}
|
|
181
|
+
byId.set(STORE_ID, { ...byId.get(STORE_ID), word: seeded.created ? WORDS.applied : WORDS.present });
|
|
182
|
+
if (seeded.tmpLeftBehind) extra.push(OUTCOME_LINES.tmpLeft(seeded.tmpLeftBehind));
|
|
183
|
+
}
|
|
184
|
+
return { judged: judged.map((item) => byId.get(item.entry.id)), extra };
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const serializeDeclines = (declined) => {
|
|
188
|
+
const sorted = Object.fromEntries(Object.keys(declined).sort().map((id) => [id, declined[id]]));
|
|
189
|
+
return `${JSON.stringify({ schema: SCHEMA, declined: sorted }, null, 2)}${LF}`;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const runDecline = (root, deps, judged, profile, declined) => {
|
|
193
|
+
const offered = judged.filter(({ word }) => word === WORDS.offered);
|
|
194
|
+
if (offered.length === 0) return { judged, extra: [OUTCOME_LINES.nothingOffered()] };
|
|
195
|
+
const merged = { ...(declined ?? {}) };
|
|
196
|
+
for (const { entry } of offered) merged[entry.id] = profile.lineage;
|
|
197
|
+
try {
|
|
198
|
+
writeDocsAiFileAtomic(root, DECLINES_REL, serializeDeclines(merged), deps, { noun: 'the decline record' });
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return { refusal: refuse('write-failed', causeOf(error)) };
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
judged: judged.map((item) => (item.word === WORDS.offered ? { ...item, word: WORDS.recorded } : item)),
|
|
204
|
+
extra: [],
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const runOffer = (options, given) => {
|
|
209
|
+
const deps = withFs(given);
|
|
210
|
+
const root = resolve(options.cwd);
|
|
211
|
+
try {
|
|
212
|
+
assertDocsAiDeployment(root, deps);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
return refuse('no-deployment', causeOf(error));
|
|
215
|
+
}
|
|
216
|
+
const shipped = readShippedProfile(deps);
|
|
217
|
+
if (shipped.refusal) return refuse(shipped.refusal);
|
|
218
|
+
const record = readDeclines(root, deps);
|
|
219
|
+
if (record.refusal) return refuse(record.refusal);
|
|
220
|
+
const notes = [];
|
|
221
|
+
const readiness = composeReadiness(root, {
|
|
222
|
+
...deps, onDetectError: (error) => notes.push(OUTCOME_LINES.detectFailed(causeOf(error))),
|
|
223
|
+
});
|
|
224
|
+
const judged = judgeEntries(root, deps, shipped.profile, record.declined);
|
|
225
|
+
const offersSlots = judged.some(({ entry, word }) => entry.id === SLOTS_ID && word === WORDS.offered);
|
|
226
|
+
const config = offersSlots ? readTierConfig(root, deps).config : null;
|
|
227
|
+
const slots = offersSlots ? planSlots(config, readiness) : [];
|
|
228
|
+
let outcome = { judged, extra: [] };
|
|
229
|
+
if (options.apply) outcome = runApply(root, deps, judged, slots, config);
|
|
230
|
+
if (options.decline) outcome = runDecline(root, deps, judged, shipped.profile, record.declined);
|
|
231
|
+
if (outcome.refusal) return outcome.refusal;
|
|
232
|
+
const lines = [...notes, ...outcome.judged.flatMap((item) => renderEntry(item, root, slots)), ...outcome.extra];
|
|
233
|
+
return result(EXIT_SUCCESS, lines.join(LF));
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
export const main = (argv, deps = {}) => {
|
|
237
|
+
const parsed = parseArgs(argv);
|
|
238
|
+
if (parsed.help) return result(EXIT_SUCCESS, OUTCOME_LINES.help());
|
|
239
|
+
if (parsed.problem) return result(EXIT_USAGE, '', OUTCOME_LINES.usage(parsed.problem));
|
|
240
|
+
return runOffer(parsed, deps);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
if (isDirectRun(import.meta.url)) {
|
|
244
|
+
const outcome = main(process.argv.slice(ARGV_OFFSET));
|
|
245
|
+
if (outcome.stdout) console.log(outcome.stdout);
|
|
246
|
+
if (outcome.stderr) console.error(outcome.stderr);
|
|
247
|
+
process.exitCode = outcome.code;
|
|
248
|
+
}
|
|
@@ -87,6 +87,12 @@ export const UPGRADE_RUNLIST = Object.freeze([
|
|
|
87
87
|
'STOP',
|
|
88
88
|
],
|
|
89
89
|
),
|
|
90
|
+
entry(
|
|
91
|
+
'tier',
|
|
92
|
+
'node ${CLAUDE_SKILL_DIR}/tools/tier-preview.mjs --cwd <project>',
|
|
93
|
+
'preview first — on an explicit yes re-run with --apply and run every other offered line with --apply; on an explicit no re-run with --decline',
|
|
94
|
+
['present', 'offered', 'declined', 'undecidable', 'applied', 'recorded', 'refused'],
|
|
95
|
+
),
|
|
90
96
|
entry(
|
|
91
97
|
'bridge-settings',
|
|
92
98
|
'node ${CLAUDE_SKILL_DIR}/tools/bridge-settings.mjs --reconcile',
|
|
@@ -26,7 +26,7 @@ import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './decl
|
|
|
26
26
|
|
|
27
27
|
// Deployment-lineage head this velocity build targets; bump together with agent-workflow-memory
|
|
28
28
|
// LINEAGE_HEAD when the deployed docs/ai structure changes.
|
|
29
|
-
export const EXPECTED_WORKFLOW_VERSION = '
|
|
29
|
+
export const EXPECTED_WORKFLOW_VERSION = '4.0.0';
|
|
30
30
|
export const SETTINGS_FILE = '.claude/settings.json';
|
|
31
31
|
export const SETTINGS_LOCAL_FILE = '.claude/settings.local.json';
|
|
32
32
|
export const CLAUDE_DIR = '.claude';
|