create-claude-cabinet 0.44.0 → 0.45.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.
Files changed (63) hide show
  1. package/README.md +5 -0
  2. package/lib/cli.js +51 -6
  3. package/lib/copy.js +56 -10
  4. package/lib/mux-setup.js +1 -0
  5. package/package.json +1 -1
  6. package/templates/cabinet/checklist-stats-schema.md +104 -0
  7. package/templates/cabinet/checkpoint-protocol.md +17 -5
  8. package/templates/cabinet/qa-dimensions-template.yaml +7 -0
  9. package/templates/cabinet/watchtower-contracts.md +38 -0
  10. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +4 -1
  11. package/templates/hooks/action-completion-gate.sh +17 -0
  12. package/templates/hooks/watchtower-session-start.sh +80 -5
  13. package/templates/mux/__tests__/claude-carveout.fixture.sh +136 -0
  14. package/templates/mux/__tests__/claude-carveout.test.mjs +38 -0
  15. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +254 -0
  16. package/templates/mux/__tests__/mux-fail-loud.test.mjs +41 -0
  17. package/templates/mux/__tests__/worktree-dirty-check.fixture.sh +184 -0
  18. package/templates/mux/__tests__/worktree-dirty-check.test.mjs +35 -0
  19. package/templates/mux/bin/mux +212 -60
  20. package/templates/mux/config/worktree-cleanup.sh +55 -9
  21. package/templates/mux/config/worktree-dirty-check.sh +128 -0
  22. package/templates/mux/config/worktree-session-health.sh +62 -35
  23. package/templates/scripts/__tests__/qa-handoff-aging.e2e.test.mjs +108 -0
  24. package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +335 -0
  25. package/templates/scripts/__tests__/resolve-project.test.mjs +144 -0
  26. package/templates/scripts/__tests__/ring-state-ownership.test.mjs +228 -0
  27. package/templates/scripts/pib-db-lib.mjs +4 -1
  28. package/templates/scripts/pib-db.mjs +4 -1
  29. package/templates/scripts/validate-memory.mjs +6 -2
  30. package/templates/scripts/watchtower-build-context.mjs +12 -8
  31. package/templates/scripts/watchtower-lib.mjs +265 -2
  32. package/templates/scripts/watchtower-migrate-keys.mjs +305 -0
  33. package/templates/scripts/watchtower-queue.mjs +226 -1
  34. package/templates/scripts/watchtower-ring1.mjs +19 -3
  35. package/templates/scripts/watchtower-ring2.mjs +4 -2
  36. package/templates/scripts/watchtower-ring3-close.mjs +92 -88
  37. package/templates/skills/audit/SKILL.md +25 -6
  38. package/templates/skills/audit/phases/checklist-pruning.md +108 -0
  39. package/templates/skills/briefing/SKILL.md +12 -1
  40. package/templates/skills/cabinet/SKILL.md +2 -2
  41. package/templates/skills/collab-consultant/SKILL.md +1 -1
  42. package/templates/skills/debrief/SKILL.md +33 -3
  43. package/templates/skills/debrief/phases/checklist-feedback.md +10 -3
  44. package/templates/skills/debrief/phases/qa-handoff-sweep.md +78 -0
  45. package/templates/skills/engagement-create/SKILL.md +1 -1
  46. package/templates/skills/engagement-help/SKILL.md +1 -1
  47. package/templates/skills/execute/SKILL.md +1 -1
  48. package/templates/skills/execute/phases/post-impl-checklist.md +18 -0
  49. package/templates/skills/execute-group/SKILL.md +76 -24
  50. package/templates/skills/inbox/SKILL.md +30 -7
  51. package/templates/skills/orient/SKILL.md +100 -6
  52. package/templates/skills/orient/phases/checklist-status.md +12 -0
  53. package/templates/skills/plan/SKILL.md +14 -6
  54. package/templates/skills/qa-handoff/SKILL.md +132 -5
  55. package/templates/skills/session-handoff/SKILL.md +165 -0
  56. package/templates/skills/setup-accounts/SKILL.md +1 -1
  57. package/templates/skills/unwrap/SKILL.md +1 -1
  58. package/templates/skills/verify/SKILL.md +2 -2
  59. package/templates/skills/watchtower/SKILL.md +19 -1
  60. package/templates/watchtower/queue/items/item.json.schema +9 -0
  61. package/templates/workflows/deliberative-audit.js +3 -0
  62. package/templates/workflows/execute-group-complete.js +93 -16
  63. package/templates/workflows/execute-group-implement.js +164 -19
