@sabaiway/agent-workflow-kit 14.4.3 → 14.5.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 CHANGED
@@ -4,6 +4,60 @@ 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.5.0 — an upgrade delivers its migration notes and entry-point blocks through two new tools, and `init` removes the files the package does not carry from an existing kit home (AD-151)
8
+
9
+ **Three things the upgrade already promised now run as code.** Up to 14.4.3 the upgrade mode asked the agent to pick
10
+ the migration notes newer than the project's stamp by itself and to paste two entry-point blocks by hand, and `init`
11
+ only added files, so a kit home kept every file a later package stopped shipping (except two paths the installer named
12
+ by hand). 14.5.0 adds two command-line tools and one library the installer calls. Story S1 of the epic
13
+ `CONSUMERS-MOVE-ONTO-THE-FULL-FLOW`; contract `docs/ai/specs/kit/upgrade-delivery/` (revision 1).
14
+
15
+ - **`tools/migration-notes.mjs --cwd <project>`** (read-only). Lists each note under `migrations/` whose version is
16
+ newer than the project's `docs/ai/.workflow-version` and not newer than the deployment-lineage head, in ascending
17
+ order, one stdout line each: `note <version> <path> :: <headline>`. A refusal is one stderr line that opens with its
18
+ name; a usage error exits 2 before anything is read.
19
+ - **`tools/migration-blocks.mjs --cwd <project> [--language <text>] [--attribution on|off] [--apply]`**. Places the
20
+ Communication language and Attribution blocks into the project's `AGENTS.md` from the kit's
21
+ `references/templates/AGENTS.md`, now the kit's only copy of their text. Without `--apply` it previews and writes
22
+ nothing; with `--apply` it writes every missing block in one atomic write, writes nothing when both blocks are
23
+ already present, and writes nothing when it refuses, naming the refusal in one stderr line.
24
+ - **`tools/payload-prune.mjs`** (library). After its copy, `init` compares each of the five folders it installs
25
+ (`references/`, `tools/`, `migrations/`, `launchers/`, `bridges/`) with the same folder in the kit home. A regular file the package does not carry is removed with one `removed` line, a file
26
+ you placed there yourself included; a directory or symlink the package does not carry is kept with a `kept` line
27
+ (the files inside such a directory are removed all the same). `init` now refuses an existing folder that is
28
+ neither empty nor a kit home (a `SKILL.md` whose frontmatter names `agent-workflow-kit`): it exits 1 before
29
+ writing anything. A kind mismatch or a failed enumeration
30
+ stops the prune before it removes anything; a failed removal is reported by path and the other files still go. When
31
+ the prune reports a failure, `init` prints a non-convergence line in place of the line that names the installed
32
+ version, still runs its launcher, bridge, memory and engine steps, and exits 1. The hand-kept `RETIRED_PATHS` list
33
+ is gone.
34
+ - **Upgrade mode.** Steps 6 and 7 of `references/modes/upgrade.md` run the notes tool and the blocks writer, relay
35
+ their lines and act on their exits: `stamp-absent` returns to step 1, `answer-missing` asks for the named answers
36
+ and re-runs once, any other refusal stops the upgrade before the stamp.
37
+ - **Notes and docs.** Notes 1.1.0 and 1.2.0 no longer carry block text; they name the writer. The
38
+ `.claude/settings.json` step of 1.2.0 stays a hand step, taken whenever the Attribution block reads `off`. The
39
+ migrations README names the notes tool as the only selection. The README and `init --help` no longer say `init` is
40
+ additive: it never deletes project settings, and in an existing kit home it removes each file in the kit's folders
41
+ that the package does not carry, one line each.
42
+ - **Reference profile.** `references/reference-profile.json` names the profile `full-flow`: ten items, each keyed to
43
+ the story that delivers it. `test/upgrade-full-flow.test.mjs` seeds an older kit home and project, upgrades them
44
+ once with the real tools and checks each landed story's items; S1's three items pass, and every story not yet landed
45
+ is asserted to show a gap.
46
+ - **Tests.** Suites for the three new modules; `test/package-content.test.mjs` pins 328 packed files and names the
47
+ four new ones; the legacy-path case and the two symlink cases of `bin/install.test.mjs` seed the kit's `SKILL.md`
48
+ their existing homes stand for.
49
+
50
+ **Upgrade criterion.** Nothing to do by hand, unless you keep a file of your own in the kit home's `references/`,
51
+ `tools/`, `migrations/`, `launchers/` or `bridges/` folders: move it out first. The first `init` of 14.5.0 over an
52
+ existing kit home (`~/.claude/skills/agent-workflow-kit/` by default) removes every file in those five folders that
53
+ the package does not carry, one `removed` line each; that is the intended cleanup. A home that an `init` before 1.15.2
54
+ wrote, or one copied by hand from the source tree, also loses the test files and fixtures under `tools/` that the
55
+ package does not ship, and the folders they leave empty are reported as `kept` on every run until you remove them. A
56
+ folder an earlier `init` wrote the kit's `SKILL.md` into counts as a kit home, a project folder included: if you once
57
+ pointed `--dir` at a project, delete the kit's `SKILL.md` there before you run `init` into it again, or the project's
58
+ own files in those five folders are removed. A `--dir` or `AGENT_WORKFLOW_KIT_DIR` that names an
59
+ existing folder which is neither empty nor a kit home is now refused; point it at a new or empty folder instead.
60
+
7
61
  ## 14.4.3 — cleanup after the two splits: one false doc pointer, a test title, a list order, comments and whitespace; nothing the kit decides, prints or records changes
8
62
 
9
63
  **Six small review findings from the review-state and core-evidence splits are fixed.** The only shipped doc change is in
package/README.md CHANGED
@@ -196,7 +196,8 @@ other agent). The manual path works identically but **isn't reflected in install
196
196
  <details>
197
197
  <summary><b>What <code>init</code> touches — and how to undo it</b></summary>
198
198
 
199
- `init` is **additive — it never deletes your settings.** It writes its own namespaced slots, then
199
+ `init` **never deletes your project settings**, and it installs only into a new or empty folder or an existing kit home: any other `--dir` is refused before anything is written. In an existing kit home it removes every file in the kit's
200
+ `references/`, `tools/`, `migrations/`, `launchers/` and `bridges/` folders that the package does not carry, a file you put there included, one reported line per file. It writes its own namespaced slots, then
200
201
  refreshes the other npm core members so a returning `init` leaves **no stale core member**:
