@sabaiway/agent-workflow-kit 3.7.0 → 3.8.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,39 @@ 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
+ ## 3.8.0 — cleanup never deletes a node_modules it cannot prove ephemeral (AD-069)
8
+
9
+ Routine non-abandon `worktrees cleanup` used to delete an ignored user-built `node_modules`
10
+ silently: `node_modules` sat unconditionally in the provision-owned root lists, so real user data
11
+ was treated as removable provision footprint. The ownership call is now made LIVE, at cleanup
12
+ time, from what deleting the node would destroy — never from who created it, and never from the
13
+ handoff record:
14
+
15
+ - **The gate** classifies the worktree's `node_modules` with one no-follow lstat and (for a
16
+ symlink) one buffer-form readlink: EPHEMERAL exactly when the raw target bytes equal MAIN's
17
+ `node_modules` path — a relative or re-encoded target that merely resolves to main stays
18
+ foreign, and the target's kind is irrelevant. Everything else — directory, file, special node —
19
+ is FOREIGN. The lane is tracked-first: a tracked path or tracked descendant wins over any
20
+ ignore rule, and an absent node with a live index entry is never clean-absent.
21
+ - **The one exemption** is the ignored-lane matching symlink (exactly what provision creates):
22
+ the ignored inventory skips it and plain `git worktree remove` unlinks it — after the gate's
23
+ verdict is RE-PROVEN, same class same lane, immediately before the irreversible remove. Any
24
+ change in between, or any probe error at any point, is a fail-closed STOP with no remove call.
25
+ - **Every other state stops surgically**, and the recovery matches the lane and kind: ignored/
26
+ untracked symlink, file, or special node → the exact single-node `rm`; directory → the
27
+ recursive form; then re-run cleanup (`--abandon` always named second). A TRACKED
28
+ `node_modules` never gets an `rm` — land its removal from MAIN instead. Probe errors offer no
29
+ removal command at all. A clean-absent verdict follows the legacy path unchanged, so landing a
30
+ tracked `node_modules` removal still converges. `--abandon` behavior is unchanged.
31
+ - The contract sentence ships as the exported `CLEANUP_OWNERSHIP_RULE`, emitted on every
32
+ ownership STOP and pinned into `references/modes/worktrees.md` by a new doc-parity binding.
33
+
34
+ Honest residuals, stated in the mode doc: the revalidation→remove window stays open (git performs
35
+ the removal; no door there, deliberately); content that git cannot see — or that appears after
36
+ the inventories inside a reset-restored tracked directory — remains a pre-existing generic
37
+ worktree-removal residual, no longer masked by a false ownership claim; on Windows a strict-bytes
38
+ mismatch degrades to the surgical STOP, never a deletion.
39
+
7
40
  ## 3.7.0 — the worktrees-dir advisor item can finally converge (AD-068)
8
41
 
9
42
  The `recommendations` advisor's `worktrees-dir` item used to fire forever: its only convergence
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: '3.7.0'
6
+ version: '3.8.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
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": "3.7.0",
6
+ "version": "3.8.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "3.7.0",
3
+ "version": "3.8.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",
@@ -54,8 +54,23 @@ own verbatim error through the existing Git-error surface.
54
54
  - `cleanup <slug> [--branch <name>] [--abandon]` — take the same transient lock and remove a LANDED
55
55
  worktree fail-closed after live landed-verification against main HEAD. Verification uses exactly
56
56
  the land exclusions (`docs/ai`, `docs/plans`), then checks untracked and ignored content before a
