@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 +54 -0
- package/README.md +2 -1
- package/SKILL.md +1 -1
- package/bin/install.mjs +16 -18
- package/capability.json +1 -1
- package/migrations/1.1.0-communication-language.md +12 -15
- package/migrations/1.2.0-agent-attribution.md +17 -21
- package/migrations/README.md +8 -2
- package/package.json +1 -1
- package/references/modes/upgrade.md +2 -2
- package/references/reference-profile.json +37 -0
- package/tools/migration-blocks.mjs +197 -0
- package/tools/migration-notes.mjs +218 -0
- package/tools/payload-prune.mjs +173 -0
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`
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
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
|
package/migrations/README.md
CHANGED
|
@@ -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.
|
|
12
|
-
|
|
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.
|
|
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.**
|
|
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.
|
|
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
|
+
};
|