@@ -0,0 +1,128 @@
1
+ #!/bin/bash
2
+ # worktree-dirty-check.sh — shared dirty-worktree detection.
3
+ #
4
+ # Single source of truth for "does this worktree have real changes?"
5
+ # (one implementation, callers delegate — see .claude/rules/maintainability.md).
6
+ #
7
+ # Callers:
8
+ # - bin/mux worktree_cleanup() (interactive `mux worktree cleanup`)
9
+ # - worktree-cleanup.sh (fire-and-forget tmux pane-exited hook,
10
+ # which SILENTLY DELETES clean worktrees)
11
+ #
12
+ # Usage:
13
+ # worktree-dirty-check.sh <wt_path> <proj_path>
14
+ #
15
+ # stdout (single line, space-separated key=value):
16
+ # commits=<n|?> uncommitted=<n|?> verdict=<clean|dirty>
17
+ #
18
+ # Exit code: 0 whenever a verdict line was printed (even verdict=dirty), so
19
+ # `set -e` callers can command-substitute safely; 2 only on usage error.
20
+ #
21
+ # FAIL-DIRTY SEMANTICS: any count that cannot be determined is reported as
22
+ # "?" and forces verdict=dirty. A worktree is never classified clean — and
23
+ # therefore never deleted — on the strength of a failed check. Callers must
24
+ # apply the same rule when this helper itself is missing or its output is
25
+ # unparseable: degrade to dirty, never abort silently and never delete.
26
+ #
27
+ # EXCLUSION CONTRACT (shell side) ---------------------------------------------
28
+ # The dirty verdict ignores ONLY known CC-infrastructure churn that mux
29
+ # itself manufactures in every worktree:
30
+ #
31
+ # 1. Untracked .claude/ infra — mux copies .claude/ (skills, agents,
32
+ # settings) into each worktree; git-exclude rules hide most of it, but
33
+ # an untracked `.claude/` dir or stray infra file is still churn.
34
+ # EXCEPTION: untracked files under any top-level .claude/ entry that
35
+ # holds TRACKED files (plans/, methodology/, rules/, cabinet/, …) are
36
+ # AUTHORED PROJECT RECORD (.claude/rules/artifacts-of-thought.md) and
37
+ # always count as dirty. The authored set is derived from the worktree's
38
+ # index at run time; if it can't be read, the static plans/methodology
39
+ # floor applies (fail-dirty, never fail-clean).
40
+ #
41
+ # 2. .mcp.json / .claudeignore typechange (` T`) — mux replaces these
42
+ # tracked files with symlinks to the main repo. A genuinely MODIFIED
43
+ # tracked .mcp.json (` M`) is real work and counts as dirty (the old
44
+ # cleanup-hook regex matched any line containing `.mcp.json`, which let
45
+ # a worktree with a real .mcp.json edit be silently deleted).
46
+ #
47
+ # 3. Untracked `node_modules` SYMLINK churn — mux symlinks node_modules
48
+ # from the main checkout into each worktree (MCP servers need it). The
49
+ # project's `.gitignore` typically reads `node_modules/` (trailing
50
+ # slash), which matches only DIRECTORIES — a symlink shows up as
51
+ # `?? node_modules` and made every mux-created worktree permanently
52
+ # dirty, so auto-cleanup never fired (found live 2026-06-11, QA pass).
53
+ # Excluding any untracked node_modules entry is safe: its contents are
54
+ # never project record, so there is no work to lose.
55
+ #
56
+ # 4. Lockfile MODIFICATION — package-lock.json (and yarn/pnpm/bun lockfiles)
57
+ # show modified in worktrees because npm rewrites `file:` dependency
58
+ # paths to the worktree's directory depth (memory:
59
+ # lesson_worktree_lockfile_false_dirty). Only modified-in-place lockfile
60
+ # lines are excluded; added/deleted/renamed lockfiles count as dirty.
61
+ # TRADEOFF (documented, accepted): an intentional, uncommitted,
62
+ # lockfile-ONLY edit is mechanically indistinguishable from this churn
63
+ # and classifies as clean (deletable). In practice intentional dependency
64
+ # changes also touch package.json (which counts as dirty); without this
65
+ # exclusion every npm-touching worktree is immortal.
66
+ #
67
+ # JS-SIDE PARALLEL: the watchtower ring scripts (.mjs) carry their own
68
+ # porcelain dirty checks with their own exclusion sets. They are a parallel
69
+ # implementation owned by a separate plan and intentionally NOT consolidated
70
+ # here yet. When the JS side converges, this header is the canonical
71
+ # exclusion contract to cite.
72
+ #
73
+ # Matching is regex against whole porcelain lines — never positional column
74
+ # slicing (memory: lesson_worktree_dirty_detection_artifacts).
75
+ # -----------------------------------------------------------------------------
76
+
77
+ set -uo pipefail # deliberately NOT -e: every failure degrades to fail-DIRTY
78
+
79
+ wt_path="${1:-}"
80
+ proj_path="${2:-}"
81
+ if [[ -z "$wt_path" || -z "$proj_path" ]]; then
82
+ echo "usage: worktree-dirty-check.sh <wt_path> <proj_path>" >&2
83
+ exit 2
84
+ fi
85
+
86
+ # Known CC-infrastructure churn (see EXCLUSION CONTRACT above).
87
+ INFRA_CHURN_RE='^\?\? \.claude/?$|^\?\? \.claude/|^.T \.mcp\.json$|^.T \.claudeignore$|^\?\? node_modules/?$|^[M ][M ] (.*/)?(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb)$'
88
+ # Authored records re-included after the untracked-.claude exclusion
89
+ # (ERE has no negative lookahead, so this is a second counting pass).
90
+ # Any top-level .claude/ entry holding tracked files is authored; the static
91
+ # plans/methodology pair is the fallback floor when the index can't be read.
92
+ AUTHORED_RECORD_RE='^\?\? \.claude/(plans|methodology)/'
93
+ if [[ -n "$wt_path" ]] && authored_dirs=$(git -C "$wt_path" ls-files '.claude/' 2>/dev/null \
94
+ | cut -d/ -f2 | sort -u | paste -sd'|' -) && [[ -n "$authored_dirs" ]]; then
95
+ AUTHORED_RECORD_RE="^\\?\\? \\.claude/(${authored_dirs})(/|\$)"
96
+ fi
97
+
98
+ commits="?"
99
+ uncommitted="?"
100
+
101
+ if [[ -d "$wt_path" ]] && git -C "$wt_path" rev-parse --git-dir >/dev/null 2>&1; then
102
+ main_head=$(git -C "$proj_path" rev-parse HEAD 2>/dev/null) || main_head=""
103
+ if [[ -n "$main_head" ]]; then
104
+ base=$(git -C "$wt_path" merge-base HEAD "$main_head" 2>/dev/null) || base=""
105
+ if [[ -n "$base" ]]; then
106
+ c=$(git -C "$wt_path" rev-list --count "${base}..HEAD" 2>/dev/null) && commits="$c"
107
+ fi
108
+ fi
109
+ # --untracked-files=all: without it, a fully-untracked .claude/ collapses
110
+ # to a single '?? .claude/' line, hiding any authored .claude/plans/ or
111
+ # .claude/methodology/ doc inside it from AUTHORED_RECORD_RE — a brand-new
112
+ # uncommitted design record would be invisible and the worktree deletable.
113
+ if porcelain=$(git -C "$wt_path" status --porcelain --untracked-files=all 2>/dev/null); then
114
+ if [[ -z "$porcelain" ]]; then
115
+ uncommitted=0
116
+ else
117
+ non_churn=$(grep -cvE "$INFRA_CHURN_RE" <<<"$porcelain") || true
118
+ authored=$(grep -cE "$AUTHORED_RECORD_RE" <<<"$porcelain") || true
119
+ uncommitted=$(( ${non_churn:-0} + ${authored:-0} ))
120
+ fi
121
+ fi
122
+ fi
123
+
124
+ verdict="dirty"
125
+ [[ "$commits" == "0" && "$uncommitted" == "0" ]] && verdict="clean"
126
+
127
+ printf 'commits=%s uncommitted=%s verdict=%s\n' "$commits" "$uncommitted" "$verdict"
128
+ exit 0
@@ -1,15 +1,20 @@
1
1
  #!/bin/bash