57
- plain worktree remove, branch `-d`, and prune. Provision-derived ignored containers are ephemeral;
58
- `node_modules` of any kind is provision-derivable because provision explicitly advises installs.
57
+ plain worktree remove, branch `-d`, and prune. Provision-derived ignored containers are ephemeral
58
+ except `node_modules`, which is never assumed provision-owned:
59
+ node_modules ownership is decided live: only a symlink whose raw target bytes equal MAIN's node_modules path, in the ignored lane, is provision-ephemeral; an absent node with no index entry is clean; every other state stops cleanup to protect user data or because inspection failed.
60
+ The verdict is
61
+ computed once, after landed verification and before the untracked/ignored inventories (it also
62
+ catches tracked `node_modules` and empty untracked directories git never lists); the lane is
63
+ tracked-first (a tracked path or tracked descendant wins over any ignore rule; an absent node with
64
+ a live index entry is never clean-absent); the single exempt state is re-proven — same class, same
65
+ lane — immediately before `git worktree remove`, and any deviation or probe error is a fail-closed
66
+ STOP with no removal performed. STOP recovery is surgical and lane-specific: untracked/ignored
67
+ symlink, file, or special node → a single-node `rm`; directory → the recursive form; then re-run
68
+ cleanup — `--abandon` is always named second; a tracked `node_modules` never gets an `rm` (land the
69
+ removal from MAIN instead); a probe error gets no removal command at all. A clean-absent verdict
70
+ follows the legacy cleanup path and carries no post-reset ownership proof: git-invisible or ignored
71
+ content appearing after the inventories, or surviving inside a reset-restored tracked directory,
72
+ remains a pre-existing generic worktree-removal residual (not `node_modules`-specific). On Windows
73
+ a strict-bytes mismatch degrades to the surgical STOP — fail-closed friction, never deletion.
59
74
  Foreign content stops cleanup. `--abandon` is the ONE destructive arm: it DESTROYS unlanded work,
60
75
  requires the handoff identity, and is the only path where `--force` may appear.
61
76
 
@@ -34,7 +34,7 @@ import {
34
34
  } from './recommendations.mjs';
35
35
  import { SKIPPED_READONLY } from './setup-backends.mjs';
36
36
  import { LATENT_ARM_NOTICE } from './review-state.mjs';
37
- import { QUEUE_SHARED_RULE, LANDING_FROM_MAIN, NO_DEPENDENCIES_POSTURE } from './worktrees.mjs';
37
+ import { QUEUE_SHARED_RULE, LANDING_FROM_MAIN, NO_DEPENDENCIES_POSTURE, CLEANUP_OWNERSHIP_RULE } from './worktrees.mjs';
38
38
 
39
39
  const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
40
40
 
@@ -105,6 +105,9 @@ export const BINDINGS = Object.freeze([
105
105
  valueBinding('queue-shared-rule', QUEUE_SHARED_RULE, QUEUE_SHARED_RULE, [WORKTREES_DOC]),
106
106
  valueBinding('landing-from-main', LANDING_FROM_MAIN, LANDING_FROM_MAIN, [WORKTREES_DOC]),
107
107
  valueBinding('no-dependencies-posture', NO_DEPENDENCIES_POSTURE, NO_DEPENDENCIES_POSTURE, [WORKTREES_DOC]),
108
+ // The cleanup-ownership contract (AD-069): the exact live sentence every ownership STOP emits —
109
+ // a reworded mode doc dropping the class × lane contract fails this pin plus the gate.
110
+ valueBinding('cleanup-ownership-rule', CLEANUP_OWNERSHIP_RULE, CLEANUP_OWNERSHIP_RULE, [WORKTREES_DOC]),
108
111
  ].map((b) => Object.freeze(b)));
109
112
 
110
113
  // ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
@@ -151,8 +154,9 @@ Usage:
151
154
  A CLOSED, exported registry binds each live code constant — the autonomy-doctor contract (the EXIT
152
155
  table, the status tokens, the trusted-dir allowlist), the recommendations/upgrade presentation
153
156
  contract (section header, empty line, verdict templates), the acks-store path, the setup refresh
