@hegemonart/get-design-done 1.57.1 → 1.57.2

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 (113) hide show
  1. package/.claude-plugin/marketplace.json +26 -41
  2. package/.claude-plugin/plugin.json +23 -48
  3. package/CHANGELOG.md +91 -0
  4. package/README.md +166 -511
  5. package/SKILL.md +2 -0
  6. package/agents/README.md +33 -36
  7. package/agents/a11y-mapper.md +3 -3
  8. package/agents/component-benchmark-harvester.md +6 -6
  9. package/agents/component-benchmark-synthesizer.md +3 -3
  10. package/agents/compose-executor.md +3 -3
  11. package/agents/cost-forecaster.md +2 -2
  12. package/agents/design-auditor.md +7 -7
  13. package/agents/design-authority-watcher.md +15 -15
  14. package/agents/design-context-builder.md +4 -4
  15. package/agents/design-context-checker-gate.md +1 -1
  16. package/agents/design-discussant.md +2 -2
  17. package/agents/design-doc-writer.md +1 -1
  18. package/agents/design-executor.md +2 -2
  19. package/agents/design-figma-writer.md +2 -2
  20. package/agents/design-fixer.md +7 -7
  21. package/agents/design-integration-checker-gate.md +1 -1
  22. package/agents/design-integration-checker.md +1 -1
  23. package/agents/design-paper-writer.md +3 -3
  24. package/agents/design-pencil-writer.md +1 -1
  25. package/agents/design-planner.md +21 -0
  26. package/agents/design-reflector.md +39 -39
  27. package/agents/design-research-synthesizer.md +1 -0
  28. package/agents/design-start-writer.md +1 -1
  29. package/agents/design-update-checker.md +5 -5
  30. package/agents/design-verifier-gate.md +1 -1
  31. package/agents/design-verifier.md +52 -48
  32. package/agents/ds-generator.md +2 -2
  33. package/agents/ds-migration-planner.md +4 -4
  34. package/agents/email-executor.md +9 -9
  35. package/agents/experiment-result-ingester.md +3 -3
  36. package/agents/flutter-executor.md +5 -5
  37. package/agents/gdd-graph-refresh.md +3 -3
  38. package/agents/gdd-intel-updater.md +2 -2
  39. package/agents/motion-mapper.md +2 -2
  40. package/agents/motion-verifier.md +4 -4
  41. package/agents/pdf-executor.md +8 -8
  42. package/agents/perf-analyzer.md +17 -17
  43. package/agents/pr-commenter.md +9 -9
  44. package/agents/prototype-gate.md +2 -2
  45. package/agents/quality-gate-runner.md +1 -1
  46. package/agents/rollout-coordinator.md +3 -3
  47. package/agents/swift-executor.md +4 -4
  48. package/agents/ticket-sync-agent.md +6 -6
  49. package/agents/user-research-synthesizer.md +2 -2
  50. package/connections/connections.md +44 -45
  51. package/connections/cursor.md +73 -0
  52. package/connections/preview.md +3 -3
  53. package/dist/claude-code/.claude/skills/cache-manager/SKILL.md +3 -3
  54. package/dist/claude-code/.claude/skills/cache-manager/cache-policy.md +1 -1
  55. package/dist/claude-code/.claude/skills/design/SKILL.md +19 -0
  56. package/dist/claude-code/.claude/skills/explore/SKILL.md +11 -0
  57. package/dist/claude-code/.claude/skills/figma-write/SKILL.md +13 -2
  58. package/dist/claude-code/.claude/skills/paper-write/SKILL.md +54 -0
  59. package/dist/claude-code/.claude/skills/pencil-write/SKILL.md +54 -0
  60. package/dist/claude-code/.claude/skills/report-issue/SKILL.md +2 -2
  61. package/dist/claude-code/.claude/skills/router/SKILL.md +2 -2
  62. package/dist/claude-code/.claude/skills/verify/verify-procedure.md +10 -11
  63. package/dist/claude-code/.claude/skills/warm-cache/SKILL.md +1 -1
  64. package/hooks/first-run-nudge.cjs +171 -0
  65. package/hooks/gdd-intel-trigger.js +243 -0
  66. package/hooks/gdd-mcp-circuit-breaker.js +62 -7
  67. package/hooks/gdd-precompact-snapshot.js +50 -29
  68. package/hooks/gdd-protected-paths.js +150 -18
  69. package/hooks/gdd-risk-gate.js +93 -1
  70. package/hooks/gdd-sessionstart-recap.js +59 -24
  71. package/hooks/hooks.json +13 -4
  72. package/hooks/inject-using-gdd.cjs +188 -0
  73. package/hooks/update-check.cjs +511 -0
  74. package/package.json +9 -2
  75. package/reference/STATE-TEMPLATE.md +10 -13
  76. package/reference/audit-scoring.md +1 -1
  77. package/reference/cache-tier-doctrine.md +46 -0
  78. package/reference/config-schema.md +9 -9
  79. package/reference/i18n.md +1 -1
  80. package/reference/intel-schema.md +37 -2
  81. package/reference/meta-rules.md +4 -4
  82. package/reference/model-tiers.md +2 -2
  83. package/reference/registry.json +101 -94
  84. package/reference/runtime-models.md +11 -1
  85. package/reference/shared-preamble.md +13 -14
  86. package/reference/skill-graph.md +24 -1
  87. package/scripts/bootstrap.cjs +373 -0
  88. package/scripts/injection-patterns.cjs +58 -0
  89. package/scripts/lib/apply-reflections/incubator-proposals.cjs +57 -26
  90. package/scripts/lib/install/converters/codex-plugin.cjs +5 -2
  91. package/scripts/lib/install/converters/cursor.cjs +20 -0
  92. package/scripts/lib/issue-reporter/report-flow.cjs +1 -1
  93. package/scripts/lib/manifest/skills.json +80 -13
  94. package/scripts/lib/state/query-surface.cjs +67 -9
  95. package/scripts/lib/state/state-store.cjs +68 -26
  96. package/sdk/cli/commands/stage.ts +17 -0
  97. package/sdk/cli/index.js +14 -0
  98. package/skills/cache-manager/SKILL.md +3 -3
  99. package/skills/cache-manager/cache-policy.md +1 -1
  100. package/skills/design/SKILL.md +19 -0
  101. package/skills/explore/SKILL.md +11 -0
  102. package/skills/figma-write/SKILL.md +13 -2
  103. package/skills/paper-write/SKILL.md +54 -0
  104. package/skills/pencil-write/SKILL.md +54 -0
  105. package/skills/report-issue/SKILL.md +2 -2
  106. package/skills/router/SKILL.md +2 -2
  107. package/skills/verify/verify-procedure.md +10 -11
  108. package/skills/warm-cache/SKILL.md +1 -1
  109. package/hooks/first-run-nudge.sh +0 -82
  110. package/hooks/inject-using-gdd.sh +0 -72
  111. package/hooks/update-check.sh +0 -251
  112. package/scripts/lib/audit-aggregator/index.cjs +0 -219
  113. package/scripts/lib/hedge-ensemble.cjs +0 -217
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * hooks/gdd-intel-trigger.js — D5 (PostToolUse on Edit|Write)
5
+ *
6
+ * On every Edit/Write that touches a design-authoritative surface
7
+ * (skills/**, agents/**, reference/**, source/skills/**), spawn a
8
+ * background, detached refresh of the .design/intel/ store so downstream
9
+ * consumers (router, planner, audits) see the latest extracts without the
10
+ * user paying for a full rebuild on the next /gdd run.
11
+ *
12
+ * Contract:
13
+ * 1. Read the PostToolUse payload from stdin. Tolerate snake_case and
14
+ * camelCase field names (tool_name/toolName, tool_input/toolInput,
15
+ * file_path/filePath/path).
16
+ * 2. If the edited path matches
17
+ * ^(skills|agents|reference|source/skills)/.*\.(md|json)$
18
+ * (path-separator-agnostic), schedule a background refresh.
19
+ * 3. Otherwise no-op — write {continue:true} and exit 0.
20
+ * 4. Always exit 0. Never block. Never surface errors. Errors only ever
21
+ * land as a stderr breadcrumb (best-effort).
22
+ *
23
+ * Opt-out:
24
+ * Set GDD_DISABLE_INTEL_TRIGGER=1 to silence this hook completely
25
+ * (still writes {continue:true}, exits 0, spawns nothing).
26
+ *
27
+ * Dedup lock:
28
+ * .design/.intel-trigger.lock — a JSON file with {ts: <epoch_ms>}.
29
+ * If the lock is younger than 5 minutes, we assume a refresh is already
30
+ * in flight (or recently ran) and skip spawning again. Rapid sequential
31
+ * edits coalesce into one background rebuild. The lock is best-effort:
32
+ * if the .design/ dir does not exist or we cannot read/write the lock,
33
+ * we still proceed (or still no-op safely).
34
+ *
35
+ * Refresh path:
36
+ * scripts/build-intel.cjs is the rebuilder. It has no `--incremental`
37
+ * flag (incremental is its DEFAULT behavior — invoking it without
38
+ * `--force` re-extracts only changed files via mtime/git-hash). The
39
+ * task spec said "if --incremental exists, spawn it; else emit a
40
+ * breadcrumb." Since the script DOES do incremental by default, we
41
+ * spawn it as `node scripts/build-intel.cjs` (no flags) and surface
42
+ * the convention in the breadcrumb so future maintainers know why
43
+ * no `--incremental` was passed. If the script is ever missing, we
44
+ * emit only a breadcrumb and continue.
45
+ *
46
+ * Spawn shape:
47
+ * child_process.spawn('node', [script], { detached: true,
48
+ * stdio: 'ignore', windowsHide: true }) followed by child.unref().
49
+ * This decouples the child from our process tree so the hook returns
50
+ * immediately and the rebuild happens out of band.
51
+ */
52
+
53
+ const fs = require('node:fs');
54
+ const path = require('node:path');
55
+ const { spawn } = require('node:child_process');
56
+
57
+ const LOCK_TTL_MS = 5 * 60 * 1000; // 5 minutes
58
+ const TARGET_RE = /^(?:skills|agents|reference|source\/skills)\/.*\.(?:md|json)$/;
59
+
60
+ /**
61
+ * Extract the edited file path + tool name from a PostToolUse payload.
62
+ * Returns { tool, filename } or null when nothing usable was found.
63
+ */
64
+ function extractTarget(payload) {
65
+ if (!payload || typeof payload !== 'object') return null;
66
+ const tool = payload.tool_name || payload.toolName;
67
+ if (tool !== 'Write' && tool !== 'Edit') return null;
68
+ const input = payload.tool_input || payload.toolInput || {};
69
+ const filename =
70
+ input.file_path ||
71
+ input.filePath ||
72
+ input.path ||
73
+ (payload.tool_response &&
74
+ (payload.tool_response.filePath || payload.tool_response.file_path)) ||
75
+ '';
76
+ if (!filename) return null;
77
+ return { tool, filename: String(filename) };
78
+ }
79
+
80
+ /**
81
+ * Decide whether the given (absolute or relative) filename, considered
82
+ * relative to `cwd`, lives under one of the design-authoritative roots
83
+ * and is .md or .json. Returns true/false. Path-separator-agnostic.
84
+ */
85
+ function isDesignSurface(filename, cwd) {
86
+ if (!filename) return false;
87
+ let rel;
88
+ try {
89
+ rel = path.isAbsolute(filename)
90
+ ? path.relative(cwd, filename)
91
+ : filename;
92
+ } catch {
93
+ return false;
94
+ }
95
+ if (!rel || rel.startsWith('..')) return false;
96
+ const normalised = rel.replace(/\\/g, '/');
97
+ return TARGET_RE.test(normalised);
98
+ }
99
+
100
+ /**
101
+ * Best-effort: returns true if the lockfile exists AND its timestamp is
102
+ * younger than LOCK_TTL_MS. False on any error (missing dir, parse fail,
103
+ * stat fail) — fail-open so we still trigger the rebuild.
104
+ */
105
+ function lockIsFresh(lockPath) {
106
+ try {
107
+ if (!fs.existsSync(lockPath)) return false;
108
+ const raw = fs.readFileSync(lockPath, 'utf8');
109
+ const parsed = JSON.parse(raw);
110
+ const ts = Number(parsed && parsed.ts);
111
+ if (!Number.isFinite(ts)) return false;
112
+ return Date.now() - ts < LOCK_TTL_MS;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Best-effort: write {ts: Date.now()} to the lockfile, ensuring its
120
+ * parent dir exists. Swallows all errors — locking is purely an
121
+ * optimisation; failure to lock just means the next edit may re-trigger.
122
+ */
123
+ function writeLock(lockPath) {
124
+ try {
125
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
126
+ fs.writeFileSync(lockPath, JSON.stringify({ ts: Date.now() }), 'utf8');
127
+ } catch {
128
+ /* swallow */
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Spawn the intel rebuild as a detached background process. The child is
134
+ * fully decoupled (stdio:'ignore', detached:true, unref()) so the hook
135
+ * returns immediately. Errors are swallowed — the worst case is a stale
136
+ * intel store, which is no worse than the pre-hook baseline.
137
+ */
138
+ function spawnRebuild(cwd, script) {
139
+ try {
140
+ const child = spawn(process.execPath, [script], {
141
+ cwd,
142
+ detached: true,
143
+ stdio: 'ignore',
144
+ windowsHide: true,
145
+ env: process.env,
146
+ });
147
+ child.unref();
148
+ return true;
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Core hook entry. Returns the decision object to write to stdout.
156
+ * Always returns {continue: true}. Exported for unit testing.
157
+ *
158
+ * Optional `opts.spawnImpl` overrides the spawn-rebuild side effect
159
+ * (so tests can assert it was called without forking a real node).
160
+ */
161
+ function main(payload, opts = {}) {
162
+ // Opt-out shortcut — read here (not at module top) so tests can flip the
163
+ // env between calls without re-requiring the module.
164
+ if (process.env.GDD_DISABLE_INTEL_TRIGGER === '1') {
165
+ return { continue: true };
166
+ }
167
+
168
+ const cwd = (payload && payload.cwd) || opts.cwd || process.cwd();
169
+ const target = extractTarget(payload);
170
+ if (!target) return { continue: true };
171
+ if (!isDesignSurface(target.filename, cwd)) return { continue: true };
172
+
173
+ const lockPath = path.join(cwd, '.design', '.intel-trigger.lock');
174
+ if (lockIsFresh(lockPath)) return { continue: true };
175
+
176
+ const script = path.join(cwd, 'scripts', 'build-intel.cjs');
177
+ if (!fs.existsSync(script)) {
178
+ // Follow-up: a missing rebuilder is not this hook's problem to fix.
179
+ try {
180
+ process.stderr.write(
181
+ '[gdd-intel-trigger] would refresh .design/intel/ if scripts/build-intel.cjs --incremental existed\n'
182
+ );
183
+ } catch {
184
+ /* swallow */
185
+ }
186
+ return { continue: true };
187
+ }
188
+
189
+ // Lock first (idempotent if write fails), then spawn. Locking first
190
+ // means a sibling Edit racing this one will see a fresh lock and skip
191
+ // even if our spawn has not yet completed.
192
+ writeLock(lockPath);
193
+ const doSpawn = typeof opts.spawnImpl === 'function' ? opts.spawnImpl : spawnRebuild;
194
+ doSpawn(cwd, script);
195
+
196
+ return { continue: true };
197
+ }
198
+
199
+ /** CLI entrypoint — read JSON from stdin, decide, write {continue:true}. */
200
+ async function run(stdin = process.stdin, stdout = process.stdout) {
201
+ let buf = '';
202
+ try {
203
+ for await (const chunk of stdin) buf += chunk;
204
+ } catch {
205
+ stdout.write(JSON.stringify({ continue: true }));
206
+ return;
207
+ }
208
+ let payload;
209
+ try {
210
+ payload = JSON.parse(buf || '{}');
211
+ } catch {
212
+ stdout.write(JSON.stringify({ continue: true }));
213
+ return;
214
+ }
215
+ let decision;
216
+ try {
217
+ decision = main(payload);
218
+ } catch {
219
+ decision = { continue: true };
220
+ }
221
+ stdout.write(JSON.stringify(decision || { continue: true }));
222
+ }
223
+
224
+ if (require.main === module) {
225
+ run().catch(() => {
226
+ try {
227
+ process.stdout.write(JSON.stringify({ continue: true }));
228
+ } catch {
229
+ /* swallow */
230
+ }
231
+ });
232
+ }
233
+
234
+ module.exports = {
235
+ main,
236
+ extractTarget,
237
+ isDesignSurface,
238
+ lockIsFresh,
239
+ writeLock,
240
+ spawnRebuild,
241
+ LOCK_TTL_MS,
242
+ TARGET_RE,
243
+ };
@@ -40,15 +40,70 @@ function loadBudget(cwd) {
40
40
  return defaults;
41
41
  }
42
42
 
43
+ /**
44
+ * Classify the outcome of an MCP tool call as 'success' | 'timeout' | 'error'.
45
+ *
46
+ * The previous implementation substring-matched 'timeout' / 'failed' against
47
+ * the ENTIRE JSON-stringified response. That fired false positives on legit
48
+ * successful payloads whose content happens to mention those words — e.g. a
49
+ * Figma node literally named "TimeoutBanner", or a summary line "2 of 5 nodes
50
+ * failed to update, retrying...". When the breaker false-positives, it
51
+ * advances consecutive_timeouts and eventually trips on healthy traffic.
52
+ *
53
+ * The fix: use the structured isError / is_error envelope as the primary
54
+ * signal. MCP tool results carry isError=true|false. Anything without an
55
+ * explicit error flag is treated as success — full stop. Only when the
56
+ * envelope says "error" do we then inspect the ERROR-message fields
57
+ * (content[*].text + error.message + error.code + top-level message) to
58
+ * decide between 'timeout' and generic 'error'. Arbitrary content text is
59
+ * never scanned.
60
+ */
43
61
  function classifyOutcome(toolResponse) {
44
62
  if (!toolResponse || typeof toolResponse !== 'object') return 'error';
45
- const text = JSON.stringify(toolResponse).slice(0, 4000).toLowerCase();
46
- // Check timeout FIRST a timed-out call may also set is_error, but we want
47
- // to classify it as "timeout" so consecutive_timeouts advances correctly.
48
- if (text.includes('timeout') || text.includes('timed out') || text.includes('deadline exceeded')) return 'timeout';
49
- if (toolResponse.is_error) return 'error';
50
- if (text.includes('"error"') || text.includes('failed')) return 'error';
51
- return 'success';
63
+
64
+ // MCP standard envelope: isError (camelCase). Claude Code historically
65
+ // passes is_error (snake_case). Accept either; treat absence as success.
66
+ const isError =
67
+ toolResponse.isError === true || toolResponse.is_error === true;
68
+
69
+ if (!isError) return 'success';
70
+
71
+ // Error path: classify timeout vs generic by reading ONLY the dedicated
72
+ // error-message fields, not the entire payload.
73
+ const messageBits = [];
74
+
75
+ // content[] may be a string (legacy) or an array of {type,text} (spec).
76
+ if (typeof toolResponse.content === 'string') {
77
+ messageBits.push(toolResponse.content);
78
+ } else if (Array.isArray(toolResponse.content)) {
79
+ for (const c of toolResponse.content) {
80
+ if (c && typeof c.text === 'string') messageBits.push(c.text);
81
+ }
82
+ }
83
+
84
+ if (toolResponse.error && typeof toolResponse.error === 'object') {
85
+ if (typeof toolResponse.error.message === 'string') {
86
+ messageBits.push(toolResponse.error.message);
87
+ }
88
+ if (typeof toolResponse.error.code === 'string') {
89
+ messageBits.push(toolResponse.error.code);
90
+ }
91
+ }
92
+ if (typeof toolResponse.message === 'string') {
93
+ messageBits.push(toolResponse.message);
94
+ }
95
+
96
+ const combined = messageBits.join(' ').toLowerCase();
97
+ // \btimeout\b matches "timeout" and "request timeout"; \btimed?\s*out\b
98
+ // matches "timed out"; deadline exceeded is gRPC; etimedout is Node fs.
99
+ if (
100
+ /\btimeout\b|\btimed?\s*out\b|\bdeadline\s+exceeded\b|\betimedout\b/.test(
101
+ combined,
102
+ )
103
+ ) {
104
+ return 'timeout';
105
+ }
106
+ return 'error';
52
107
  }
53
108
 
54
109
  function readJsonlTail(filePath) {
@@ -25,14 +25,27 @@
25
25
  const fs = require('node:fs');
26
26
  const path = require('node:path');
27
27
 
28
- const SNAPSHOT_DIR = path.resolve(process.cwd(), '.design', 'snapshots');
29
- const STATE_MD_PATH = path.resolve(process.cwd(), '.design', 'STATE.md');
30
- const EVENTS_PATH = path.resolve(process.cwd(), '.design', 'telemetry', 'events.jsonl');
31
28
  const RETENTION_COUNT = 10;
32
29
  const EVENTS_TAIL_COUNT = 50;
33
30
  const DECISIONS_TAIL_COUNT = 10;
34
31
  const SCHEMA_VERSION = '1.0.0';
35
32
 
33
+ /**
34
+ * Resolve the bundle of paths the hook reads/writes, anchored at `cwd`.
35
+ *
36
+ * Originally these resolved at module load via `process.cwd()`, which is
37
+ * the wrong anchor when Claude Code invokes the hook from a worktree
38
+ * (the harness's cwd at module load can differ from the project root).
39
+ * Resolving against `payload.cwd` matches how 8 sibling hooks already work.
40
+ */
41
+ function computePaths(cwd) {
42
+ return {
43
+ snapshotDir: path.resolve(cwd, '.design', 'snapshots'),
44
+ stateMdPath: path.resolve(cwd, '.design', 'STATE.md'),
45
+ eventsPath: path.resolve(cwd, '.design', 'telemetry', 'events.jsonl'),
46
+ };
47
+ }
48
+
36
49
  // ---------------------------------------------------------------------------
37
50
  // Harness detection (D-10)
38
51
  // ---------------------------------------------------------------------------
@@ -66,13 +79,13 @@ function getAppendEvent() {
66
79
  // STATE.md tolerant parser — extracts frontmatter + decisions + blockers
67
80
  // ---------------------------------------------------------------------------
68
81
 
69
- function readStateSections() {
70
- if (!fs.existsSync(STATE_MD_PATH)) {
82
+ function readStateSections(paths) {
83
+ if (!fs.existsSync(paths.stateMdPath)) {
71
84
  return { frontmatter: {}, decisions: [], blockers: [], session: '' };
72
85
  }
73
86
  let body;
74
87
  try {
75
- body = fs.readFileSync(STATE_MD_PATH, 'utf8');
88
+ body = fs.readFileSync(paths.stateMdPath, 'utf8');
76
89
  } catch {
77
90
  return { frontmatter: {}, decisions: [], blockers: [], session: '' };
78
91
  }
@@ -124,11 +137,11 @@ function readStateSections() {
124
137
  // Events tail reader — JSONL-tolerant (malformed lines are skipped)
125
138
  // ---------------------------------------------------------------------------
126
139
 
127
- function readEventsTail(count) {
128
- if (!fs.existsSync(EVENTS_PATH)) return [];
140
+ function readEventsTail(paths, count) {
141
+ if (!fs.existsSync(paths.eventsPath)) return [];
129
142
  let body;
130
143
  try {
131
- body = fs.readFileSync(EVENTS_PATH, 'utf8');
144
+ body = fs.readFileSync(paths.eventsPath, 'utf8');
132
145
  } catch {
133
146
  return [];
134
147
  }
@@ -149,16 +162,16 @@ function readEventsTail(count) {
149
162
  // Retention prune — LRU by mtime, keep last RETENTION_COUNT (D-08)
150
163
  // ---------------------------------------------------------------------------
151
164
 
152
- function pruneSnapshots() {
165
+ function pruneSnapshots(paths) {
153
166
  let files;
154
167
  try {
155
- files = fs.readdirSync(SNAPSHOT_DIR);
168
+ files = fs.readdirSync(paths.snapshotDir);
156
169
  } catch {
157
170
  return;
158
171
  }
159
172
  const jsonFiles = files
160
173
  .filter((f) => f.endsWith('.json') && f !== 'last-recap.json')
161
- .map((f) => ({ name: f, full: path.join(SNAPSHOT_DIR, f), mtime: 0 }));
174
+ .map((f) => ({ name: f, full: path.join(paths.snapshotDir, f), mtime: 0 }));
162
175
 
163
176
  for (const entry of jsonFiles) {
164
177
  try {
@@ -186,35 +199,43 @@ function pruneSnapshots() {
186
199
  async function main() {
187
200
  const harness = detectHarness();
188
201
  if (harness === 'codex') {
189
- // D-10: Codex has no PreCompact event; emit notice + exit. Phase 45 dep
190
- // for full `pre-large-context-action` interception.
202
+ // D-10: Codex has no PreCompact event; emit notice + exit. Tracked in
203
+ // the runtime-parity matrix; full pre-large-context-action interception
204
+ // is on the roadmap.
191
205
  process.stderr.write(
192
206
  '[gdd-precompact-snapshot] this harness does not emit PreCompact; snapshots disabled\n',
193
207
  );
194
208
  process.exit(0);
195
209
  }
196
210
 
197
- // Drain stdin (Claude Code may pipe a hook event JSON; we don't need it
198
- // but draining avoids EPIPE on the parent's writer side).
211
+ // Drain stdin and extract payload.cwd. Claude Code pipes a hook-event JSON
212
+ // envelope including the project cwd; we need it to anchor SNAPSHOT_DIR and
213
+ // friends correctly when the harness's process.cwd() at module load may not
214
+ // match (e.g. when invoked from a worktree). Draining also avoids EPIPE on
215
+ // the writer side. Falls back to process.cwd() when stdin is empty or
216
+ // malformed (unit tests, direct invocation).
217
+ let buf = '';
199
218
  try {
200
- if (!process.stdin.isTTY) {
201
- // Best-effort, non-blocking — we have nothing time-sensitive in stdin.
202
- process.stdin.on('error', () => {
203
- /* swallow */
204
- });
205
- process.stdin.resume();
206
- }
219
+ for await (const chunk of process.stdin) buf += chunk;
207
220
  } catch {
208
- /* swallow */
221
+ /* swallow — empty stdin is fine */
222
+ }
223
+ let payload = {};
224
+ try {
225
+ payload = JSON.parse(buf || '{}');
226
+ } catch {
227
+ /* malformed stdin → fall through with empty payload */
209
228
  }
229
+ const cwd = (payload && typeof payload.cwd === 'string') ? payload.cwd : process.cwd();
230
+ const paths = computePaths(cwd);
210
231
 
211
232
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
212
- const snapshotPath = path.join(SNAPSHOT_DIR, ts + '.json');
233
+ const snapshotPath = path.join(paths.snapshotDir, ts + '.json');
213
234
  const tmpPath = snapshotPath + '.tmp';
214
235
 
215
236
  // Ensure snapshot dir exists (mkdir -p semantics).
216
237
  try {
217
- fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
238
+ fs.mkdirSync(paths.snapshotDir, { recursive: true });
218
239
  } catch {
219
240
  /* swallow — write will fail loudly below if truly missing */
220
241
  }
@@ -240,8 +261,8 @@ async function main() {
240
261
  }
241
262
 
242
263
  try {
243
- const sections = readStateSections();
244
- const events = readEventsTail(EVENTS_TAIL_COUNT);
264
+ const sections = readStateSections(paths);
265
+ const events = readEventsTail(paths, EVENTS_TAIL_COUNT);
245
266
  const decisions = sections.decisions.slice(-DECISIONS_TAIL_COUNT);
246
267
  const cycleId =
247
268
  sections.frontmatter && sections.frontmatter.milestone
@@ -280,7 +301,7 @@ async function main() {
280
301
  }
281
302
 
282
303
  // Retention prune (T-27.6.05-04 DoS mitigation).
283
- pruneSnapshots();
304
+ pruneSnapshots(paths);
284
305
 
285
306
  // Best-effort event emit.
286
307
  const appendEvent = getAppendEvent();
@@ -59,28 +59,160 @@ function loadProtectedPaths(cwd) {
59
59
  }
60
60
 
61
61
  /**
62
- * Extract a target path from a Bash command, best-effort.
63
- * Returns an array of candidate paths; empty if none parsed.
62
+ * Tokenise a string of shell-style args into individual arguments, honoring
63
+ * single/double quotes and basic backslash escapes. Used by the bash target
64
+ * extractor to get reliable arg arrays for destructive coreutils.
65
+ */
66
+ function parseShellArgs(s) {
67
+ const args = [];
68
+ let current = '';
69
+ let inQuote = null;
70
+ for (let i = 0; i < s.length; i++) {
71
+ const ch = s[i];
72
+ if (inQuote) {
73
+ if (ch === inQuote) {
74
+ inQuote = null;
75
+ args.push(current);
76
+ current = '';
77
+ } else if (ch === '\\' && i + 1 < s.length && (s[i + 1] === inQuote || s[i + 1] === '\\')) {
78
+ current += s[++i];
79
+ } else {
80
+ current += ch;
81
+ }
82
+ } else if (ch === '"' || ch === "'") {
83
+ if (current) { args.push(current); current = ''; }
84
+ inQuote = ch;
85
+ } else if (/\s/.test(ch)) {
86
+ if (current) { args.push(current); current = ''; }
87
+ } else if (ch === '\\' && i + 1 < s.length) {
88
+ current += s[++i];
89
+ } else {
90
+ current += ch;
91
+ }
92
+ }
93
+ if (current) args.push(current);
94
+ return args;
95
+ }
96
+
97
+ /**
98
+ * Extract destructive-op file targets from one shell pipeline segment
99
+ * (already split on `&&`/`||`/`;`/`|`). Catches:
100
+ * - rm / cp / mv / mkdir / touch / rmdir / chmod / chown / ln / tee
101
+ * with ALL their non-flag args (not just the first).
102
+ * - git rm / mv / restore / checkout — same treatment.
103
+ * - sed -i <args> file1 [file2 ...]
104
+ * - > file and >> file redirects appearing anywhere in the segment.
105
+ *
106
+ * `sudo ` prefix is stripped before dispatch.
107
+ */
108
+ function extractTargetsFromSegment(seg) {
109
+ const targets = [];
110
+ const cleaned = seg.replace(/^sudo\s+/, '');
111
+
112
+ // git destructive subcommands
113
+ const gitMatch = cleaned.match(/^git\s+(rm|mv|restore|checkout)\b(.*)$/);
114
+ if (gitMatch) {
115
+ const args = parseShellArgs(gitMatch[2]);
116
+ for (const arg of args) {
117
+ if (!arg.startsWith('-')) targets.push(arg);
118
+ }
119
+ }
120
+
121
+ // sed -i: only treat as destructive when -i is present
122
+ if (/^sed\b/.test(cleaned) && /(?:^|\s)-i(?:\b|=)/.test(cleaned)) {
123
+ const tokens = parseShellArgs(cleaned).slice(1); // drop 'sed'
124
+ let i = 0;
125
+ while (i < tokens.length) {
126
+ const tok = tokens[i];
127
+ // BSD `sed -i ''` consumes an extra empty-string arg
128
+ if (tok === '-i' && i + 1 < tokens.length && tokens[i + 1] === '') {
129
+ i += 2;
130
+ continue;
131
+ }
132
+ if (tok.startsWith('-')) { i++; continue; }
133
+ // First non-flag arg may be either an in-line sed script or a file;
134
+ // path matcher will simply not match a non-path. Be permissive: queue all.
135
+ targets.push(tok);
136
+ i++;
137
+ }
138
+ }
139
+
140
+ // Coreutils destructive verbs
141
+ const coreutilsMatch = cleaned.match(/^(rm|cp|mv|mkdir|touch|rmdir|chmod|chown|ln|tee)\b(.*)$/);
142
+ if (coreutilsMatch) {
143
+ const args = parseShellArgs(coreutilsMatch[2]);
144
+ for (const arg of args) {
145
+ if (!arg.startsWith('-')) targets.push(arg);
146
+ }
147
+ }
148
+
149
+ // Redirect targets: > file or >> file (appear anywhere in the segment)
150
+ const redirects = seg.matchAll(/(?:^|[^&>])>>?\s*([^\s|;&]+)/g);
151
+ for (const m of redirects) targets.push(m[1]);
152
+
153
+ return targets;
154
+ }
155
+
156
+ /**
157
+ * Extract all candidate file paths a Bash command may mutate. Walks the
158
+ * command string in three passes:
159
+ *
160
+ * 1. Recursively process every `$(...)` and `\`...\`` subshell. The
161
+ * subshell is evaluated by the shell and its OUTPUT substitutes into
162
+ * the parent command — but the inner commands themselves ALSO run,
163
+ * so anything destructive inside is a target.
164
+ * 2. Strip subshells from the outer command to simplify splitting.
165
+ * 3. Split outer command on `&&`, `||`, `;`, `|` and feed each segment
166
+ * to extractTargetsFromSegment.
167
+ *
168
+ * Previous implementation called String.prototype.match() (returns only
169
+ * the first match) and a single regex with a `[^\\s|;&>]+` capture group.
170
+ * That missed:
171
+ * - chained commands (`rm safe.txt && rm secret`)
172
+ * - multi-arg destructive verbs (`rm a b c` — only `a` was extracted)
173
+ * - subshell content (`rm $(echo secret)`)
174
+ * - backtick command substitution (`rm \`echo secret\``)
175
+ *
176
+ * xargs bypass — `find protected -print0 | xargs -0 rm` — is NOT closed
177
+ * here, because the targets come from stdin which we can't model without
178
+ * a full pipeline shape analysis. The `find` segment will be checked but
179
+ * the subsequent xargs+rm segment carries no explicit path. Project policy
180
+ * should rely on `find <protected-dir>` being blocked at the upstream
181
+ * segment via the .git/** / reference/** globs, plus general operator
182
+ * caution. Future enhancement: scan pipeline for xargs-with-destructive
183
+ * verbs and require the upstream stage to not reference protected globs.
64
184
  */
65
185
  function extractBashTargets(command) {
66
186
  if (!command) return [];
187
+
67
188
  const targets = [];
68
- // rm / cp / mv / mkdir trailing arg(s)
69
- const rmMatch = command.match(/\b(rm|cp|mv|mkdir|touch|rmdir|chmod|chown)\s+(?:-[A-Za-z]+\s+)*([^\s|;&>]+)/);
70
- if (rmMatch) targets.push(rmMatch[2]);
71
- // redirect / tee
72
- const redirectMatch = command.match(/[>|]\s*(?:tee\s+)?([^\s|;&]+)$/);
73
- if (redirectMatch) targets.push(redirectMatch[1]);
74
- // sed -i <path> (BSD and GNU variants)
75
- const sedMatch = command.match(/\bsed\s+-i(?:\s*['"][^'"]*['"])?\s+(?:-[A-Za-z]+\s+)*(?:['"][^'"]*['"]\s+)?([^\s|;&]+)/);
76
- if (sedMatch) targets.push(sedMatch[1]);
77
- // git rm / git mv
78
- const gitMatch = command.match(/\bgit\s+(rm|mv|restore|checkout)\s+(?:-[A-Za-z]+\s+)*([^\s|;&]+)/);
79
- if (gitMatch) targets.push(gitMatch[2]);
80
-
81
- return targets
82
- .filter(Boolean)
83
- .map(p => p.replace(/^['"]|['"]$/g, ''));
189
+
190
+ // 1. Recursive subshell scan.
191
+ const SUBSHELL_RE = /\$\(([^()]*)\)|`([^`]*)`/g;
192
+ let m;
193
+ while ((m = SUBSHELL_RE.exec(command)) !== null) {
194
+ targets.push(...extractBashTargets(m[1] !== undefined ? m[1] : m[2]));
195
+ }
196
+
197
+ // 2. Strip subshells from outer command.
198
+ const stripped = command.replace(SUBSHELL_RE, '');
199
+
200
+ // 3. Split on shell separators and process each segment.
201
+ const segments = stripped.split(/\s*(?:&&|\|\||;|\|)\s*/);
202
+ for (const segment of segments) {
203
+ const seg = segment.trim();
204
+ if (!seg) continue;
205
+ targets.push(...extractTargetsFromSegment(seg));
206
+ }
207
+
208
+ // Dedup + strip surrounding quotes.
209
+ return [
210
+ ...new Set(
211
+ targets
212
+ .filter(Boolean)
213
+ .map((p) => p.replace(/^['"]|['"]$/g, '')),
214
+ ),
215
+ ];
84
216
  }
85
217
 
86
218
  async function main() {