create-claude-cabinet 0.42.0 → 0.43.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 (28) hide show
  1. package/lib/cli.js +2 -9
  2. package/lib/engagement-setup.js +1 -1
  3. package/package.json +1 -1
  4. package/templates/engagement/pib-db-patches/pib-db-schema.sql +1 -1
  5. package/templates/mux/bin/mux +28 -10
  6. package/templates/mux/config/worktree-session-health.sh +66 -12
  7. package/templates/scripts/watchtower-build-context.mjs +4 -3
  8. package/templates/scripts/watchtower-lib.mjs +54 -0
  9. package/templates/scripts/watchtower-ring1.mjs +20 -0
  10. package/templates/scripts/watchtower-ring2.mjs +3 -2
  11. package/templates/scripts/watchtower-ring3-close.mjs +14 -6
  12. package/templates/scripts/watchtower-status.sh +11 -2
  13. package/templates/scripts/watchtower-validate.mjs +1 -0
  14. package/templates/skills/briefing/SKILL.md +5 -1
  15. package/templates/skills/debrief/phases/methodology-capture.md +13 -0
  16. package/templates/skills/inbox/SKILL.md +6 -0
  17. package/templates/skills/qa-handoff/SKILL.md +192 -0
  18. package/templates/skills/threads/SKILL.md +14 -8
  19. package/templates/skills/validate/phases/validators.md +34 -0
  20. package/templates/watchtower/queue/items/item.json.schema +2 -1
  21. package/templates/skills/decisions/SKILL.md +0 -13
  22. package/templates/skills/engagement/SKILL.md +0 -9
  23. package/templates/skills/engagement-add/SKILL.md +0 -7
  24. package/templates/skills/engagement-edit/SKILL.md +0 -7
  25. package/templates/skills/engagement-message/SKILL.md +0 -7
  26. package/templates/skills/engagement-progress/SKILL.md +0 -7
  27. package/templates/skills/engagement-status/SKILL.md +0 -7
  28. package/templates/skills/engagement-sync/SKILL.md +0 -7