2
2
  # Worktree health check — single source of truth.
3
3
  #
4
- # Called two ways:
4
+ # Called three ways:
5
5
  # 1. CC SessionStart hook (no args) — uses $PWD, exits silently outside worktrees
6
6
  # 2. mux worktree health (args: proj_path wt_path) — checks a specific worktree
7
+ # 3. mux worktree refresh (args: proj_path wt_path --refresh) — forces an
8
+ # infra re-sync even when nothing is stale. Refresh delegates here so
9
+ # the authored-record carve-out (sync_claude_infra) lives in exactly
10
+ # one place — never a wholesale rm -rf of .claude/.
7
11
  #
8
12
  # Self-heals all fixable issues. Positive confirmation for every check.
9
13
  # Exit codes: 0 = healthy (possibly after auto-fix), 1 = unfixable issue
10
14
 
11
15
  proj_path="${1:-}"
12
16
  wt_path="${2:-}"
17
+ force_refresh="${3:-}"
13
18
 
14
19
  if [[ -z "$wt_path" ]]; then
15
20
  # SessionStart mode — detect from environment
@@ -22,14 +27,18 @@ fi
22
27
  passes=()
23
28
  fails=()
24
29
 
25
- # .claude/plans/ and .claude/methodology/ are authored project record — real
26
- # work that must commit from a worktree. Everything else under .claude/ is
27
- # regenerated infra (skills, agents, settings) that's copied per worktree and
28
- # must NOT churn or commit. The helpers below keep that line. See
30
+ # Any git-TRACKED file under .claude/ is authored project record — plans,
31
+ # methodology, rules, cabinet docs, anything committed real work that must
32
+ # commit from a worktree. Only gitignored infra (installed skills, agents,
33
+ # settings copies) is disposable: copied per worktree, refreshed from main,
34
+ # and hidden from status. The helpers below keep that line. See
29
35
  # .claude/rules/artifacts-of-thought.md.