201
202
 
202
203
  | Path | What |
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: '14.4.3'
6
+ version: '14.5.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/bin/install.mjs CHANGED
@@ -30,13 +30,14 @@
30
30
  //
31
31
  // Dependency-free, Node >= 22.
32
32
 
33
- import { readFile, mkdir, rm } from 'node:fs/promises';
33
+ import { readFile, mkdir } from 'node:fs/promises';
34
34
  import { existsSync, lstatSync, realpathSync } from 'node:fs';
35
35
  import { dirname, resolve, sep } from 'node:path';
36
36
  import { fileURLToPath } from 'node:url';
37
37
  import { homedir } from 'node:os';
38
38
  import { spawnSync } from 'node:child_process';
39
39
  import { copyTreeRefresh } from '../tools/fs-safe.mjs';
40
+ import { assertInstallableHome, prunePayload } from '../tools/payload-prune.mjs';
40
41
  // Dependency-free semver, shared with the family registry's bridge freshness probe (one leaf, no
41
42
  // second drifting copy). The null-on-unparseable contract is load-bearing here: legacy installs
42
43
  // predate any version stamp, so an unparseable side means "no gate", never a false ordering.
@@ -75,12 +76,6 @@ const PAYLOAD = [
75
76
  'bridges',
76
77
  ];
77
78
 
78
- // Kit-owned files the package NO LONGER ships (Plan 3D retired the bundled methodology mirror). The
79
- // refresh copy is additive, so a 1.10.0→1.11.0 upgrade would leave these dead copies behind; remove
80
- // exactly these known kit paths from the target on install (never user content, never a dir/symlink),
81
- // so an upgraded install has the same single-source-of-truth shape as a fresh one.
82
- const RETIRED_PATHS = ['references/planning.md', 'tools/methodology-slot.md'];
83
-
84
79
  // Collapse only a LEADING homedir() to "~" — anchored at the string start (boundary-checked with
85
80
  // `sep`), never a mid-path occurrence (Issue-004 parity with the engine/memory installers).
86
81
  const tildify = (path) =>
@@ -305,7 +300,8 @@ Installs/refreshes the kit at ~/.claude/skills/agent-workflow-kit
305
300
  launcher file (backed up first). The bridges are never PLACED by init (first placement
306
301
  stays /agent-workflow-kit setup, opt-in); once placed, init refreshes them from the kit's
307
302
  own bundled copies — local files only, never a downgrade — and --no-bridges skips that
308
- refresh. init is additive — it never deletes your settings. If the
303
+ refresh. init never deletes your project settings; in an existing kit home it removes each
304
+ file in its folders that the package does not carry, one line each. If the
309
305
  installed kit is newer than the version you ran, init refuses (no network — it compares
310
306
  the version on disk) and points you at @latest; --allow-downgrade overrides that
311
307
  refusal (distinct from --force, which is launcher-only).
@@ -373,19 +369,14 @@ const main = async () => {
373
369
  process.exit(1);
374
370
  }
375
371
 
372
+ assertInstallableHome(target);
376
373
  await mkdir(target, { recursive: true });
377
374
  for (const entry of PAYLOAD.filter((e) => existsSync(resolve(PKG_ROOT, e)))) {
378
375
  copyTreeRefresh(resolve(PKG_ROOT, entry), resolve(target, entry), target);
379
376
  }