package/lib/cli.js CHANGED
@@ -609,15 +609,8 @@ const MODULES = {
609
609
  templates: [
610
610
  'skills/collab-client',
611
611
  'skills/collab-consultant',
612
- 'skills/engagement',
613
- 'skills/engagement-progress',
614
612
  'skills/engagement-help',
615
- 'skills/engagement-message',
616
613
  'skills/engagement-create',
617
- 'skills/engagement-edit',
618
- 'skills/engagement-add',
619
- 'skills/engagement-status',
620
- 'skills/engagement-sync',
621
614
  'skills/setup-accounts',
622
615
  'skills/guide',
623
616
  'engagement',
@@ -635,7 +628,6 @@ const MODULES = {
635
628
  'scripts/watchtower-lib.mjs',
636
629
  'scripts/watchtower-queue.mjs',
637
630
  'skills/inbox',
638
- 'skills/decisions',
639
631
  'hooks/watchtower-session-start.sh',
640
632
  'scripts/watchtower-build-context.mjs',
641
633
  'scripts/watchtower-ring1.mjs',
@@ -657,6 +649,7 @@ const MODULES = {
657
649
  'scripts/watchtower-status.sh',
658
650
  'skills/briefing',
659
651
  'skills/threads',
652
+ 'skills/qa-handoff',
660
653
  ],
661
654
  },
662
655
  mux: {
@@ -666,7 +659,7 @@ const MODULES = {
666
659
  default: false,
667
660
  lean: false,
668
661
  postInstall: 'mux-setup',
669
- templates: ['skills/orient/phases/dx-captures.md'],
662
+ templates: ['skills/orient/phases/dx-captures.md', 'skills/dx-feedback'],
670
663
  },
671
664
  'engagement-server': {
672
665
  name: 'Engagement Server',
@@ -177,7 +177,7 @@ function setupEngagement({ dryRun, projectDir } = {}) {
177
177
  target_fid TEXT,
178
178
  packet_id TEXT,
179
179
  kind TEXT NOT NULL
180
- CHECK(kind IN ('client_feedback','status_push','delegation','approval','note','packet_sent')),
180
+ CHECK(kind IN ('client_feedback','status_push','delegation','approval','note','packet_sent','packet_opened')),
181
181
  author TEXT NOT NULL,
182
182
  verdict TEXT CHECK(verdict IS NULL OR verdict IN ('approve','object','comment','none')),
183
183
  body TEXT CHECK(body IS NULL OR length(body) <= 10000),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "Claude Cabinet — opinionated process scaffolding for Claude Code projects",
5
5
  "bin": {
6
6
  "create-claude-cabinet": "bin/create-claude-cabinet.js"
@@ -114,7 +114,7 @@ CREATE TABLE IF NOT EXISTS engagement_events (
114
114
  target_fid TEXT,
115
115
  packet_id TEXT,
116
116
  kind TEXT NOT NULL
117
- CHECK(kind IN ('client_feedback','status_push','delegation','approval','note','packet_sent')),
117
+ CHECK(kind IN ('client_feedback','status_push','delegation','approval','note','packet_sent','packet_opened')),
118
118
  author TEXT NOT NULL,
119
119
  verdict TEXT CHECK(verdict IS NULL OR verdict IN ('approve','object','comment','none')),
120
120
  body TEXT CHECK(body IS NULL OR length(body) <= 10000),
@@ -227,19 +227,37 @@ create_worktree() {
227
227
  }
228
228
  }
229
229
 
230
- # Copy .claude/ into worktree so CC sees worktree-local paths in its
231
- # system context. A symlink here causes CC to resolve through to the
232
- # main repo path, leaking it into every Read/Edit/Write call.
233
- git -C "$wt_path" ls-files .claude/ 2>/dev/null | while IFS= read -r f; do
234
- git -C "$wt_path" update-index --assume-unchanged "$f" 2>/dev/null || true
235
- done
230
+ # Copy .claude/ infra into the worktree so CC sees worktree-local paths in
231
+ # its system context (a symlink makes CC resolve through to the main repo
232
+ # path, leaking it into every Read/Edit/Write). EXCEPT the authored project
233
+ # record .claude/plans/ and .claude/methodology/ which is real work that
234
+ # must commit from the worktree. Those stay as normal git-tracked files,
235
+ # left exactly as `git worktree add` checked them out from HEAD. See
236
+ # .claude/rules/artifacts-of-thought.md.
237
+ git -C "$wt_path" ls-files .claude/ 2>/dev/null \
238
+ | grep -vE '^\.claude/(plans|methodology)/' \
239
+ | while IFS= read -r f; do
240
+ git -C "$wt_path" update-index --assume-unchanged "$f" 2>/dev/null || true
241
+ done
236
242
  if [[ -d "$proj_path/.claude" ]]; then
237
- rm -rf "$wt_path/.claude"
238
- cp -R "$proj_path/.claude" "$wt_path/.claude"
239
- # Exclude copied .claude/ from worktree's git status (it's infra, not work)
243
+ # Refresh infra subdirs from main, one top-level entry at a time, never
244
+ # the authored-record dirs (leave the worktree's HEAD-checked-out copies).
245
+ local entry name
246
+ for entry in "$proj_path"/.claude/*; do
247
+ [[ -e "$entry" ]] || continue
248
+ name=$(basename "$entry")
249
+ [[ "$name" == "plans" || "$name" == "methodology" ]] && continue
250
+ rm -rf "$wt_path/.claude/$name"
251
+ cp -R "$entry" "$wt_path/.claude/$name"
252
+ done
253
+ # Hide infra from the worktree's git status, but keep authored records
254
+ # visible so new design docs created in a worktree show up and commit.
240
255
  local wt_gitdir
241
256
  wt_gitdir=$(git -C "$wt_path" rev-parse --git-dir 2>/dev/null)
242
- [[ -n "$wt_gitdir" ]] && mkdir -p "$wt_gitdir/info" && echo '.claude/' >> "$wt_gitdir/info/exclude"
257
+ if [[ -n "$wt_gitdir" ]]; then
258
+ mkdir -p "$wt_gitdir/info"
259
+ { echo '.claude/*'; echo '!.claude/plans/'; echo '!.claude/methodology/'; } >> "$wt_gitdir/info/exclude"
260
+ fi
243
261
  fi
244
262
  for f in .mcp.json .claudeignore; do
245
263
  [[ -f "$proj_path/$f" ]] && ln -sf "$proj_path/$f" "$wt_path/$f"
@@ -22,27 +22,78 @@ fi
22
22
  passes=()
23
23
  fails=()
24
24
 
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
29
+ # .claude/rules/artifacts-of-thought.md.
30
+
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.
25
33
  exclude_claude_dir() {
26
- local wp="$1"
27
- local gd
28
- gd=$(git -C "$wp" rev-parse --git-dir 2>/dev/null)
29
- [[ -n "$gd" ]] && mkdir -p "$gd/info" && grep -q '^\.claude/$' "$gd/info/exclude" 2>/dev/null || echo '.claude/' >> "$gd/info/exclude"
34
+ local wp="$1" gd
35
+ gd=$(git -C "$wp" rev-parse --git-dir 2>/dev/null) || return 0
36
+ [[ -n "$gd" ]] || return 0
37
+ mkdir -p "$gd/info"
38
+ local ex="$gd/info/exclude"
39
+ 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"
46
+ }
47
+
48
+ # Clear git's assume-unchanged bit on authored records so worktree edits to
49
+ # plans/methodology actually commit (mux historically set it wholesale).
50
+ 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
30
55
  }
31
56
 
32
- # 1. .claude/ must be a local copy, not a symlink
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).
60
+ sync_claude_infra() {
61
+ local sp="$1" wp="$2" entry name
62
+ mkdir -p "$wp/.claude"
63
+ for entry in "$sp/.claude"/*; do
64
+ [[ -e "$entry" ]] || continue
65
+ name=$(basename "$entry")
66
+ [[ "$name" == "plans" || "$name" == "methodology" ]] && continue
67
+ rm -rf "$wp/.claude/$name"
68
+ cp -R "$entry" "$wp/.claude/$name"
69
+ done
70
+ }
71
+
72
+ # 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.
33
75
  if [[ -L "$wt_path/.claude" ]]; then
34
76
  if [[ -d "$proj_path/.claude" ]]; then
35
77
  rm -f "$wt_path/.claude"
36
- cp -R "$proj_path/.claude" "$wt_path/.claude"
78
+ mkdir -p "$wt_path/.claude"
79
+ unprotect_authored_records "$wt_path"
80
+ git -C "$wt_path" checkout -- .claude/plans .claude/methodology 2>/dev/null || true
81
+ sync_claude_infra "$proj_path" "$wt_path"
37
82
  exclude_claude_dir "$wt_path"
38
83
  passes+=("✓ .claude/ auto-fixed: was symlink, now local copy (path isolation restored)")
39
84
  else
40
85
  fails+=("⚠ .claude/ is a symlink and main repo has no .claude/ to copy from")
41
86
  fi
42
87
  elif [[ -d "$wt_path/.claude" ]]; then
88
+ # Heal existing worktrees: un-freeze authored records + migrate the exclude.
89
+ unprotect_authored_records "$wt_path"
90
+ exclude_claude_dir "$wt_path"
43
91
  passes+=("✓ .claude/ is a local copy (path isolation intact)")
44
92
  elif [[ -d "$proj_path/.claude" ]]; then
45
- cp -R "$proj_path/.claude" "$wt_path/.claude"
93
+ mkdir -p "$wt_path/.claude"
94
+ unprotect_authored_records "$wt_path"
95
+ git -C "$wt_path" checkout -- .claude/plans .claude/methodology 2>/dev/null || true
96
+ sync_claude_infra "$proj_path" "$wt_path"
46
97
  exclude_claude_dir "$wt_path"
47
98
  passes+=("✓ .claude/ auto-fixed: was missing, copied from main repo")
48
99
  else
@@ -71,14 +122,17 @@ else
71
122
  fails+=("⚠ Identity link missing and main project identity not found")
72
123
  fi
73
124
 
74
- # 4. .claude/ freshness — any file in main newer than the worktree copy
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.
75
128
  if [[ -d "$proj_path/.claude" ]] && [[ -d "$wt_path/.claude" ]] && [[ ! -L "$wt_path/.claude" ]]; then
76
- stale_count=$(find "$proj_path/.claude" -type f -newer "$wt_path/.claude" 2>/dev/null | wc -l | tr -d ' ')
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 ' ')
77
131
  if [[ "$stale_count" -gt 0 ]]; then
78
- rm -rf "$wt_path/.claude"
79
- cp -R "$proj_path/.claude" "$wt_path/.claude"
132
+ sync_claude_infra "$proj_path" "$wt_path"
80
133
  exclude_claude_dir "$wt_path"
81
- passes+=("✓ .claude/ auto-refreshed ($stale_count file(s) updated from main repo)")
134
+ unprotect_authored_records "$wt_path"
135
+ passes+=("✓ .claude/ infra auto-refreshed ($stale_count file(s) updated; records preserved)")
82
136
  else
83
137
  passes+=("✓ .claude/ current with main repo")
84
138
  fi
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { readFileSync, readdirSync, existsSync, statSync, mkdirSync } from 'fs';
14
14
  import { join, resolve, basename } from 'path';
15
+ import { currentCursor } from './watchtower-lib.mjs';
15
16
 
16
17
  const WATCHTOWER_DIR = process.env.WATCHTOWER_DIR
17
18
  || join(process.env.HOME, '.claude-cabinet', 'watchtower');
@@ -143,7 +144,7 @@ function renderFocalZoom(threads, projectSlug) {
143
144
  // Cursor level: primary thread for this project, full detail
144
145
  if (projectThreads.length > 0) {
145
146
  const primary = projectThreads[0];
146
- const c = primary.cursor || {};
147
+ const c = currentCursor(primary);
147
148
  lines.push(`**${primary.thread}** (primary)`);
148
149
  if (c.what) lines.push(` What: ${c.what}`);
149
150
  if (c.why) lines.push(` Why: ${c.why}`);
@@ -160,7 +161,7 @@ function renderFocalZoom(threads, projectSlug) {
160
161
 
161
162
  // Thread level: other project threads, one line each
162
163
  for (const t of projectThreads.slice(1)) {
163
- const what = t.cursor?.what || '';
164
+ const what = currentCursor(t).what || '';
164
165
  const age = t.last_updated?.slice(0, 10) || '?';
165
166
  lines.push(`${t.thread}: ${what} (${age})`);
166
167
  }
@@ -170,7 +171,7 @@ function renderFocalZoom(threads, projectSlug) {
170
171
  lines.push('');
171
172
  lines.push('Other active threads:');
172
173
  for (const t of otherThreads.slice(0, 5)) {
173
- const what = t.cursor?.what || '';
174
+ const what = currentCursor(t).what || '';
174
175
  const proj = t.sessions?.[t.sessions.length - 1]?.project || '?';
175
176
  lines.push(` ${t.thread} [${proj}]: ${what}`);
176
177
  }
@@ -66,6 +66,60 @@ export function slugify(text) {
66
66
  return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
67
67
  }
68
68
 
69
+ // ---------------------------------------------------------------------------
70
+ // Thread cursor history (schema v2)
71
+ // ---------------------------------------------------------------------------
72
+ // A thread's cursor is no longer a single overwritten object; it is an
73
+ // append-only `cursor_history` array of point-in-time snapshots, one per
74
+ // session that advanced the thread. Each entry is
75
+ // { date, session_id, cursor: { what, why, where_left_off, open_questions,
76
+ // next_steps } }
77
+ // The "current" cursor is always the last entry. Overwriting threw away the
78
+ // journey (symptom → diagnosis → solution → abstraction); the history keeps it.
79
+ // `cursor_history` is the thread's first sibling timeline — the QA-handoff
80
+ // protocol (.claude/plans/qa-handoff-protocol.md) adds a parallel
81
+ // point-in-time event sibling rather than nesting inside the cursor.
82
+
83
+ // migrateThreadCursor — convert a legacy (schema v1) thread object that has a
84
+ // single `cursor` field into the v2 `cursor_history` shape, in place.
85
+ // Idempotent: a thread already carrying `cursor_history` is left alone (beyond
86
+ // stripping any stale `cursor` field and bumping the version). The single
87
+ // migrated entry inherits the date/session_id of the thread's most recent
88
+ // session, since the legacy cursor reflected the latest understanding.
89
+ export function migrateThreadCursor(threadData) {
90
+ if (!threadData || typeof threadData !== 'object') return threadData;
91
+ if (threadData.schema_version === undefined) threadData.schema_version = 1;
92
+
93
+ if (Array.isArray(threadData.cursor_history)) {
94
+ delete threadData.cursor; // drop any stale legacy field
95
+ if (threadData.schema_version < 2) threadData.schema_version = 2;
96
+ return threadData;
97
+ }
98
+
99
+ const legacy = threadData.cursor;
100
+ const sessions = Array.isArray(threadData.sessions) ? threadData.sessions : [];
101
+ const last = sessions.length ? sessions[sessions.length - 1] : null;
102
+ threadData.cursor_history = legacy
103
+ ? [{
104
+ date: last?.date || (threadData.last_updated || '').slice(0, 10) || null,
105
+ session_id: last?.id || null,
106
+ cursor: legacy,
107
+ }]
108
+ : [];
109
+ delete threadData.cursor;
110
+ threadData.schema_version = 2;
111
+ return threadData;
112
+ }
113
+
114
+ // currentCursor — the most recent cursor snapshot for a thread. Reads the last
115
+ // `cursor_history` entry, falling back to a legacy `cursor` field so consumers
116
+ // stay correct against any un-migrated thread file. Always returns an object.
117
+ export function currentCursor(thread) {
118
+ const hist = thread?.cursor_history;
119
+ if (Array.isArray(hist) && hist.length) return hist[hist.length - 1].cursor || {};
120
+ return thread?.cursor || {};
121
+ }
122
+
69
123
  // ---------------------------------------------------------------------------
70
124
  // loadBetterSqlite3 — resolve better-sqlite3 from wherever it actually lives
71
125
  // ---------------------------------------------------------------------------
@@ -396,10 +396,30 @@ function countRealUncommitted(wtPath) {
396
396
  if (/(?:^|[\s/])yarn\.lock$/.test(l)) return false;
397
397
  if (/(?:^|[\s/])pnpm-lock\.yaml$/.test(l)) return false;
398
398
  if (/(?:^|[\s/])bun\.lockb$/.test(l)) return false;
399
+ if (isMainShadow(l, wtPath)) return false;
399
400
  return true;
400
401
  }).length;
401
402
  }
402
403
 
404
+ // A worktree whose HEAD predates a file committed to main shows that file as
405
+ // untracked ("?? path") even though main already owns it — a stale-worktree
406
+ // shadow, not work to lose. (The mux/devex false-alarm: scripts/skill-usage.mjs
407
+ // was added to main after devex branched, so it counted as a "real" uncommitted
408
+ // change and blocked the merged branch from ever auto-resolving.) Exclude an
409
+ // untracked file only when main holds the SAME path with byte-identical content
410
+ // — a genuinely new untracked file, or a modified shadow, still counts, so real
411
+ // work-to-lose still alarms.
412
+ function isMainShadow(porcelainLine, wtPath) {
413
+ const m = porcelainLine.match(/^\?\?\s+(.+)$/);
414
+ if (!m) return false;
415
+ let p = m[1].trim();
416
+ if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
417
+ const mainBlob = safeExec(`git rev-parse --verify --quiet "main:${p}"`, { cwd: wtPath });
418
+ if (!mainBlob) return false;
419
+ const wtBlob = safeExec(`git hash-object "${p}"`, { cwd: wtPath });
420
+ return !!wtBlob && wtBlob === mainBlob;
421
+ }
422
+
403
423
  // Does a worktree-unmerged item belong to this project? Can't trust
404
424
  // item.project_path alone — Ring 3 historically attributed items to the
405
425
  // worktree path rather than the project root. Resolve ownership through
@@ -1048,8 +1048,9 @@ async function absorbThreadSessions(config) {
1048
1048
  continue;
1049
1049
  }
1050
1050
 
1051
- // Prune old session detail files that are already captured in the thread cursor.
1052
- // The session entry in the thread's sessions array (with transcript pointer) survives.
1051
+ // Prune old session detail files that are already captured in the thread's
1052
+ // cursor_history. The session entry in the thread's sessions array (with
1053
+ // transcript pointer) survives.
1053
1054
  if (!thread.sessions || thread.sessions.length === 0) continue;
1054
1055
 
1055
1056
  const projects = config.projects || {};
@@ -34,6 +34,7 @@ import {
34
34
  atomicWrite, loadConfig, slugify,
35
35
  log as _log, logError as _logError,
36
36
  getWatchtowerDir, createItem, listPending, loadBetterSqlite3,
37
+ migrateThreadCursor, currentCursor,
37
38
  } from './watchtower-lib.mjs';
38
39
 
39
40
  const require = createRequire(import.meta.url);
@@ -398,7 +399,7 @@ async function threadCapture(compressed, projectSlug, sessionId, summary, transc
398
399
  if (thread.status === 'active') {
399
400
  existingThreads.push({
400
401
  id: thread.thread,
401
- what: thread.cursor?.what || '',
402
+ what: currentCursor(thread).what || '',
402
403
  });
403
404
  }
404
405
  } catch { /* skip malformed */ }
@@ -475,12 +476,19 @@ Rules:
475
476
  threadIds.push(threadSlug);
476
477
  const threadPath = join(threadsDir, `${threadSlug}.json`);
477
478
 
479
+ // One cursor snapshot per session that advanced this thread — appended,
480
+ // never overwritten (see migrateThreadCursor / cursor_history in
481
+ // watchtower-lib.mjs). The qa_handoff payload is a future SIBLING of this
482
+ // history, not a field inside the cursor (qa-handoff-protocol.md).
483
+ const cursorEntry = { date, session_id: sessionId, cursor: t.cursor };
484
+
478
485
  let threadData;
479
486
  if (existsSync(threadPath) && !t.is_new) {
480
487
  threadData = JSON.parse(readFileSync(threadPath, 'utf8'));
481
- // Heal pre-versioning thread files (watchtower-contracts.md §Schema Versioning)
482
- if (threadData.schema_version === undefined) threadData.schema_version = 1;
483
- threadData.cursor = t.cursor;
488
+ // Heal pre-versioning + legacy single-cursor files into cursor_history
489
+ // (watchtower-contracts.md §Schema Versioning).
490
+ migrateThreadCursor(threadData);
491
+ threadData.cursor_history.push(cursorEntry);
484
492
  if (t.display_name) threadData.display_name = t.display_name;
485
493
  threadData.last_updated = now;
486
494
  threadData.sessions.push({
@@ -493,10 +501,10 @@ Rules:
493
501
  });
494
502
  } else {
495
503
  threadData = {
496
- schema_version: 1,
504
+ schema_version: 2,
497
505
  thread: threadSlug,
498
506
  display_name: t.display_name || threadSlug,
499
- cursor: t.cursor,
507
+ cursor_history: [cursorEntry],
500
508
  sessions: [{
501
509
  id: sessionId,
502
510
  contribution: t.contribution || '',
@@ -21,6 +21,15 @@ json_field() {
21
21
  node -p "try{JSON.parse(require('fs').readFileSync('$file','utf8'))${field}}catch{}" 2>/dev/null
22
22
  }
23
23
 
24
+ # Read a field off a thread's CURRENT cursor (last cursor_history entry),
25
+ # falling back to a legacy single `cursor` field for un-migrated thread files.
26
+ # Mirrors currentCursor() in watchtower-lib.mjs. Returns '' when absent.
27
+ current_cursor_field() {
28
+ local file="$1" sub="$2"
29
+ [ -f "$file" ] || return 1
30
+ node -p "try{const t=JSON.parse(require('fs').readFileSync('$file','utf8'));const h=t.cursor_history;const c=(Array.isArray(h)&&h.length?h[h.length-1].cursor:t.cursor)||{};c['$sub']??''}catch{}" 2>/dev/null
31
+ }
32
+
24
33
  ts_to_epoch() {
25
34
  local ts="${1%%.*}"
26
35
  ts="${ts%%Z}"
@@ -226,9 +235,9 @@ if [ "$VERBOSE" = "--verbose" ] || [ "$VERBOSE" = "-v" ]; then
226
235
  status=$(json_field "$f" ".status")
227
236
  [ "$status" != "active" ] && continue
228
237
  slug=$(json_field "$f" ".thread")
229
- what=$(json_field "$f" ".cursor?.what" 2>/dev/null || echo "")
238
+ what=$(current_cursor_field "$f" "what" 2>/dev/null || echo "")
230
239
  [ "$what" = "undefined" ] && what=""
231
- left_off=$(json_field "$f" ".cursor?.where_left_off" 2>/dev/null || echo "")
240
+ left_off=$(current_cursor_field "$f" "where_left_off" 2>/dev/null || echo "")
232
241
  [ "$left_off" = "undefined" ] && left_off=""
233
242
  echo " $slug"
234
243
  [ -n "$what" ] && echo " $what"
@@ -54,6 +54,7 @@ const VALID_CATEGORIES = [
54
54
  'deferred-trigger', 'routing-decision', 'knowledge-extraction', 'methodology-capture',
55
55
  'upstream-friction', 'project-completion', 'completion-review', 'branch-diverged',
56
56
  'stale-project', 'pattern-promotion', 'watchtower-health', 'worktree-unmerged',
57
+ 'qa-handoff',
57
58
  ];
58
59
  const VALID_ENRICHMENT = ['bare', 'in-progress', 'complete'];
59
60
  const VALID_URGENCY = ['urgent', 'normal', 'low'];
@@ -94,7 +94,11 @@ project's file.
94
94
  ### 1e. Active Threads
95
95
 
96
96
  Read `state/threads/*.json`. For each thread file, parse the JSON and
97
- collect threads with `status: "active"`. For each active thread, extract:
97
+ collect threads with `status: "active"`. The **current cursor** is the
98
+ last entry of the `cursor_history` array
99
+ (`cursor_history[length-1].cursor`); older thread files may instead carry
100
+ a single `cursor` object — treat that lone object as the current cursor.
101
+ For each active thread, extract from the current cursor:
98
102
  - `thread` (slug name)
99
103
  - `cursor.what` (one-line description)
100
104
  - `cursor.where_left_off` (current state)
@@ -140,6 +140,19 @@ Structure (flexible — adapt to what the work is):
140
140
  Format so it could be pasted into a pitch deck section, an about page,
141
141
  an investor update, a collaborator email, or a customer FAQ.
142
142
 
143
+ ### 3c. Index the new artifacts (required)
144
+
145
+ These records are version-controlled **project thought-record**, not
146
+ ephemera (see `.claude/rules/artifacts-of-thought.md`). In the same
147
+ change that writes a doc, add an index line for it to
148
+ `.claude/methodology/README.md` — date, title, one-line hook, and a
149
+ link. Create that index file if it doesn't exist yet. An unindexed
150
+ record is an invisible record, and the `/validate`
151
+ `artifacts-of-thought` check fails on any methodology doc git isn't
152
+ tracking — so a doc left unindexed-and-uncommitted will break
153
+ validation. If the project routes output to a custom location (see
154
+ Path override), index it there instead.
155
+
143
156
  ### 4. Integration with the debrief report
144
157
 
145
158
  At the end of the report phase, surface any methodology artifacts
@@ -147,6 +147,12 @@ Based on the chosen action:
147
147
  - `knowledge-extraction` -- run `/cc-remember` to capture the knowledge
148
148
  - `methodology-capture` -- run `/cc-remember` to capture the methodology
149
149
  - `completion-review` -- close the action in pib-db if confirmed
150
+ - `qa-handoff` -- the post-merge QA work for a merged branch. Read
151
+ `evidence.verification.runtime_needed` and run those named checks on
152
+ main (e2e, manual flow, deploy smoke); if `evidence.publish.needed`,
153
+ prepare the publish/deploy command and stop for the operator. Resolve
154
+ only after the runtime checks pass — never flip a handoff to verified
155
+ on a static claim.
150
156
  - `pattern-promotion` -- item has pre-authored options (write/promote/dismiss):
151
157
  - **write**: Read `evidence.target_member`, `evidence.target_project_path`,
152
158
  and `evidence.pattern_text` from the item. Write the pattern to
@@ -0,0 +1,192 @@
1
+ ---
2
+ name: qa-handoff
3
+ description: |
4
+ Package a just-merged worktree session into a QA handoff — the bridge
5
+ between "worktree work is done" and the post-merge work that can only
6
+ happen on main (e2e tests against a live server, npm publish, deploy).
7
+ Writes an operator-level handoff (what merged, what the worktree could
8
+ NOT verify, what hangs on the next step) to the watchtower inbox so it
9
+ surfaces and ages until QA happens. Stage 1 of the QA-handoff protocol:
10
+ this PACKAGES the handoff; it does not yet dispatch it to window 1.
11
+ Use when: "qa-handoff", "/qa-handoff", "hand off to QA", "package the
12
+ handoff", or right after merging a worktree branch into main.
13
+ argument-hint: "optional branch — e.g., (none = most recent merge), 'mux/my-branch'"
14
+ ---
15
+
16
+ # /qa-handoff — Package a merge for post-merge QA
17
+
18
+ Working in a worktree exposes a real gap: a worktree can't do anything
19
+ that depends on the main checkout — e2e tests need a live dev server
20
+ bound to one checkout, `npm publish` needs the main branch, deploy needs
21
+ main. These are all **post-merge** activities, and there's no bridge
22
+ between "the worktree work is merged" and "QA / publish / deploy runs on
23
+ main." This skill builds the handoff that crosses that gap.
24
+
25
+ Full design: `.claude/plans/qa-handoff-protocol.md`. This skill is
26
+ **stage 1** — it packages a merge into a durable, surfaced handoff. The
27
+ dispatch half (mux poking window 1, pane-state detection, the on-disk
28
+ queue) is a later stage; for now the handoff lands in the inbox and is
29
+ surfaced by `/briefing` until the operator runs QA.
30
+
31
+ ## The verification contract this enforces
32
+
33
+ A worktree session **cannot** runtime-verify e2e — the dev stack only
34
+ runs from main. So a handoff records two honest states, never one
35
+ overclaimed `verified`:
36
+
37
+ 1. **`statically-validated`** — what the worktree DID check without a
38
+ live stack: `node -c` / type-check, unit tests, `cucumber --dry-run`,
39
+ duplicate-step scans, a clean build. Real, but not runtime.
40
+ 2. **`runtime-needed`** — what only post-merge QA on main can confirm:
41
+ e2e scenarios against the live server, a manual flow, a deploy smoke
42
+ test. The handoff names these; it never claims them done.
43
+
44
+ The flip to `runtime-verified` is owned by the post-merge QA session
45
+ citing named checks that passed — not by this skill.
46
+
47
+ ## When to run
48
+
49
+ Right after you merge a worktree branch into main (this repo's
50
+ convention: Claude merges worktree → main). Run it from the main
51
+ checkout or the worktree you just merged from.
52
+
53
+ ## Step 1: Identify what merged
54
+
55
+ Resolve the merge being handed off:
56
+
57
+ - **No argument:** the most recent merge into `main`. Find it with
58
+ `git log --merges -1 --format='%H %s' main`, and the work range with
59
+ `git log main@{1}..main` if a reflog entry exists, else the merged
60
+ branch's commits.
61
+ - **`<branch>` argument:** that branch's contribution —
62
+ `git log --oneline main..<branch>` won't help post-merge (it's 0), so
63
+ use `git log <branch> --not $(git merge-base main <branch>)~1` or the
64
+ merge commit's `^1..^2` range.
65
+
66
+ Collect: the merge/HEAD commit SHA on main, a `--stat` of the diff, and
67
+ the list of changed files. Keep the file list — capability detection and
68
+ the verification split both key off it.
69
+
70
+ ## Step 2: Pull the acceptance criteria
71
+
72
+ The handoff's value is naming what still needs checking. Gather the AC
73
+ from the work:
74
+
75
+ 1. Scan the merged commit messages for `act:` / `prj:` fids.
76
+ 2. For each, read the action from pib-db (`pib_get_action` MCP, or
77
+ `node scripts/pib-db.mjs get <fid>`) and extract its Acceptance
78
+ Criteria / Verify Plan.
79
+ 3. Mark each AC `statically-validated` (the worktree proved it without a
80
+ live stack) or `runtime-needed` (only post-merge QA can confirm).
81
+ When unsure, default to `runtime-needed` — overclaiming is the
82
+ failure this protocol exists to prevent.
83
+
84
+ If no fids are referenced, derive the criteria from the diff itself —
85
+ what a reviewer on main would want to confirm still works.
86
+
87
+ ## Step 3: Detect capabilities (what post-merge work this project supports)
88
+
89
+ The handoff degrades to what the project can actually do. Detect from
90
+ the repo root:
91
+
92
+ - **e2e:** an `e2e/` dir, `cucumber.*`/`*.feature` files, a Playwright
93
+ config, or `cabinet-verify` in package.json → e2e applies.
94
+ - **publish:** a publishable npm package (package.json, not `private:
95
+ true`) or a `/cc-publish` skill present → publish applies.
96
+ - **deploy:** `railway.toml` → Railway; `fly.toml` → Fly; `vercel.json`
97
+ / `.vercel/` → Vercel; `netlify.toml` → Netlify; else none.
98
+
99
+ A project with none of these gets a handoff that's purely "here's what
100
+ merged, here's what to eyeball" — that's fine; silence is better than a
101
+ fake gate.
102
+
103
+ ## Step 4: Compose the handoff payload
104
+
105
+ Build one operator-level object. Reuse the thread-cursor's voice (plain
106
+ English, what a colleague needs to pick this up) — this is the QA
107
+ sibling of the cursor, a point-in-time event, not a field on it:
108
+
109
+ ```json
110
+ {
111
+ "merged_branch": "mux/<branch>",
112
+ "merged_commit": "<sha on main>",
113
+ "merged_into": "main",
114
+ "date": "YYYY-MM-DD",
115
+ "session_id": "<this session's id, if known>",
116
+ "what": "One line: what this work was.",
117
+ "why": "Why it happened — the motivation.",
118
+ "what_merged": ["The substantive changes, a few bullets."],
119
+ "could_not_verify": [
120
+ "What the worktree could not runtime-verify and post-merge QA must — be specific (which e2e scenario, which flow)."
121
+ ],
122
+ "hangs_on": [
123
+ "What the next step depends on — a publish decision, a deploy, a follow-up act: fid."
124
+ ],
125
+ "acceptance_criteria": [
126
+ {"fid": "act:xxxxxxxx", "text": "...", "state": "statically-validated | runtime-needed"}
127
+ ],
128
+ "capabilities": {"e2e": true, "publish": true, "deploy": "railway | fly | vercel | netlify | none"},
129
+ "verification": {
130
+ "statically_validated": ["What the worktree DID verify."],
131
+ "runtime_needed": ["What main must verify, by name."]
132
+ },
133
+ "publish": {"needed": true, "command": "the exact publish/deploy command, prepared not run"}
134
+ }
135
+ ```
136
+
137
+ Keep `could_not_verify` and `hangs_on` honest and short. An empty
138
+ `runtime_needed` with `publish.needed: false` is a valid handoff — it
139
+ just says "merged, nothing hangs on it."
140
+
141
+ ## Step 5: File the handoff to the inbox
142
+
143
+ File it as a `qa-handoff` inbox item. Urgency is `normal` by default —
144
+ reserve `urgent` for a handoff that genuinely blocks (a publish the
145
+ operator is waiting on). Better dark than wrong: a wall of urgent
146
+ handoffs trains the operator to ignore them.
147
+
148
+ Write the payload to a temp file, then file via the installed queue API:
149
+
150
+ ```bash
151
+ WT="${WATCHTOWER_DIR:-$HOME/.claude-cabinet/watchtower}"
152
+ # 1. Write the createItem args to /tmp/qa-handoff-item.json:
153
+ # { project, project_path, category: "qa-handoff", urgency,
154
+ # title, summary, context_anchor, evidence: <the payload above>,
155
+ # thread_ids: [<linked thread slugs, if known>],
156
+ # filed_by: "manual" }
157
+ node --input-type=module -e '
158
+ import { readFileSync } from "fs";
159
+ import { createItem } from "'"$WT"'/scripts/watchtower-queue.mjs";
160
+ const args = JSON.parse(readFileSync(process.argv[1], "utf8"));
161
+ console.log("Filed qa-handoff item:", createItem(args));
162
+ ' /tmp/qa-handoff-item.json
163
+ ```
164
+
165
+ - `title`: `QA handoff: <what>, merged <short-sha>`
166
+ - `summary`: one line — what merged and what QA must confirm.
167
+ - `context_anchor`: `git show <merged_commit>` (so QA can reconstruct).
168
+ - `thread_ids`: the thread slug(s) this work advanced, if you know them
169
+ (links the handoff to its cursor history — the sibling relationship).
170
+ Ring 3 mints threads at session close, so a fresh thread may not exist
171
+ yet; leave `[]` rather than guessing.
172
+
173
+ ## Step 6: Report to the operator
174
+
175
+ Tell the operator, plainly:
176
+ - What was handed off (the `what` + merged commit).
177
+ - What QA on main needs to do — the `runtime_needed` list, named.
178
+ - Whether a publish/deploy is staged and waiting on their go.
179
+ - That it's in the inbox and `/briefing` will resurface it until acted
180
+ on.
181
+
182
+ Do **not** run the publish or deploy. This skill prepares and stops;
183
+ promotion is an explicit operator decision (and, later, a dispatched
184
+ window-1 step).
185
+
186
+ ## Scope boundary (stage 1)
187
+
188
+ This skill does NOT poke window 1, detect pane state, or auto-run QA.
189
+ Those are the dispatch stages in `.claude/plans/qa-handoff-protocol.md`.
190
+ What it guarantees today: every merge can be packaged into one honest,
191
+ surfaced, ageable handoff that names what could not be verified from the
192
+ worktree — so nothing silently ships unverified.
@@ -50,11 +50,15 @@ Pure filesystem — no API calls. Read every `*.json` under:
50
50
  ```
51
51
 
52
52
  Ignore any `threads-test/` directory (fixtures). For each thread, you have:
53
- `thread` (slug), `display_name` (optional, falls back to slug), `cursor`
54
- (`what`, `why`, `where_left_off`, `open_questions`, `next_steps`),
55
- `sessions` (append-only log, each with `id`, `date`, `project`, `summary`,
53
+ `thread` (slug), `display_name` (optional, falls back to slug),
54
+ `cursor_history` (append-only array of `{date, session_id, cursor}`
55
+ snapshots the CURRENT cursor is the last entry's `cursor`, carrying
56
+ `what`, `why`, `where_left_off`, `open_questions`, `next_steps`; the
57
+ earlier entries are the trail of how understanding evolved), `sessions`
58
+ (append-only log, each with `id`, `date`, `project`, `summary`,
56
59
  `transcript`), `related_fids`, `lineage` (optional), `last_updated`,
57
- `status`.
60
+ `status`. Older thread files may still carry a single `cursor` object
61
+ instead of `cursor_history` — treat that lone object as the current cursor.
58
62
 
59
63
  If the directory is missing or empty, say so plainly ("No threads
60
64
  captured yet — watchtower's Ring 3 writes these at session close") and
@@ -87,9 +91,11 @@ in the output. (This is the house style — see the memory
87
91
  ### Arc mode (`arc <slug>`)
88
92
 
89
93
  Tell the story of one thread across its `sessions` in order: how the
90
- framing (`what`) evolved, the turning points, where it stands now, and
91
- its `lineage` if present (what it grew out of, what it merged into). End
92
- with the open questions and next steps.
94
+ framing (`what`) evolved `cursor_history` records this directly, one
95
+ snapshot per session, so walk the entries in sequence rather than
96
+ inferring the arc the turning points, where it stands now, and its
97
+ `lineage` if present (what it grew out of, what it merged into). End
98
+ with the current cursor's open questions and next steps.
93
99
 
94
100
  ### Connections mode
95
101
 
@@ -124,7 +130,7 @@ any of these smells:
124
130
  - **Drift** — a `display_name`/`what` that no longer matches what the
125
131
  recent sessions are actually about.
126
132
  - **Orphans** — sessions referenced but missing transcripts, or threads
127
- with an empty cursor.
133
+ with an empty `cursor_history`.
128
134
 
129
135
  If the threads look well-segmented, say so in one line. If they don't,
130
136
  name the specific threads and suggest the fix is a re-sort
@@ -147,6 +147,40 @@ values (must be high|moderate|info), invalid tags (must be run|review).
147
147
  Exits 0 silently if qa-dimensions.yaml doesn't exist (the checklist
148
148
  engine is opt-in).
149
149
 
150
+ ### artifacts-of-thought
151
+
152
+ ```bash
153
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "not a git work tree — skipping"; exit 0; }
154
+ untracked=0
155
+ for dir in .claude/methodology .claude/plans; do
156
+ [ -d "$dir" ] || continue
157
+ for f in "$dir"/*.md; do
158
+ [ -e "$f" ] || continue
159
+ if ! git ls-files --error-unmatch "$f" >/dev/null 2>&1; then
160
+ echo "UNTRACKED: $f"
161
+ untracked=$((untracked + 1))
162
+ fi
163
+ done
164
+ done
165
+ if [ "$untracked" -gt 0 ]; then
166
+ echo ""
167
+ echo "$untracked design record(s)/plan(s) are not tracked by git."
168
+ echo "These are project thought-record, not ephemera. Commit them, or if"
169
+ echo "one is genuinely disposable, move it out of these dirs. Never resolve"
170
+ echo "this by gitignoring the directory — see .claude/rules/artifacts-of-thought.md."
171
+ exit 1
172
+ fi
173
+ echo "All methodology records and plans are tracked."
174
+ ```
175
+
176
+ Catches the silent-loss failure mode: a design record in
177
+ `.claude/methodology/` or a plan in `.claude/plans/` that git isn't
178
+ tracking — because it was just written and not committed, or because the
179
+ directory was gitignored (which un-tracks every *new* doc while older
180
+ committed ones survive, masking the problem). `README.md` indexes are
181
+ tracked, so they don't flag. Skips silently outside a git work tree or
182
+ when neither directory exists.
183
+
150
184
  ## Example Validators (commented — enable for your project)
151
185
 
152
186
  <!--
@@ -33,7 +33,8 @@
33
33
  "enum": [
34
34
  "deferred-trigger", "routing-decision", "knowledge-extraction", "methodology-capture",
35
35
  "upstream-friction", "project-completion", "completion-review", "branch-diverged",
36
- "stale-project", "pattern-promotion", "watchtower-health", "worktree-unmerged"
36
+ "stale-project", "pattern-promotion", "watchtower-health", "worktree-unmerged",
37
+ "qa-handoff"
37
38
  ]
38
39
  },
39
40
  "urgency": {
@@ -1,13 +0,0 @@
1
- ---
2
- name: decisions
3
- description: "Redirect: use /inbox instead"
4
- ---
5
-
6
- # /decisions → /inbox
7
-
8
- This skill has been renamed to `/inbox`. Run `/inbox` instead.
9
-
10
- The watchtower inbox holds items that background rings noticed and want
11
- your attention on — knowledge to route, completion candidates, methodology,
12
- and friction reports. `/decisions` was misleading because the items aren't
13
- decisions awaiting judgment; they're incoming signals awaiting triage.
@@ -1,9 +0,0 @@
1
- ---
2
- name: engagement
3
- description: "Moved to /collab-client. Use that instead."
4
- manual: true
5
- ---
6
-
7
- This skill has moved to `/collab-client`. Please run `/collab-client` instead.
8
-
9
- For a quick status glance, use `/collab-client progress`.
@@ -1,7 +0,0 @@
1
- ---
2
- name: engagement-add
3
- description: "Moved to /collab-consultant add. Use that instead."
4
- manual: true
5
- ---
6
-
7
- This skill has moved to `/collab-consultant add`. Please run `/collab-consultant add` instead.
@@ -1,7 +0,0 @@
1
- ---
2
- name: engagement-edit
3
- description: "Moved to /collab-consultant edit. Use that instead."
4
- manual: true
5
- ---
6
-
7
- This skill has moved to `/collab-consultant edit`. Please run `/collab-consultant edit` instead.
@@ -1,7 +0,0 @@
1
- ---
2
- name: engagement-message
3
- description: "Moved to /collab-consultant message. Use that instead."
4
- manual: true
5
- ---
6
-
7
- This skill has moved to `/collab-consultant message <who>`. Please run `/collab-consultant message <who>` instead.
@@ -1,7 +0,0 @@
1
- ---
2
- name: engagement-progress
3
- description: "Moved to /collab-client progress. Use that instead."
4
- manual: true
5
- ---
6
-
7
- This skill has moved to `/collab-client progress`. Please run `/collab-client progress` instead.
@@ -1,7 +0,0 @@
1
- ---
2
- name: engagement-status
3
- description: "Moved to /collab-consultant. Use that instead."
4
- manual: true
5
- ---
6
-
7
- This skill has moved to `/collab-consultant` (the default subcommand is the dashboard). Please run `/collab-consultant` instead.
@@ -1,7 +0,0 @@
1
- ---
2
- name: engagement-sync
3
- description: "Moved to /collab-consultant sync. Use that instead."
4
- manual: true
5
- ---
6
-
7
- This skill has moved to `/collab-consultant sync`. Please run `/collab-consultant sync` instead.