154
- degrade token, the review-state clean-tree latent-arm notice, and the worktrees provision-record
155
- orientation contract (shared-queue rule, landing-from-main, no-dependencies install posture) — to
157
+ degrade token, the review-state clean-tree latent-arm notice, the worktrees provision-record
158
+ orientation contract (shared-queue rule, landing-from-main, no-dependencies install posture), and
159
+ the worktrees cleanup-ownership rule — to
156
160
  the exact token its references/modes/*.md contract must carry, and
157
161
  asserts the CURRENT value renders into every bound file. A drifted doc, an unreadable bound file,
158
162
  or an absent token FAILS CLOSED.
@@ -937,6 +937,10 @@ export const NO_DEPENDENCIES_POSTURE = 'no install needed — the project declar
937
937
  // The recorded node_modules mode for that same verdict: provision neither advised nor created a
938
938
  // node_modules for this worktree.
939
939
  export const NODE_MODULES_NONE = 'no-dependencies';
940
+ // The cleanup-ownership contract (AD-069): ownership is information content, decided live at
941
+ // cleanup time — never provenance, never the handoff record. Doc-parity pins this exact sentence
942
+ // into the worktrees mode doc; every ownership STOP emits it.
943
+ export const CLEANUP_OWNERSHIP_RULE = "node_modules ownership is decided live: only a symlink whose raw target bytes equal MAIN's node_modules path, in the ignored lane, is provision-ephemeral; an absent node with no index entry is clean; every other state stops cleanup to protect user data or because inspection failed";
940
944
 
941
945
  // The record is LINE-oriented and is parsed back for IDENTITY, so a value carrying a control byte
942
946
  // is refused rather than written: a newline spills a second line the parser reads as a real field
@@ -2266,7 +2270,6 @@ const provisionKnownRoots = (identity) => {
2266
2270
  ...registryRoots(),
2267
2271
  ...identity.record.includes.map(safeRecordedPath),
2268
2272
  PLANS_REL,
2269
- 'node_modules',
2270
2273
  ];
2271
2274
  if (identity.record.vscode === 'written') roots.push('.vscode/settings.json');
2272
2275
  return [...new Set(roots)];
@@ -2277,7 +2280,6 @@ const provisionKnownDirectoryRoots = ({ root, identity, fs }) => {
2277
2280
  ...KIT_OWN_PATHS.filter(isDirPattern),
2278
2281
  ...KNOWN_FOOTPRINT.filter((entry) => entry.type === 'dir').map((entry) => entry.pattern),
2279
2282
  PLANS_REL,
2280
- 'node_modules',
2281
2283
  ].map((path) => normalizeSlashes(path).replace(/^\//, '').replace(/\/$/, ''));
2282
2284
  for (const include of identity.record.includes.map(safeRecordedPath)) {
2283
2285
  const kind = classifyNodeNoFollow(join(root, include), fs).kind;
@@ -2294,7 +2296,7 @@ const rootCoveringPath = (path, roots) => roots.find((root) => {
2294
2296
  return path === root || path.startsWith(`${root}/`);
2295
2297
  }) ?? null;
2296
2298
 
2297
- const foreignIgnoredPaths = ({ root, git, identity, fs }) => {
2299
+ const foreignIgnoredPaths = ({ root, git, identity, fs, exemptNodeModules = false }) => {
2298
2300
  const result = gitRead(
2299
2301
  git, ['status', '--porcelain=v1', '-z', '--ignored', '--untracked-files=all'], root,
2300
2302
  'git status --ignored failed',
@@ -2305,6 +2307,9 @@ const foreignIgnoredPaths = ({ root, git, identity, fs }) => {
2305
2307
  for (const rawPath of entry.paths) {
2306
2308
  const normalized = normalizeSlashes(rawPath);
2307
2309
  const path = normalized.replace(/\/$/, '');
2310
+ // The ONE ownership exemption (AD-069): the gate has already proven this exact node an
2311
+ // ignored-lane matching symlink; it is neither re-probed nor reported here.
2312
+ if (exemptNodeModules && path === NODE_MODULES_REL) continue;
2308
2313
  const kind = classifyNodeNoFollow(join(root, path), fs).kind;
2309
2314
  const rootType = kind === 'plain-directory' || kind === 'symlink-to-directory'
2310
2315
  ? 'directory'
@@ -2327,6 +2332,88 @@ const foreignIgnoredPaths = ({ root, git, identity, fs }) => {
2327
2332
  }).map(([path]) => path);
2328
2333
  };
2329
2334
 
2335
+ // ── the node_modules ownership gate (AD-069) ──────────────────────────────────────────────
2336
+ // Class {absent, ephemeral, foreign} × lane {tracked, ignored, untracked}, decided live from
2337
+ // exactly one no-follow lstat + (symlink only) one buffer-form readlink + the git lane probes.
2338
+ // The record is never consulted; every probe error is FOREIGN fail-closed with no recovery
2339
+ // command. The single exempt state — the ignored-lane matching link — is re-proven immediately
2340
+ // before the irreversible worktree remove.
2341
+
2342
+ const NODE_MODULES_REL = 'node_modules';
2343
+
2344
+ const nodeModulesInspectStop = (nmPath, detail) => stop(
2345
+ `cleanup stopped: cannot inspect ${nmPath} (${detail})\n${CLEANUP_OWNERSHIP_RULE}`,
2346
+ );
2347
+
2348
+ const nodeModulesSnapshot = ({ wtRoot, mainRoot, git, fs }) => {
2349
+ const nmPath = join(wtRoot, NODE_MODULES_REL);
2350
+ const node = (() => {
2351
+ try {
2352
+ return { stat: fs.lstat(nmPath) };
2353
+ } catch (error) {
2354
+ return error?.code === 'ENOENT' ? { stat: null } : { error: error?.code ?? 'fs error' };
2355
+ }
2356
+ })();
2357
+ if (node.error) throw nodeModulesInspectStop(nmPath, node.error);
2358
+ const trackedProbe = git(['ls-files', '--cached', '-z', '--', NODE_MODULES_REL], wtRoot);
2359
+ if (trackedProbe.status !== 0) {
2360
+ throw nodeModulesInspectStop(nmPath, `git ls-files failed: ${(trackedProbe.stderr || trackedProbe.stdout).trim()}`);
2361
+ }
2362
+ const tracked = trackedProbe.stdout.length > 0;
2363
+ if (node.stat === null) {
2364
+ return { klass: 'absent', kind: 'absent', lane: tracked ? 'tracked' : null, nmPath };
2365
+ }
2366
+ const lane = tracked ? 'tracked' : (() => {
2367
+ const probe = git(['check-ignore', '--', NODE_MODULES_REL], wtRoot);
2368
+ if (probe.status === 0) return 'ignored';
2369
+ if (probe.status === 1) return 'untracked';
2370
+ throw nodeModulesInspectStop(nmPath, `git check-ignore failed: ${(probe.stderr || probe.stdout).trim()}`);
2371
+ })();
2372
+ if (!node.stat.isSymbolicLink()) {
2373
+ const kind = node.stat.isDirectory() ? 'directory' : node.stat.isFile() ? 'file' : 'special';
2374
+ return { klass: 'foreign', kind, lane, nmPath };
2375
+ }
2376
+ const target = (() => {
2377
+ try {
2378
+ return { bytes: fs.readlink(nmPath, { encoding: 'buffer' }) };
2379
+ } catch (error) {
2380
+ return { error: error?.code ?? 'fs error' };
2381
+ }
2382
+ })();
2383
+ if (target.error) throw nodeModulesInspectStop(nmPath, target.error);
2384
+ // Raw BYTES against MAIN's node_modules path — never decoded, never resolved: a relative or
2385
+ // re-encoded form that merely RESOLVES to main stays foreign, and the target's kind is
2386
+ // irrelevant (unlink touches only the link).
2387
+ const matches = Buffer.compare(target.bytes, Buffer.from(join(mainRoot, NODE_MODULES_REL))) === 0;
2388
+ return { klass: matches ? 'ephemeral' : 'foreign', kind: 'symlink', lane, nmPath };
2389
+ };
2390
+
2391
+ const nodeModulesExemptVerdict = (snapshot) => snapshot.klass === 'ephemeral' && snapshot.lane === 'ignored';
2392
+
2393
+ const composeNodeModulesStop = ({ snapshot, slug, branch, changed = false }) => {
2394
+ const { klass, kind, lane, nmPath } = snapshot;
2395
+ const kindDesc = kind === 'symlink'
2396
+ ? (klass === 'ephemeral' ? 'a main-matching symlink' : 'a foreign symlink')
2397
+ : kind === 'directory' ? 'a directory'
2398
+ : kind === 'file' ? 'a regular file'
2399
+ : kind === 'special' ? 'a special node' : 'absent';
2400
+ const head = `cleanup stopped: ${nmPath} is ${kindDesc} in the ${lane} lane`
2401
+ + `${changed ? ' (changed during cleanup)' : ''}\n${CLEANUP_OWNERSHIP_RULE}`;
2402
+ if (lane === 'tracked') {
2403
+ return stop([
2404
+ head,
2405
+ 'this node_modules is tracked: removing it by hand only creates drift that stops cleanup earlier — land its removal from MAIN, then re-run cleanup',
2406
+ destructiveRecovery({ slug, branch }),
2407
+ ].join('\n'));
2408
+ }
2409
+ const removal = kind === 'directory' ? `rm -rf -- ${shellQuoteArg(nmPath)}` : `rm -- ${shellQuoteArg(nmPath)}`;
2410
+ return stop([
2411
+ head,
2412
+ `remove it yourself, then re-run cleanup: ${removal}`,
2413
+ destructiveRecovery({ slug, branch }),
2414
+ ].join('\n'));
2415
+ };
2416
+
2330
2417
  const composeCleanupCommand = ({ slug, branch, abandon }) =>
2331
2418
  `cleanup ${shellQuoteArg(slug)}` +
2332
2419
  `${branch === `${DEFAULT_BRANCH_PREFIX}${slug}` ? '' : ` --branch ${shellQuoteArg(branch)}`}` +
@@ -2380,6 +2467,7 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
2380
2467
 
2381
2468
  const knownRoots = provisionKnownRoots(identity);
2382
2469
  let removalRoots = [];
2470
+ let nodeModulesExempt = false;
2383
2471
  if (!flags.abandon) {
2384
2472
  const mainHead = gitRead(git, ['rev-parse', 'HEAD'], root, 'cannot resolve main HEAD').stdout.trim();
2385
2473
  // Transfer verification excludes docs/ai and docs/plans; their tracked drift is checked separately before reset.
@@ -2422,6 +2510,16 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
2422
2510
  );
2423
2511
  }
2424
2512
 
2513
+ // The ownership gate (AD-069) runs BEFORE the inventories: it also catches tracked
2514
+ // node_modules and empty untracked directories git never lists. Clean-absent proceeds on
2515
+ // the legacy path with no post-reset ownership arm; the single exemption is re-proven
2516
+ // below, immediately before the irreversible remove.
2517
+ const ownership = nodeModulesSnapshot({ wtRoot: entry.path, mainRoot: root, git, fs });
2518
+ if (!nodeModulesExemptVerdict(ownership) && !(ownership.klass === 'absent' && ownership.lane !== 'tracked')) {
2519
+ throw composeNodeModulesStop({ snapshot: ownership, slug, branch });
2520
+ }
2521
+ nodeModulesExempt = nodeModulesExemptVerdict(ownership);
2522
+
2425
2523
  const untracked = untrackedPaths(git, entry.path);
2426
2524
  const foreign = [];
2427
2525
  for (const path of untracked) {
@@ -2435,7 +2533,7 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
2435
2533
  destructiveRecovery({ slug, branch }),
2436
2534
  );
2437
2535
  }
2438
- const foreignIgnored = foreignIgnoredPaths({ root: entry.path, git, identity, fs });
2536
+ const foreignIgnored = foreignIgnoredPaths({ root: entry.path, git, identity, fs, exemptNodeModules: nodeModulesExempt });
2439
2537
  if (foreignIgnored.length > 0) {
2440
2538
  throw stop(`foreign ignored content requires --abandon:\n${foreignIgnored.join('\n')}`);
2441
2539
  }
@@ -2455,6 +2553,20 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
2455
2553
  for (const rel of removalRoots) {
2456
2554
  removeNodeNoFollow({ root: entry.path, abs: join(entry.path, rel), fs, label: rel });
2457
2555
  }
2556
+ if (nodeModulesExempt) {
2557
+ // The authoritative re-proof (AD-069): the verdict authorizing the irreversible remove
2558
+ // postdates the last tree-mutating operation. Strict equality with the exempt state —
2559
+ // never re-authorization: ANY deviation fails closed with no remove call.
2560
+ const revalidated = nodeModulesSnapshot({ wtRoot: entry.path, mainRoot: root, git, fs });
2561
+ if (!nodeModulesExemptVerdict(revalidated)) {
2562
+ if (revalidated.klass === 'absent' && revalidated.lane !== 'tracked') {
2563
+ throw stop(
2564
+ `cleanup stopped: ${revalidated.nmPath} changed during cleanup (now absent) — re-run cleanup\n${CLEANUP_OWNERSHIP_RULE}`,
2565
+ );
2566
+ }
2567
+ throw composeNodeModulesStop({ snapshot: revalidated, slug, branch, changed: true });
2568
+ }
2569
+ }
2458
2570
  }
2459
2571
 
2460
2572
  removeWorktree({ root, entry, branch, abandon: flags.abandon, git });