30
36
 
31
- # Hide .claude/ infra from the worktree's git status, but keep authored records
32
- # visible so new design docs / plan specs created in a worktree show and commit.
37
+ # Hide untracked .claude/ infra from the worktree's git status, but negate
38
+ # every top-level entry that holds tracked (authored) files so new docs
39
+ # created beside them stay visible and commit. The managed lines are rebuilt
40
+ # on every run, so the negation set follows what's actually committed (and
41
+ # the old static plans/methodology pair migrates automatically).
33
42
  exclude_claude_dir() {
34
43
  local wp="$1" gd
35
44
  gd=$(git -C "$wp" rev-parse --git-dir 2>/dev/null) || return 0
@@ -37,47 +46,62 @@ exclude_claude_dir() {
37
46
  mkdir -p "$gd/info"
38
47
  local ex="$gd/info/exclude"
39
48
  touch "$ex"
40
- # Migrate the old wholesale rule, which hid authored records too.
41
- if grep -qxF '.claude/' "$ex" 2>/dev/null; then
42
- sed -i.bak '/^\.claude\/$/d' "$ex" && rm -f "$ex.bak"
43
- fi
44
- grep -qxF '.claude/*' "$ex" 2>/dev/null \
45
- || printf '%s\n' '.claude/*' '!.claude/plans/' '!.claude/methodology/' >> "$ex"
49
+ { grep -vE '^(\.claude/\*?|!\.claude/.*)$' "$ex" 2>/dev/null || true; } > "$ex.tmp"
50
+ echo '.claude/*' >> "$ex.tmp"
51
+ git -C "$wp" ls-files '.claude/' 2>/dev/null \
52
+ | awk -F/ 'NF>2 {print "!.claude/" $2 "/"} NF==2 {print "!" $0}' \
53
+ | sort -u >> "$ex.tmp"
54
+ mv "$ex.tmp" "$ex"
46
55
  }
47
56
 
48
- # Clear git's assume-unchanged bit on authored records so worktree edits to
49
- # plans/methodology actually commit (mux historically set it wholesale).
57
+ # Clear git's assume-unchanged bit on every tracked .claude/ file tracked
58
+ # means authored, and authored work must commit from the worktree (mux
59
+ # historically froze all of .claude/, then everything outside plans/ and
60
+ # methodology/; both left edits to tracked files invisible to git).
50
61
  unprotect_authored_records() {
51
- local wp="$1" f
52
- git -C "$wp" ls-files .claude/plans .claude/methodology 2>/dev/null | while IFS= read -r f; do
53
- git -C "$wp" update-index --no-assume-unchanged "$f" 2>/dev/null || true
54
- done
62
+ local wp="$1"
63
+ git -C "$wp" ls-files -z '.claude/' 2>/dev/null \
64
+ | xargs -0 git -C "$wp" update-index --no-assume-unchanged -- 2>/dev/null || true
55
65
  }
56
66
 
57
- # Refresh .claude/ INFRA from main, one top-level entry at a time, never
58
- # touching the authored-record dirs (preserve the worktree's own tracked
59
- # plans/ and methodology/, including uncommitted edits).
67
+ # Refresh .claude/ INFRA from main without touching authored record. Authored
68
+ # = tracked in main's index; it syncs via git (merge main), never a file copy
69
+ # that could clobber uncommitted worktree edits. A pure-infra entry (no
70
+ # tracked files) is replaced wholesale so deletions in main reconcile; an
71
+ # entry holding tracked files gets a per-file copy of only the untracked
72
+ # infra inside it — no deletes, tracked files never overwritten.
60
73
  sync_claude_infra() {
61
- local sp="$1" wp="$2" entry name
74
+ local sp="$1" wp="$2" entry name rel entry_tracked
62
75
  mkdir -p "$wp/.claude"
63
76
  for entry in "$sp/.claude"/*; do
64
77
  [[ -e "$entry" ]] || continue
65
78
  name=$(basename "$entry")
66
- [[ "$name" == "plans" || "$name" == "methodology" ]] && continue
79
+ entry_tracked=$(git -C "$sp" ls-files ".claude/$name" 2>/dev/null)
80
+ if [[ -n "$entry_tracked" ]]; then
81
+ if [[ -d "$entry" ]]; then
82
+ (cd "$sp" && find ".claude/$name" -type f 2>/dev/null) | while IFS= read -r rel; do
83
+ grep -qxF "$rel" <<<"$entry_tracked" && continue
84
+ mkdir -p "$wp/$(dirname "$rel")"
85
+ cp "$sp/$rel" "$wp/$rel"
86
+ done
87
+ fi
88
+ continue
89
+ fi
67
90
  rm -rf "$wp/.claude/$name"
68
91
  cp -R "$entry" "$wp/.claude/$name"
69
92
  done
70
93
  }
71
94
 
72
95
  # 1. .claude/ must be a local copy, not a symlink. Infra is refreshed from
73
- # main; authored records (plans/methodology) stay as the worktree's own
74
- # git-tracked files, restored from HEAD if a prior wipe lost them.
96
+ # main; authored records (all tracked .claude/ files) stay as the
97
+ # worktree's own git-tracked files, restored from HEAD if a prior wipe
98
+ # lost them.
75
99
  if [[ -L "$wt_path/.claude" ]]; then
76
100
  if [[ -d "$proj_path/.claude" ]]; then
77
101
  rm -f "$wt_path/.claude"
78
102
  mkdir -p "$wt_path/.claude"
79
103
  unprotect_authored_records "$wt_path"
80
- git -C "$wt_path" checkout -- .claude/plans .claude/methodology 2>/dev/null || true
104
+ git -C "$wt_path" checkout -- .claude/ 2>/dev/null || true
81
105
  sync_claude_infra "$proj_path" "$wt_path"
82
106
  exclude_claude_dir "$wt_path"
83
107
  passes+=("✓ .claude/ auto-fixed: was symlink, now local copy (path isolation restored)")
@@ -92,7 +116,7 @@ elif [[ -d "$wt_path/.claude" ]]; then
92
116
  elif [[ -d "$proj_path/.claude" ]]; then
93
117
  mkdir -p "$wt_path/.claude"
94
118
  unprotect_authored_records "$wt_path"
95
- git -C "$wt_path" checkout -- .claude/plans .claude/methodology 2>/dev/null || true
119
+ git -C "$wt_path" checkout -- .claude/ 2>/dev/null || true
96
120
  sync_claude_infra "$proj_path" "$wt_path"
97
121
  exclude_claude_dir "$wt_path"
98
122
  passes+=("✓ .claude/ auto-fixed: was missing, copied from main repo")
@@ -122,17 +146,20 @@ else
122
146
  fails+=("⚠ Identity link missing and main project identity not found")
123
147
  fi
124
148
 
125
- # 4. .claude/ INFRA freshness — any infra file in main newer than the worktree
126
- # copy. Authored records are excluded: they sync via git (merge main), never
127
- # a file copy that could clobber uncommitted worktree edits.
149
+ # 4. .claude/ INFRA freshness — any untracked (infra) file in main newer than
150
+ # the worktree copy. Tracked files are excluded: they're authored record,
151
+ # synced via git (merge main), never a file copy that could clobber
152
+ # uncommitted worktree edits.
128
153
  if [[ -d "$proj_path/.claude" ]] && [[ -d "$wt_path/.claude" ]] && [[ ! -L "$wt_path/.claude" ]]; then
129
- stale_count=$(find "$proj_path/.claude" -type f -newer "$wt_path/.claude" \
130
- -not -path '*/.claude/plans/*' -not -path '*/.claude/methodology/*' 2>/dev/null | wc -l | tr -d ' ')
131
- if [[ "$stale_count" -gt 0 ]]; then
154
+ stale_count=$( (cd "$proj_path" && find .claude -type f -newer "$wt_path/.claude" 2>/dev/null) \
155
+ | while IFS= read -r rel; do
156
+ git -C "$proj_path" ls-files --error-unmatch "$rel" >/dev/null 2>&1 || echo "$rel"
157
+ done | wc -l | tr -d ' ')
158
+ if [[ "$stale_count" -gt 0 || "$force_refresh" == "--refresh" ]]; then
132
159
  sync_claude_infra "$proj_path" "$wt_path"
133
160
  exclude_claude_dir "$wt_path"
134
161
  unprotect_authored_records "$wt_path"
135
- passes+=("✓ .claude/ infra auto-refreshed ($stale_count file(s) updated; records preserved)")
162
+ passes+=("✓ .claude/ infra refreshed ($stale_count stale file(s); authored records preserved)")
136
163
  else
137
164
  passes+=("✓ .claude/ current with main repo")
138
165
  fi
@@ -0,0 +1,108 @@
1
+ // Ring 2 [AGING] fall-through — end-to-end against escalateQueueItems.
2
+ //
3
+ // The recipient gate exempts qa-handoff items from expiry at every terminal
4
+ // (expireItem THROWS on them; runExpiry skips them); ring2's escalation loop
5
+ // must route a 30d+ qa-handoff item to the [AGING] title path instead of
6
+ // expiring it. This is the e2e the gate handoff confessed as verified-by-
7
+ // reading-only (dec-51a67f01 / act:d20df77d).
8
+ //
9
+ // True end-to-end: spawns `watchtower-ring2.mjs --fast` as a subprocess
10
+ // against a hermetic WATCHTOWER_DIR (ANTHROPIC_API_KEY stripped — the
11
+ // enrichment phase degrades via its phase-level catch; escalation is pure
12
+ // local). Differential controls prove the test is sensitive, not vacuous:
13
+ // an equally-aged non-qa item DOES expire, and a fresh qa-handoff item is
14
+ // untouched. QA_AGING_RING2 overrides the script under test for mutation
15
+ // runs (red-then-green verification).
16
+
17
+ import { test } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
20
+ import { join, dirname } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { tmpdir } from 'node:os';
23
+ import { spawnSync } from 'node:child_process';
24
+
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+ const RING2 = process.env.QA_AGING_RING2 || join(here, '..', 'watchtower-ring2.mjs');
27
+
28
+ const root = mkdtempSync(join(tmpdir(), 'qa-aging-'));
29
+ // Set BEFORE the dynamic import — the lib reads WATCHTOWER_DIR into a module
30
+ // const at load; a static import would silently target the LIVE inbox.
31
+ process.env.WATCHTOWER_DIR = root;
32
+ for (const d of [join('queue', 'items'), 'state', 'logs']) {
33
+ mkdirSync(join(root, d), { recursive: true });
34
+ }
35
+ writeFileSync(join(root, 'config.json'), '{"schema_version":1,"projects":[]}\n');
36
+
37
+ const q = await import('../watchtower-queue.mjs');
38
+
39
+ test.after(() => rmSync(root, { recursive: true, force: true }));
40
+
41
+ const itemFile = (id) => join(root, 'queue', 'items', `${id}.json`);
42
+ const readBack = (id) => JSON.parse(readFileSync(itemFile(id), 'utf8'));
43
+
44
+ function makeItem(category, title, agedDays) {
45
+ // createItem returns the new item's id (a string), not the item object.
46
+ const id = q.createItem({
47
+ project: 'test-proj',
48
+ project_path: '/tmp/test-proj',
49
+ category,
50
+ title,
51
+ summary: 'aging e2e fixture',
52
+ context_anchor: 'git show abc1234',
53
+ });
54
+ if (agedDays > 0) {
55
+ const j = readBack(id);
56
+ j.filed_at = new Date(Date.now() - agedDays * 24 * 60 * 60 * 1000).toISOString();
57
+ writeFileSync(itemFile(id), JSON.stringify(j, null, 2) + '\n');
58
+ }
59
+ return id;
60
+ }
61
+
62
+ function runRing2Fast() {
63
+ const env = { ...process.env, WATCHTOWER_DIR: root };
64
+ delete env.ANTHROPIC_API_KEY; // enrichment must degrade, never be exercised
65
+ return spawnSync(process.execPath, [RING2, '--fast'], {
66
+ env,
67
+ encoding: 'utf8',
68
+ timeout: 60_000,
69
+ });
70
+ }
71
+
72
+ test('aged qa-handoff gets [AGING] and never expires; aged non-qa expires; fresh qa untouched; re-run idempotent', () => {
73
+ const agedQa = makeItem('qa-handoff', 'QA handoff: aged merge', 31);
74
+ const agedOther = makeItem('knowledge-extraction', 'old knowledge', 31);
75
+ const freshQa = makeItem('qa-handoff', 'QA handoff: fresh merge', 0);
76
+
77
+ const run = runRing2Fast();
78
+ assert.equal(
79
+ run.status, 0,
80
+ `ring2 --fast exited ${run.status}\nstdout: ${run.stdout}\nstderr: ${run.stderr}`
81
+ );
82
+ // ring2 exits 0 even on a fatal setup error (the error lands in ring
83
+ // health) — assert on health too, or a config-rejected run that touched
84
+ // nothing would masquerade as green.
85
+ const health = JSON.parse(readFileSync(join(root, 'state', 'ring2-fast-health.json'), 'utf8'));
86
+ assert.equal(health.status, 'success', `ring2 health: ${health.status} (${health.error})`);
87
+
88
+ const qa = readBack(agedQa);
89
+ assert.equal(qa.status, 'pending', 'aged qa-handoff must NOT expire (QA debt stays surfaced)');
90
+ assert.ok(qa.title.startsWith('[AGING] '), `aged qa-handoff gets the [AGING] prefix (got: ${qa.title})`);
91
+
92
+ // Differential control: same age, non-qa category — expiry path is alive,
93
+ // so the qa-handoff survival above is the exemption, not a dead loop.
94
+ const other = readBack(agedOther);
95
+ assert.equal(other.status, 'expired', 'equally-aged non-qa item expires');
96
+
97
+ const fresh = readBack(freshQa);
98
+ assert.equal(fresh.status, 'pending', 'fresh qa-handoff stays pending');
99
+ assert.ok(!fresh.title.startsWith('[AGING]'), 'fresh qa-handoff gets no prefix');
100
+
101
+ // Idempotency: a second tick must not stack prefixes.
102
+ const run2 = runRing2Fast();
103
+ assert.equal(run2.status, 0, `second ring2 run exited ${run2.status}\nstderr: ${run2.stderr}`);
104
+ assert.ok(
105
+ !readBack(agedQa).title.startsWith('[AGING] [AGING]'),
106
+ 'no double [AGING] prefix on re-run'
107
+ );
108
+ });