380
- // Remove the retired mirror files an older install may have left (additive refresh never deletes).
381
- // Only a regular file at the exact known path is removed — lstat (no-follow) so a dir/symlink is
382
- // left untouched, and the path is a hardcoded kit-owned constant (no traversal, never user content).
383
- for (const rel of RETIRED_PATHS) {
384
- const retired = resolve(target, rel);
385
- if (lstatNoFollow(retired)?.isFile()) {
386
- await rm(retired, { force: true });
387
- console.log(`[agent-workflow-kit] removed retired file ${tildify(retired)} (now read live from the engine).`);
388
- }
377
+ const prune = prunePayload({ packageRoot: PKG_ROOT, home: target, payload: PAYLOAD });
378
+ for (const line of prune.lines) {
379
+ console.log(line);
389
380
  }
390
381
  // Verb keyed on the OBSERVED version relation (cmp), never on mere presence: null (fresh install,
391
382
  // or a legacy/unstamped one whose prior version is unknowable) → "installed"; -1 → a real update;
@@ -397,7 +388,11 @@ const main = async () => {
397
388
  : cmp === 1 ? 'downgraded the kit to'
398
389
  : cmp === -1 ? 'updated the kit to'
399
390
  : 'installed';
400
- console.log(`[agent-workflow-kit] ${verb} v${version} -> ${tildify(target)}`);
391
+ if (prune.ok) {
392
+ console.log(`[agent-workflow-kit] ${verb} v${version} -> ${tildify(target)}`);
393
+ } else {
394
+ console.log(prune.nonConvergence);
395
+ }
401
396
 
402
397
  // F12 restart hint — any run over a PRE-EXISTING install (every cmp outcome, incl. the legacy
403
398
  // unstamped one) leaves an already-open agent session still holding the PREVIOUS kit files in
@@ -545,6 +540,9 @@ const main = async () => {
545
540
  console.log('[agent-workflow-kit] methodology engine installed.');
546
541
  }
547
542
 
543
+ // The non-convergence line already took the verb's place.
544
+ if (!prune.ok) process.exit(1);
545
+
548
546
  // This command (de)installed the *kit* globally. Deploying it into a project is a
549
547
  // separate, in-agent step — and which sub-command depends on whether that project
550
548
  // already has the kit. Spell both out so it's unambiguous (see README "Use").
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "14.4.3",
6
+ "version": "14.5.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
@@ -20,21 +20,18 @@ translated either. This migration changes *one* doc block, nothing else.
20
20
  this migration is done; skip to Verification.
21
21
  3. **Ask the user** which language the agent should converse in (questions, explanations,
22
22
  summaries, status). Offer the language they're already writing in as the default.
23
- 4. Insert this block immediately **after the opening blockquote** (before `## 🧭 Memory Map`),
24
- replacing `<their language>` with the answer from step 3:
25
-
26
- ```markdown
27
- ---
28
-
29
- ## 🗣️ Communication language
30
-
31
- > **Talk to the user in <their language>** — every question, explanation, summary, and status update.
32
- > Keep code, identifiers, file paths, shell commands, log output, and abbreviations in their **source language** — translating them breaks copy-paste, search, and tooling.
33
- > This sets the **dialogue** language only — it does not translate the files in `docs/ai/` or this entry point, which stay in their source language (for cross-agent / cross-team portability).
34
- ```
35
-
36
- 5. Keep `AGENTS.md` within its ≤100-line budget (the block is ~6 lines; it fits). Do **not**
37
- touch any other section, and do **not** translate existing file contents.
23
+ Reuse an answer already collected by the upgrade's step-6 prompt; never re-ask it.
24
+ 4. Preview with `node ${CLAUDE_SKILL_DIR}/tools/migration-blocks.mjs --cwd <project>`; it writes nothing.
25
+ Then insert from the kit's `references/templates/AGENTS.md` with
26
+ `node ${CLAUDE_SKILL_DIR}/tools/migration-blocks.mjs --cwd <project> --apply --language <answer>`.
27
+ If the Attribution block is absent too, collect its answer and pass `--attribution <off|on>`
28
+ in the same run: both blocks land in one atomic write, only after all answers are collected.
29
+ Otherwise `answer-missing` on stderr withholds the write; ask only for the answers stdout
30
+ names and re-run apply once. A second withheld result stops the migration.
31
+ Any other named refusal on stderr stops the migration; relay it as worded before any stamp update.
32
+ The upgrade's step 7 runs this same command with the answers its step-6 prompt collected.
33
+ 5. The writer refuses with `cap` rather than push `AGENTS.md` past its 100-line budget.
34
+ Relay that refusal, trim `AGENTS.md`, then re-run the upgrade; do not translate existing contents.
38
35
 
39
36
  ## Verification
40
37
 
@@ -16,30 +16,26 @@ keeps it permanently via PR refs — so the safe default is "no mention anywhere
16
16
  ## Steps
17
17
 
18
18
  1. Open the project's entry point `AGENTS.md` (the real file; `CLAUDE.md` is a symlink to it).
19
- 2. **Idempotency check** — if it already contains a `## ✍️ Attribution` heading, skip to Verification.
20
- 3. **Ask the user**: may the agent attribute work to itself / AI anywhere (commits, PRs, code,
21
- comments, docs)? **Default to `off`** unless they opt in.
22
- 4. Insert this block immediately **after the *Communication language* block** (or, if that is
23
- absent, after the opening blockquote — before `## 🧭 Memory Map`), replacing `<off|on>` with
24
- the answer from step 3:
25
-
26
- ```markdown
27
- ---
28
-
29
- ## ✍️ Attribution
30
-
31
- > **Agent attribution: <off|on>** (chosen at setup).
32
- > **off** → never attribute work to the agent, AI, or the model: no `Co-Authored-By` trailers, no "Generated with …" footers, no AI/agent/model mentions in code, comments, commit messages, PR titles/bodies, branch names, or docs. Author everything as the human.
33
- > **on** → the agent may add its standard `Co-Authored-By` trailer / footer per your tooling defaults.
34
- > Claude Code also honours `includeCoAuthoredBy` in `.claude/settings.json`; this block binds every agent for everything written by hand.
35
- ```
36
-
37
- 5. **If the answer is `off` and the project uses Claude Code**, also set the harness flag so the
38
- automatic trailer stops (a doc directive cannot stop a harness-added trailer):
19
+ 2. **Idempotency check** — if it already contains a `## ✍️ Attribution` heading, skip step 4;
20
+ step 5 still applies.
21
+ 3. **Ask only for a missing block's answer not already collected**: may the agent attribute work
22
+ to itself / AI anywhere (commits, PRs, code, comments, docs)? **Default to `off`** unless opted in.
23
+ 4. Preview with `node ${CLAUDE_SKILL_DIR}/tools/migration-blocks.mjs --cwd <project>`; it writes nothing.
24
+ Re-run that command with `--apply --attribution <off|on>` to insert from the kit's template.
25
+ If the Communication language block is absent too, collect its answer and pass `--language <answer>`
26
+ in the same run: the writer places both blocks in ONE atomic write, or withholds with
27
+ `answer-missing` and writes nothing if an answer is missing.
28
+ Upgrade step 7 runs this same command with the answers its step-6 prompt collected; never re-ask them.
29
+ Relay the writer's lines; a named refusal on stderr stops the migration and is relayed as worded.
30
+ For `answer-missing`, ask exactly the missing answers and retry `--apply` once; a second withheld run stops.
31
+ 5. **Whenever the Attribution block reads `off`**, whether just inserted or already present,
32
+ keep this hand step outside the writer: for Claude Code, set the harness flag so the automatic
33
+ trailer stops (a doc directive cannot stop a harness-added trailer):
39
34
  - Ensure `.claude/settings.json` exists in the project root; create it as `{}` if absent.
40
35
  - Set `"includeCoAuthoredBy": false` (merge into the existing JSON; do not clobber other keys).
41
36
  - For other tools, disable their equivalent co-author / footer setting if present.
42
- 6. Keep `AGENTS.md` within its ≤100-line budget (the block is ~7 lines; it fits). Do **not**
37
+ 6. The writer refuses with `cap` rather than push `AGENTS.md` past its 100-line budget;
38
+ trim the entry point and re-run the upgrade. Do **not**
43
39
  retroactively rewrite existing git history here — that is a separate, deliberate operation.
44
40
 
45
41
  ## Verification
@@ -8,8 +8,13 @@ releases add files/templates, which `upgrade` reconciles without a migration.
8
8
  ## How `upgrade` applies them
9
9
 
10
10
  1. Read the project's stamped version from `docs/ai/.workflow-version`.
11
- 2. Select every migration whose `<version>` is **strictly newer** than the stamp.
12
- 3. Apply them in **ascending semver order**.
11
+ 2. Run `node ${CLAUDE_SKILL_DIR}/tools/migration-notes.mjs --cwd <project>` as the sole selector.
12
+ It lists every migration whose `<version>` is **strictly newer** than the stamp and not newer than the head.
13
+ Each line is `note <version> <path> :: <headline>`, with an absolute `<path>` under the running kit.
14
+ The headline is the note's first `# ` line, with that prefix removed and surrounding whitespace trimmed.
15
+ The upgrade must relay those stdout lines verbatim and apply exactly the listed notes.
16
+ A named refusal stops the upgrade, except `stamp-absent`, which routes it back to the upgrade's step 1.
17
+ 3. Apply them in the order the tool listed them (**ascending semver order**).
13
18
  4. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`3.0.0` today — the
14
19
  shared lineage, **not** this skill's npm package version). A stamp greater than the head → STOP.
15
20
 
@@ -19,6 +24,7 @@ releases add files/templates, which `upgrade` reconciles without a migration.
19
24
  - **Non-destructive** — never clobber project-authored content (their `decisions.md`, `known_issues.md`, page specs). Add/rename/restructure only what the kernel owns.
20
25
  - **Self-contained** — exact paths + commands, readable cold, like a mini-plan.
21
26
  - **Mention rollback** — note how to undo if the step is risky.
27
+ - **Template blocks** — a note never pastes entry-point block text; `tools/migration-blocks.mjs` places it from the kit's template.
22
28
 
23
29
  ## Template
24
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "14.4.3",
3
+ "version": "14.5.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",
@@ -99,6 +99,6 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
99
99
  - **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` / `agents` / `hook`).
100
100
  - **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).
101
101
  5. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` diff (entries newer than the project's stamp).
102
- 6. **Collect the migration answers FIRST, then apply.** If `AGENTS.md` is missing BOTH the *Communication language* and *Attribution* blocks — i.e. both blocks are missing (a pre-1.1.0 deployment) — 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 (its own "Ask the user" step is the standalone fallback); a single missing block keeps its single ask (step 7). Then 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.
103
- 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`. (An answer already collected by the step-6 batched prompt is carried in — never re-asked here.) **Then, before the stamp, re-run the LATE navigator finalizer:** the migrations of steps 6–7 may have added or 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).
102
+ 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.
103
+ 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).
104
104
  8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`3.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** outcomes (same outcome sets as step 4), 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.
@@ -0,0 +1,37 @@
1
+ {
2
+ "schema": 1,
3
+ "name": "full-flow",
4
+ "lineage": "4.0.0",
5
+ "items": [
6
+ { "id": "migration-notes-delivered",
7
+ "story": "S1",
8
+ "has": "An upgrade tells you every migration note your project's version selects, by path and headline." },
9
+ { "id": "migration-blocks-placed",
10
+ "story": "S1",
11
+ "has": "The entry point carries the communication-language and attribution blocks, placed from your answers." },
12
+ { "id": "payload-converged",
13
+ "story": "S1",
14
+ "has": "The installed kit holds no file the package stopped shipping." },
15
+ { "id": "story-sessions-section",
16
+ "story": "S2",
17
+ "has": "The project's rules carry the story-sessions section." },
18
+ { "id": "epic-task-slots",
19
+ "story": "S3",
20
+ "has": "The orchestration settings carry the epic and task slots, each resolved to your environment or to solo with the reason." },
21
+ { "id": "epic-store-seeded",
22
+ "story": "S3",
23
+ "has": "The epic store is seeded." },
24
+ { "id": "checker-gates-declared",
25
+ "story": "S4",
26
+ "has": "The kit-owned checker gates are declared." },
27
+ { "id": "session-close-rules",
28
+ "story": "S5",
29
+ "has": "The rules your project reads state the session-close value section and one task per session." },
30
+ { "id": "named-queue-row-seed",
31
+ "story": "S5",
32
+ "has": "A new work queue is seeded with the named row." },
33
+ { "id": "profile-gap-screen",
34
+ "story": "S6",
35
+ "has": "The Recommendations screen names every difference from this profile you have not declined, with the command for each." }
36
+ ]
37
+ }
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ import { lstatSync, readFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { AGENTS_MD_CAP } from './inject-methodology.mjs';
6
+ import { writeContainedFileAtomic } from './atomic-write.mjs';
7
+ import { isDirectRun } from './direct-run.mjs';
8
+
9
+ const LF = String.fromCharCode(10);
10
+ const CRLF = String.fromCharCode(13) + LF;
11
+ const ANCHOR = '## 🧭 Memory Map';
12
+ const TEMPLATE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'references', 'templates', 'AGENTS.md');
13
+ const REINSTALL = 'npx @sabaiway/agent-workflow-kit@latest init';
14
+ const BLOCKS = [
15
+ { name: 'communication', heading: '## 🗣️ Communication language', flag: 'language', placeholder: '{{COMM_LANGUAGE}}' },
16
+ { name: 'attribution', heading: '## ✍️ Attribution', flag: 'attribution', placeholder: '{{AGENT_ATTRIBUTION}}' },
17
+ ];
18
+ const FLAGS = ['--cwd', '--language', '--attribution', '--apply'];
19
+ const USAGE = 'usage: migration-blocks.mjs --cwd <project> [--language <text>] [--attribution on|off] [--apply]';
20
+ const CONTROL_BYTES = /[\x00-\x1f\x7f\u2028\u2029]/u;
21
+
22
+ const makeResult = (code, stdout = '', stderr = '') => ({ code, stdout, stderr });
23
+ const renderDetail = (detail) => String(detail).replace(/[\r\n\u2028\u2029]+/gu, ' ');
24
+ const refuse = (name, detail) => makeResult(1, '', detail ? `${name}: ${renderDetail(detail)}` : name);
25
+ const countLines = (text) => text.split(LF).length - Number(text.endsWith(LF));
26
+ const findHeadings = (lines, heading) => lines.flatMap((line, index) => line.trimEnd() === heading ? [index] : []);
27
+
28
+ const parseArgs = (argv) => {
29
+ const parsed = argv.reduce((state, argument) => {
30
+ if (state.pending) {
31
+ if (argument.startsWith('--')) throw new Error(`${state.pending} needs a value`);
32
+ return { options: { ...state.options, [state.pending.slice(2)]: argument }, pending: null };
33
+ }
34
+ if (!FLAGS.includes(argument)) throw new Error(`unknown argument ${argument}`);
35
+ const key = argument.slice(2);
36
+ if (Object.hasOwn(state.options, key)) throw new Error(`${argument} repeated`);
37
+ if (argument === '--apply') return { options: { ...state.options, apply: true }, pending: null };
38
+ return { options: state.options, pending: argument };
39
+ }, { options: {}, pending: null });
40
+ if (parsed.pending) throw new Error(`${parsed.pending} needs a value`);
41
+ if (!parsed.options.cwd) throw new Error('--cwd is required');
42
+ return parsed.options;
43
+ };
44
+
45
+ const checkAnswers = ({ language, attribution }) => {
46
+ if (language !== undefined && (!language.trim() || CONTROL_BYTES.test(language))) {
47
+ return refuse('language', 'provide one nonempty line without control bytes');
48
+ }
49
+ if (attribution !== undefined && !['on', 'off'].includes(attribution)) {
50
+ return refuse('attribution', 'expected on or off');
51
+ }
52
+ return null;
53
+ };
54
+
55
+ const extractSpans = (template) => {
56
+ const lines = template.split(CRLF).join(LF).split(LF);
57
+ return BLOCKS.map((block) => {
58
+ const headings = findHeadings(lines, block.heading);
59
+ if (headings.length !== 1) throw new Error(`${block.name} heading must occur once`);
60
+ const start = headings[0];
61
+ const next = lines.findIndex((line, index) => index > start && line.startsWith('## '));
62
+ const span = next < 0 ? lines.slice(start).join(LF) : lines.slice(start, next).join(LF) + LF;
63
+ if (span.split(block.placeholder).length !== 2) {
64
+ throw new Error(`${block.name} placeholder must occur once in its span`);
65
+ }
66
+ return { ...block, span };
67
+ });
68
+ };
69
+
70
+ const readTemplate = (path, deps) => {
71
+ try {
72
+ const read = deps.readFileSync ?? readFileSync;
73
+ return { spans: extractSpans(read(path, 'utf8')) };
74
+ } catch (error) {
75
+ return { error: refuse('template', `${error.message}; run ${REINSTALL}`) };
76
+ }
77
+ };
78
+
79
+ const readDocument = (path, deps) => {
80
+ const probe = deps.lstatSync ?? lstatSync;
81
+ const read = deps.readFileSync ?? readFileSync;
82
+ const status = (() => {
83
+ try {
84
+ return { stat: probe(path) };
85
+ } catch (error) {
86
+ const name = error.code === 'ENOENT' ? 'file-absent' : 'file-unreadable';
87
+ return { error: refuse(name, error.message) };
88
+ }
89
+ })();
90
+ if (status.error) return status;
91
+ if (status.stat.isSymbolicLink()) return { error: refuse('file-symlink', path) };
92
+ if (!status.stat.isFile()) return { error: refuse('file-unreadable', `${path} is not a regular file`) };
93
+ try {
94
+ return { text: read(path, 'utf8') };
95
+ } catch (error) {
96
+ return { error: refuse('file-unreadable', error.message) };
97
+ }
98
+ };
99
+
100
+ const classifyBlock = (block, lines, answers, newline) => {
101
+ const headings = findHeadings(lines, block.heading);
102
+ const answer = answers[block.flag];
103
+ if (headings.length > 1) return { error: refuse('heading-twice', block.name) };
104
+ if (headings.length === 1) {
105
+ return { ...block, state: 'present', index: headings[0], ignored: answer !== undefined };
106
+ }
107
+ if (answer === undefined) return { ...block, state: 'needs' };
108
+ const body = block.span.split(block.placeholder).join(answer).split(LF).join(newline);
109
+ return { ...block, state: 'planned', body };
110
+ };
111
+
112
+ const renderBlock = (block, applied) => {
113
+ if (block.state === 'present') return `${block.name}: present${block.ignored ? '; answer ignored' : ''}`;
114
+ if (block.state === 'needs') return `${block.name}: needs --${block.flag}`;
115
+ return `${block.name}: ${applied ? 'inserted' : 'planned'}`;
116
+ };
117
+
118
+ const insertBlocks = (text, lines, blocks) => {
119
+ const planned = blocks.filter((block) => block.state === 'planned');
120
+ if (planned.length === 0) return { body: text };
121
+ const anchors = lines.flatMap((line, index) => line.replace(/\r$/u, '') === ANCHOR ? [index] : []);
122
+ if (anchors.length === 0) return { error: refuse('anchor-absent', ANCHOR) };
123
+ if (anchors.length !== 1) return { error: refuse('anchor-repeated', ANCHOR) };
124
+ const total = countLines(text) + planned.reduce((count, block) => count + countLines(block.body), 0);
125
+ if (total > AGENTS_MD_CAP) {
126
+ return { error: refuse('cap', `${total} lines exceed ${AGENTS_MD_CAP}; trim AGENTS.md and re-run`) };
127
+ }
128
+ const anchor = anchors[0];
129
+ const attribution = blocks.find((block) => block.name === 'attribution');
130
+ const insertions = planned.map((block) => ({
131
+ ...block,
132
+ index: block.name === 'communication' && attribution.state === 'present' && attribution.index < anchor
133
+ ? attribution.index : anchor,
134
+ }));
135
+ const body = lines.map((line, index) => {
136
+ const prefix = insertions.filter((block) => block.index === index).map((block) => block.body).join('');
137
+ return prefix + line;
138
+ }).join(LF);
139
+ return { body };
140
+ };
141
+
142
+ export const planBlocks = ({ text, spans, language, attribution, apply = false }) => {
143
+ const lines = text.split(LF);
144
+ const newline = text.includes(CRLF) ? CRLF : LF;
145
+ const blocks = spans.map((block) => classifyBlock(block, lines, { language, attribution }, newline));
146
+ const refused = blocks.find((block) => block.error);
147
+ if (refused) return { result: refused.error, write: false };
148
+ const insertion = insertBlocks(text, lines, blocks);
149
+ if (insertion.error) return { result: insertion.error, write: false };
150
+ const missing = blocks.some((block) => block.state === 'needs');
151
+ const write = apply && !missing && blocks.some((block) => block.state === 'planned');
152
+ const stdout = blocks.map((block) => renderBlock(block, write)).join(LF);
153
+ const result = apply && missing ? makeResult(1, stdout, 'answer-missing') : makeResult(0, stdout);
154
+ return { result, write, body: insertion.body };
155
+ };
156
+
157
+ const runBlocks = (options, deps) => {
158
+ const invalid = checkAnswers(options);
159
+ if (invalid) return invalid;
160
+ const template = readTemplate(deps.templatePath ?? TEMPLATE_PATH, deps);
161
+ if (template.error) return template.error;
162
+ const root = resolve(options.cwd);
163
+ const destination = resolve(root, 'AGENTS.md');
164
+ const document = readDocument(destination, deps);
165
+ if (document.error) return document.error;
166
+ const plan = planBlocks({ ...options, text: document.text, spans: template.spans });
167
+ if (plan.write) {
168
+ try {
169
+ writeContainedFileAtomic(root, destination, plan.body, {
170
+ writeFile: deps.writeFile,
171
+ rename: deps.rename,
172
+ rm: deps.rm,
173
+ });
174
+ } catch (error) {
175
+ return refuse('write-failed', error.message);
176
+ }
177
+ }
178
+ return plan.result;
179
+ };
180
+
181
+ export const main = (argv, deps = {}) => {
182
+ const parsed = (() => {
183
+ try {
184
+ return { options: parseArgs(argv) };
185
+ } catch (error) {
186
+ return { error: makeResult(2, '', `${USAGE}; ${renderDetail(error.message)}`) };
187
+ }
188
+ })();
189
+ return parsed.error ?? runBlocks(parsed.options, deps);
190
+ };
191
+
192
+ if (isDirectRun(import.meta.url)) {
193
+ const result = await main(process.argv.slice(2));
194
+ if (result.stdout) console.log(result.stdout);
195
+ if (result.stderr) console.error(result.stderr);
196
+ process.exit(result.code);
197
+ }
@@ -0,0 +1,218 @@
1
+ import { lstatSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { EXPECTED_WORKFLOW_VERSION } from './cheap-agents-read.mjs';
5
+ import { isDirectRun } from './direct-run.mjs';
6
+
7
+ const NOTES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'migrations');
8
+ const STAMP_PATH = 'docs/ai/.workflow-version';
9
+ const VERSION_PATTERN = /^[0-9]+[.][0-9]+[.][0-9]+$/u;
10
+ const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
11
+ const CONTROL_PATTERN = /[\p{Cc}\p{Zl}\p{Zp}]/gu;
12
+ const NOTE_SUFFIX = '.md';
13
+ const README_NAME = 'README.md';
14
+ const HEADLINE_PREFIX = '# ';
15
+ const LF = String.fromCharCode(10);
16
+ const CR = String.fromCharCode(13);
17
+ const EXIT_ACCEPTED = 0;
18
+ const EXIT_REFUSED = 1;
19
+ const EXIT_USAGE = 2;
20
+
21
+ const formatDetail = (value) => String(value).replace(CONTROL_PATTERN, ' ');
22
+ const makeRefusal = (name, detail) => ({
23
+ code: EXIT_REFUSED,
24
+ stdout: '',
25
+ stderr: `${name}: ${formatDetail(detail)}`,
26
+ });
27
+ const matchesEntire = (text, pattern) => text.match(pattern)?.[0] === text;
28
+ const parseVersion = (version) => version.split('.').map((field) => BigInt(field));
29
+ const compareVersions = (left, right) => {
30
+ const rightFields = parseVersion(right);
31
+ for (const [index, field] of parseVersion(left).entries()) {
32
+ if (field < rightFields[index]) {
33
+ return -1;
34
+ }
35
+ if (field > rightFields[index]) {
36
+ return 1;
37
+ }
38
+ }
39
+ return 0;
40
+ };
41
+
42
+ const judgeStamp = (stamp, head) => {
43
+ if (!matchesEntire(stamp, VERSION_PATTERN)) {
44
+ return makeRefusal('stamp-unparseable', 'expected one major.minor.patch version');
45
+ }
46
+ if (compareVersions(stamp, head) > 0) {
47
+ return makeRefusal('stamp-above-head', `${stamp} is newer than ${head}; never downgrade`);
48
+ }
49
+ return null;
50
+ };
51
+
52
+ const shouldIgnore = (name) => name.startsWith('.') || !name.endsWith(NOTE_SUFFIX);
53
+ const parseNote = (entry) => {
54
+ const stem = entry.name.slice(0, -NOTE_SUFFIX.length);
55
+ const separator = stem.indexOf('-');
56
+ const version = stem.slice(0, separator);
57
+ const slug = stem.slice(separator + 1);
58
+ const valid = separator > 0 && matchesEntire(version, VERSION_PATTERN) && matchesEntire(slug, SLUG_PATTERN);
59
+ return { ...entry, version, valid };
60
+ };
61
+
62
+ const readHeadline = (text) => {
63
+ const line = text.split(LF).find((candidate) => candidate.startsWith(HEADLINE_PREFIX));
64
+ if (line === undefined) {
65
+ return null;
66
+ }
67
+ const content = (line.endsWith(CR) ? line.slice(0, -CR.length) : line).slice(HEADLINE_PREFIX.length);
68
+ return content.search(CONTROL_PATTERN) < 0 && content.trim() !== '' ? content.trim() : null;
69
+ };
70
+
71
+ const findDuplicate = (notes) => {
72
+ const versions = new Set();
73
+ for (const note of notes) {
74
+ const key = parseVersion(note.version).join('.');
75
+ if (versions.has(key)) {
76
+ return note;
77
+ }
78
+ versions.add(key);
79
+ }
80
+ return null;
81
+ };
82
+
83
+ // The caller has trimmed and judged the stamp; main judges every stamp cell before reading the notes directory.
84
+ export const selectNotes = (stamp, entries, head) => {
85
+ const candidates = entries.filter(({ name }) => !shouldIgnore(name));
86
+ const unreadable = candidates.find(({ kind }) => kind === 'unreadable');
87
+ if (unreadable) {
88
+ return makeRefusal('notes-unreadable', `${unreadable.name}: ${unreadable.detail}`);
89
+ }
90
+ const parsed = candidates.map(parseNote);
91
+ const invalid = parsed.find(({ kind, name, valid }) => kind !== 'file' || (name !== README_NAME && !valid));
92
+ if (invalid) {
93
+ return makeRefusal('notes-entry', invalid.name);
94
+ }
95
+ const notes = parsed.filter(({ name }) => name !== README_NAME);
96
+ const aboveHead = notes.find(({ version }) => compareVersions(version, head) > 0);
97
+ if (aboveHead) {
98
+ return makeRefusal('note-above-head', `${aboveHead.name} is newer than ${head}`);
99
+ }
100
+ const duplicate = findDuplicate(notes);
101
+ if (duplicate) {
102
+ return makeRefusal('note-version-twice', duplicate.version);
103
+ }
104
+ const titled = notes.map((note) => ({ ...note, headline: readHeadline(note.text) }));
105
+ const untitled = titled.find(({ headline }) => headline === null);
106
+ if (untitled) {
107
+ return makeRefusal('note-headline', untitled.name);
108
+ }
109
+ const selected = titled
110
+ .filter(({ version }) => compareVersions(version, stamp) > 0 && compareVersions(version, head) <= 0)
111
+ .sort((left, right) => compareVersions(left.version, right.version));
112
+ const none = compareVersions(stamp, head) === 0
113
+ ? `none: stamp ${stamp} equals head ${head}; no note applies`
114
+ : `none: no notes newer than stamp ${stamp} through head ${head}`;
115
+ const lines = selected.map(({ version, path, headline }) => `note ${version} ${path} :: ${headline}`);
116
+ const ignored = entries.filter(({ name }) => shouldIgnore(name)).map(({ name }) => `ignored: ${formatDetail(name)}`);
117
+ return {
118
+ code: EXIT_ACCEPTED,
119
+ stdout: lines.length > 0 ? lines.join(LF) : none,
120
+ stderr: ignored.join(LF),
121
+ };
122
+ };
123
+
124
+ const probePath = (path, fs) => {
125
+ try {
126
+ const stat = fs.lstatSync(path);
127
+ if (stat.isSymbolicLink()) {
128
+ return { kind: 'symlink' };
129
+ }
130
+ if (stat.isFile()) {
131
+ return { kind: 'file' };
132
+ }
133
+ return { kind: stat.isDirectory() ? 'directory' : 'other' };
134
+ } catch (error) {
135
+ return { kind: error.code === 'ENOENT' ? 'absent' : 'unreadable', detail: error.message };
136
+ }
137
+ };
138
+
139
+ const readStamp = (path, fs) => {
140
+ const probe = probePath(path, fs);
141
+ if (probe.kind === 'absent') {
142
+ return { refusal: makeRefusal('stamp-absent', `${path}; use step 1 conservative re-bootstrap`) };
143
+ }
144
+ if (probe.kind !== 'file') {
145
+ return { refusal: makeRefusal('stamp-unreadable', `${path}: ${probe.detail ?? probe.kind}`) };
146
+ }
147
+ try {
148
+ return { text: fs.readFileSync(path, 'utf8') };
149
+ } catch (error) {
150
+ return { refusal: makeRefusal('stamp-unreadable', `${path}: ${error.message}`) };
151
+ }
152
+ };
153
+
154
+ const readEntry = (notesDir, name, fs) => {
155
+ const path = join(notesDir, name);
156
+ if (shouldIgnore(name)) {
157
+ return { name, path };
158
+ }
159
+ const probe = probePath(path, fs);
160
+ if (probe.kind !== 'file' || name === README_NAME) {
161
+ return { name, path, ...probe };
162
+ }
163
+ try {
164
+ return { name, path, kind: 'file', text: fs.readFileSync(path, 'utf8') };
165
+ } catch (error) {
166
+ return { name, path, kind: 'unreadable', detail: error.message };
167
+ }
168
+ };
169
+
170
+ const readNotes = (notesDir, fs) => {
171
+ const probe = probePath(notesDir, fs);
172
+ if (probe.kind === 'absent') {
173
+ return { refusal: makeRefusal('notes-absent', notesDir) };
174
+ }
175
+ if (probe.kind !== 'directory') {
176
+ return { refusal: makeRefusal('notes-unreadable', `${notesDir}: ${probe.detail ?? probe.kind}`) };
177
+ }
178
+ try {
179
+ return { entries: fs.readdirSync(notesDir).map((name) => readEntry(notesDir, name, fs)) };
180
+ } catch (error) {
181
+ return { refusal: makeRefusal('notes-unreadable', `${notesDir}: ${error.message}`) };
182
+ }
183
+ };
184
+
185
+ export const main = (argv, deps = {}) => {
186
+ const validUsage = argv.length === 2 && argv[0] === '--cwd'
187
+ && typeof argv[1] === 'string' && argv[1].trim() !== '' && !argv[1].startsWith('--');
188
+ if (!validUsage) {
189
+ return { code: EXIT_USAGE, stdout: '', stderr: 'usage: migration-notes.mjs --cwd <project>' };
190
+ }
191
+ const fs = {
192
+ lstatSync: deps.lstatSync ?? lstatSync,
193
+ readFileSync: deps.readFileSync ?? readFileSync,
194
+ readdirSync: deps.readdirSync ?? readdirSync,
195
+ };
196
+ const stamp = readStamp(resolve(argv[1], STAMP_PATH), fs);
197
+ if (stamp.refusal) {
198
+ return stamp.refusal;
199
+ }
200
+ const stampText = stamp.text.trim();
201
+ const stampRefusal = judgeStamp(stampText, EXPECTED_WORKFLOW_VERSION);
202
+ if (stampRefusal) {
203
+ return stampRefusal;
204
+ }
205
+ const notes = readNotes(resolve(deps.notesDir ?? NOTES_DIR), fs);
206
+ return notes.refusal ?? selectNotes(stampText, notes.entries, EXPECTED_WORKFLOW_VERSION);
207
+ };
208
+
209
+ if (isDirectRun(import.meta.url)) {
210
+ const result = await main(process.argv.slice(2));
211
+ if (result.stdout) {
212
+ console.log(result.stdout);
213
+ }
214
+ if (result.stderr) {
215
+ console.error(result.stderr);
216
+ }
217
+ process.exit(result.code);
218
+ }
@@ -0,0 +1,173 @@
1
+ import { lstatSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
2
+ import { resolve, sep } from 'node:path';
3
+ import { assertContainedRealPath } from './fs-safe.mjs';
4
+ import { lstatNoFollowRead } from './fs-read-nofollow.mjs';
5
+
6
+ const PREFIX = '[agent-workflow-kit]';
7
+ const NON_CONVERGENCE = `${PREFIX} the kit home did not converge to the package (see the lines above) `
8
+ + '— fix them, then re-run npx @sabaiway/agent-workflow-kit@latest init';
9
+
10
+ export const readHomeOwner = (home) => {
11
+ try {
12
+ const stat = lstatSync(home, { throwIfNoEntry: false });
13
+ if (stat === undefined) {
14
+ return 'absent';
15
+ }
16
+ if (!stat.isDirectory()) {
17
+ return 'other';
18
+ }
19
+ if (readdirSync(home).length === 0) {
20
+ return 'empty';
21
+ }
22
+ const skill = resolve(home, 'SKILL.md');
23
+ if (!lstatSync(skill).isFile()) {
24
+ return 'other';
25
+ }
26
+ const lines = readFileSync(skill, 'utf8').split('\n').map((line) => line.replace(/\r$/, ''));
27
+ if (lines[0] !== '---') {
28
+ return 'other';
29
+ }
30
+ const end = lines.findIndex((line, index) => index > 0 && line === '---');
31
+ if (end < 0) {
32
+ return 'other';
33
+ }
34
+ return lines.slice(1, end).some((line) => line.trimEnd() === 'name: agent-workflow-kit') ? 'kit' : 'other';
35
+ } catch {
36
+ return 'other';
37
+ }
38
+ };
39
+
40
+ export const assertInstallableHome = (home) => {
41
+ const owner = readHomeOwner(home);
42
+ if (owner === 'other') {
43
+ throw new Error(
44
+ `${PREFIX} refusing to install into ${home}: it is not empty and holds no agent-workflow-kit SKILL.md; `
45
+ + 'choose a new or empty folder, or an existing kit home',
46
+ );
47
+ }
48
+ return owner;
49
+ };
50
+
51
+ const describeKind = (stat) => {
52
+ if (stat.isSymbolicLink()) {
53
+ return 'symlink';
54
+ }
55
+ if (stat.isDirectory()) {
56
+ return 'directory';
57
+ }
58
+ return stat.isFile() ? 'file' : 'non-regular object';
59
+ };
60
+
61
+ const reportFailure = (path, error) => ({
62
+ ok: false,
63
+ line: `${PREFIX} failed ${path}: ${error.message}`,
64
+ });
65
+
66
+ const finishReport = (reports) => {
67
+ const ok = reports.every((report) => report.ok);
68
+ return {
69
+ ok,
70
+ lines: reports.map((report) => report.line),
71
+ nonConvergence: ok ? null : NON_CONVERGENCE,
72
+ };
73
+ };
74
+
75
+ const readTree = (root, entry, reports, directoryOnly = false) => {
76
+ const entries = new Map();
77
+ const visitEntry = (path, first = false) => {
78
+ try {
79
+ const absolute = resolve(root, path);
80
+ const stat = lstatSync(absolute, { throwIfNoEntry: !first });
81
+ if (!stat || (first && directoryOnly && !stat.isDirectory())) {
82
+ return;
83
+ }
84
+ const kind = describeKind(stat);
85
+ entries.set(path, kind);
86
+ if (kind === 'directory') {
87
+ for (const name of readdirSync(absolute).sort()) {
88
+ visitEntry(`${path}/${name}`);
89
+ }
90
+ }
91
+ } catch (error) {
92
+ reports.push(reportFailure(path, error));
93
+ }
94
+ };
95
+ try {
96
+ const stat = lstatSync(root, { throwIfNoEntry: false });
97
+ if (!stat) {
98
+ return entries;
99
+ }
100
+ if (!stat.isDirectory()) {
101
+ throw new Error(`the root is a ${describeKind(stat)}, not a real directory`);
102
+ }
103
+ visitEntry(entry, true);
104
+ } catch (error) {
105
+ reports.push(reportFailure(entry, error));
106
+ }
107
+ return entries;
108
+ };
109
+
110
+ const planSubtree = (carried, installed, orphans, reports) => {
111
+ for (const [path, kind] of installed) {
112
+ if (carried.has(path)) {
113
+ const packageKind = carried.get(path);
114
+ if (kind !== packageKind) {
115
+ reports.push({
116
+ ok: false,
117
+ line: `${PREFIX} kind-mismatch ${path}: the home holds a ${kind}, the package a ${packageKind}`,
118
+ });
119
+ }
120
+ } else if (kind === 'file') {
121
+ orphans.push(path);
122
+ } else {
123
+ reports.push({
124
+ ok: true,
125
+ line: `${PREFIX} kept ${path} (a ${kind} the package does not carry — yours to remove by hand)`,
126
+ });
127
+ }
128
+ }
129
+ };
130
+
131
+ const removeOrphan = (home, path, remove) => {
132
+ const absolute = resolve(home, path);
133
+ try {
134
+ remove.assertContainedRealPath(home, absolute);
135
+ const stat = remove.lstatNoFollowRead(absolute);
136
+ if (stat === null) {
137
+ return [];
138
+ }
139
+ if (!stat.isFile()) {
140
+ throw new Error('the leaf is no longer a regular file');
141
+ }
142
+ remove.unlinkSync(absolute);
143
+ return [{ ok: true, line: `${PREFIX} removed ${path} (not in the package)` }];
144
+ } catch (error) {
145
+ return [reportFailure(path, error)];
146
+ }
147
+ };
148
+
149
+ export const prunePayload = ({ packageRoot, home, payload }, deps = {}) => {
150
+ const reports = [];
151
+ const orphans = [];
152
+ for (const entry of new Set(payload)) {
153
+ const path = entry.split(sep).join('/');
154
+ const carried = readTree(packageRoot, path, reports, true);
155
+ if (![...carried.values()].includes('file')) {
156
+ continue;
157
+ }
158
+ const installed = readTree(home, path, reports);
159
+ planSubtree(carried, installed, orphans, reports);
160
+ }
161
+ if (reports.some((report) => !report.ok)) {
162
+ return finishReport(reports);
163
+ }
164
+ const remove = {
165
+ assertContainedRealPath: deps.assertContainedRealPath ?? assertContainedRealPath,
166
+ lstatNoFollowRead: deps.lstatNoFollowRead ?? lstatNoFollowRead,
167
+ unlinkSync: deps.unlinkSync ?? unlinkSync,
168
+ };
169
+ for (const path of orphans) {
170
+ reports.push(...removeOrphan(home, path, remove));
171
+ }
172
+ return finishReport(reports);
173
+ };