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.
- package/README.md +5 -0
- package/lib/cli.js +51 -6
- package/lib/copy.js +56 -10
- package/lib/mux-setup.js +1 -0
- package/package.json +1 -1
- package/templates/cabinet/checklist-stats-schema.md +104 -0
- package/templates/cabinet/checkpoint-protocol.md +17 -5
- package/templates/cabinet/qa-dimensions-template.yaml +7 -0
- package/templates/cabinet/watchtower-contracts.md +38 -0
- package/templates/engagement/pib-db-patches/pib-db-lib.mjs +4 -1
- package/templates/hooks/action-completion-gate.sh +17 -0
- package/templates/hooks/watchtower-session-start.sh +80 -5
- package/templates/mux/__tests__/claude-carveout.fixture.sh +136 -0
- package/templates/mux/__tests__/claude-carveout.test.mjs +38 -0
- package/templates/mux/__tests__/mux-fail-loud.fixture.sh +254 -0
- package/templates/mux/__tests__/mux-fail-loud.test.mjs +41 -0
- package/templates/mux/__tests__/worktree-dirty-check.fixture.sh +184 -0
- package/templates/mux/__tests__/worktree-dirty-check.test.mjs +35 -0
- package/templates/mux/bin/mux +212 -60
- package/templates/mux/config/worktree-cleanup.sh +55 -9
- package/templates/mux/config/worktree-dirty-check.sh +128 -0
- package/templates/mux/config/worktree-session-health.sh +62 -35
- package/templates/scripts/__tests__/qa-handoff-aging.e2e.test.mjs +108 -0
- package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +335 -0
- package/templates/scripts/__tests__/resolve-project.test.mjs +144 -0
- package/templates/scripts/__tests__/ring-state-ownership.test.mjs +228 -0
- package/templates/scripts/pib-db-lib.mjs +4 -1
- package/templates/scripts/pib-db.mjs +4 -1
- package/templates/scripts/validate-memory.mjs +6 -2
- package/templates/scripts/watchtower-build-context.mjs +12 -8
- package/templates/scripts/watchtower-lib.mjs +265 -2
- package/templates/scripts/watchtower-migrate-keys.mjs +305 -0
- package/templates/scripts/watchtower-queue.mjs +226 -1
- package/templates/scripts/watchtower-ring1.mjs +19 -3
- package/templates/scripts/watchtower-ring2.mjs +4 -2
- package/templates/scripts/watchtower-ring3-close.mjs +92 -88
- package/templates/skills/audit/SKILL.md +25 -6
- package/templates/skills/audit/phases/checklist-pruning.md +108 -0
- package/templates/skills/briefing/SKILL.md +12 -1
- package/templates/skills/cabinet/SKILL.md +2 -2
- package/templates/skills/collab-consultant/SKILL.md +1 -1
- package/templates/skills/debrief/SKILL.md +33 -3
- package/templates/skills/debrief/phases/checklist-feedback.md +10 -3
- package/templates/skills/debrief/phases/qa-handoff-sweep.md +78 -0
- package/templates/skills/engagement-create/SKILL.md +1 -1
- package/templates/skills/engagement-help/SKILL.md +1 -1
- package/templates/skills/execute/SKILL.md +1 -1
- package/templates/skills/execute/phases/post-impl-checklist.md +18 -0
- package/templates/skills/execute-group/SKILL.md +76 -24
- package/templates/skills/inbox/SKILL.md +30 -7
- package/templates/skills/orient/SKILL.md +100 -6
- package/templates/skills/orient/phases/checklist-status.md +12 -0
- package/templates/skills/plan/SKILL.md +14 -6
- package/templates/skills/qa-handoff/SKILL.md +132 -5
- package/templates/skills/session-handoff/SKILL.md +165 -0
- package/templates/skills/setup-accounts/SKILL.md +1 -1
- package/templates/skills/unwrap/SKILL.md +1 -1
- package/templates/skills/verify/SKILL.md +2 -2
- package/templates/skills/watchtower/SKILL.md +19 -1
- package/templates/watchtower/queue/items/item.json.schema +9 -0
- package/templates/workflows/deliberative-audit.js +3 -0
- package/templates/workflows/execute-group-complete.js +93 -16
- 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
|
|
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
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
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
|
|
32
|
-
#
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
49
|
-
#
|
|
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"
|
|
52
|
-
git -C "$wp" ls-files
|
|
53
|
-
git -C "$wp" update-index --no-assume-unchanged
|
|
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
|
|
58
|
-
#
|
|
59
|
-
#
|
|
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
|
-
|
|
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 (
|
|
74
|
-
# git-tracked files, restored from HEAD if a prior wipe
|
|
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/
|
|
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/
|
|
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
|
|
126
|
-
# copy.
|
|
127
|
-
# a file copy that could clobber
|
|
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=$(
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
|
+
});
|