@hegemonart/get-design-done 1.59.7 → 1.59.9

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 (55) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +59 -0
  4. package/README.md +2 -2
  5. package/SKILL.md +1 -1
  6. package/agents/design-authority-watcher.md +24 -5
  7. package/bin/gdd-graph +4 -1
  8. package/hooks/_hook-emit.js +113 -29
  9. package/hooks/budget-enforcer.ts +104 -5
  10. package/hooks/gdd-mcp-circuit-breaker.js +72 -3
  11. package/hooks/gdd-sessionstart-recap.js +23 -14
  12. package/hooks/hooks.json +2 -2
  13. package/package.json +2 -2
  14. package/reference/bandit-integration.md +13 -2
  15. package/reference/prices/claude.md +11 -0
  16. package/reference/runtime-models.md +9 -9
  17. package/reference/schemas/generated.d.ts +4 -0
  18. package/reference/schemas/runtime-models.schema.json +5 -0
  19. package/scripts/bootstrap.cjs +40 -8
  20. package/scripts/install.cjs +23 -1
  21. package/scripts/lib/bandit-router.cjs +47 -5
  22. package/scripts/lib/budget-enforcer.cjs +34 -5
  23. package/scripts/lib/detect/cli.cjs +13 -3
  24. package/scripts/lib/install/converters/cursor.cjs +11 -19
  25. package/scripts/lib/install/installer.cjs +72 -21
  26. package/scripts/lib/install/merge.cjs +31 -3
  27. package/scripts/lib/install/parse-runtime-models.cjs +9 -1
  28. package/scripts/lib/install/runtime-artifact-layout.cjs +42 -8
  29. package/scripts/lib/manifest/harnesses.json +29 -1
  30. package/scripts/lib/manifest/skills.json +1 -1
  31. package/scripts/lib/model-id.cjs +141 -0
  32. package/scripts/lib/session-runner/index.ts +87 -16
  33. package/scripts/skill-templates/bandit-reset/SKILL.md +2 -0
  34. package/scripts/skill-templates/bandit-status/SKILL.md +4 -1
  35. package/scripts/skill-templates/darkmode/SKILL.md +1 -1
  36. package/scripts/skill-templates/graphify/SKILL.md +6 -6
  37. package/scripts/skill-templates/quick/SKILL.md +3 -1
  38. package/scripts/skill-templates/reflect/SKILL.md +1 -1
  39. package/scripts/skill-templates/router/SKILL.md +4 -2
  40. package/sdk/cli/index.js +132 -55
  41. package/sdk/dashboard/data/source.cjs +50 -4
  42. package/sdk/event-stream/writer.ts +112 -30
  43. package/sdk/mcp/gdd-mcp/server.js +49 -36
  44. package/sdk/mcp/gdd-mcp/tools/shared.ts +20 -2
  45. package/sdk/mcp/gdd-state/server.js +107 -41
  46. package/sdk/primitives/lockfile.cjs +26 -5
  47. package/sdk/state/index.ts +91 -17
  48. package/sdk/state/lockfile.ts +47 -8
  49. package/skills/bandit-reset/SKILL.md +2 -0
  50. package/skills/bandit-status/SKILL.md +4 -1
  51. package/skills/darkmode/SKILL.md +1 -1
  52. package/skills/graphify/SKILL.md +6 -6
  53. package/skills/quick/SKILL.md +3 -1
  54. package/skills/reflect/SKILL.md +1 -1
  55. package/skills/router/SKILL.md +4 -2
@@ -197,6 +197,7 @@ __export(get_exports, {
197
197
  // sdk/state/index.ts
198
198
  var import_node_fs2 = require("node:fs");
199
199
  var import_node_path = require("node:path");
200
+ var import_node_module = require("node:module");
200
201
 
201
202
  // sdk/state/lockfile.ts
202
203
  var import_node_fs = require("node:fs");
@@ -263,6 +264,14 @@ async function acquire(path2, opts = {}) {
263
264
  }
264
265
  const parsed = parseLock(existing);
265
266
  if (parsed !== null && isStale(parsed, staleMs)) {
267
+ const confirm = readLockSafe(lockPath);
268
+ if (confirm === null) {
269
+ continue;
270
+ }
271
+ if (confirm !== existing) {
272
+ await sleep(pollMs);
273
+ continue;
274
+ }
266
275
  try {
267
276
  (0, import_node_fs.unlinkSync)(lockPath);
268
277
  } catch (delErr) {
@@ -321,10 +330,14 @@ function parseLock(raw) {
321
330
  }
322
331
  }
323
332
  function isStale(payload, staleMs) {
324
- if (!isPidAlive(payload.pid, payload.host)) return true;
325
- const acquiredAt = Date.parse(payload.acquired_at);
326
- if (!Number.isFinite(acquiredAt)) return true;
327
- return Date.now() - acquiredAt > staleMs;
333
+ const pidRecorded = typeof payload.pid === "number" && Number.isInteger(payload.pid) && payload.pid > 0;
334
+ if (!pidRecorded) {
335
+ const acquiredAt = Date.parse(payload.acquired_at);
336
+ if (!Number.isFinite(acquiredAt)) return true;
337
+ return Date.now() - acquiredAt > staleMs;
338
+ }
339
+ if (isPidAlive(payload.pid, payload.host)) return false;
340
+ return true;
328
341
  }
329
342
  function isPidAlive(pid, host) {
330
343
  if (host !== (0, import_node_os.hostname)()) {
@@ -1731,6 +1744,8 @@ function gateFor(from, to) {
1731
1744
  }
1732
1745
 
1733
1746
  // sdk/state/index.ts
1747
+ var _moduleDir = typeof __dirname !== "undefined" ? __dirname : (0, import_node_path.dirname)(process.argv[1] || process.cwd());
1748
+ var _require = typeof require !== "undefined" ? require : (0, import_node_module.createRequire)(process.argv[1] || process.cwd());
1734
1749
  function _findPackageRoot(startDir) {
1735
1750
  let dir = (0, import_node_path.resolve)(startDir);
1736
1751
  let firstWithPkg = null;
@@ -1738,7 +1753,7 @@ function _findPackageRoot(startDir) {
1738
1753
  const pkgPath = (0, import_node_path.join)(dir, "package.json");
1739
1754
  if ((0, import_node_fs2.existsSync)(pkgPath)) {
1740
1755
  try {
1741
- const pkg = require(pkgPath);
1756
+ const pkg = _require(pkgPath);
1742
1757
  if (firstWithPkg === null) firstWithPkg = dir;
1743
1758
  if (pkg.name === "@hegemonart/get-design-done") return dir;
1744
1759
  } catch {
@@ -1755,7 +1770,7 @@ var _backendCache = null;
1755
1770
  function _loadBackend() {
1756
1771
  if (_backendCache !== null) return _backendCache === false ? null : _backendCache;
1757
1772
  try {
1758
- const pkgRoot = _findPackageRoot(__dirname);
1773
+ const pkgRoot = _findPackageRoot(_moduleDir);
1759
1774
  if (pkgRoot === null) {
1760
1775
  _backendCache = false;
1761
1776
  return null;
@@ -1765,7 +1780,7 @@ function _loadBackend() {
1765
1780
  _backendCache = false;
1766
1781
  return null;
1767
1782
  }
1768
- _backendCache = require(backendPath);
1783
+ _backendCache = _require(backendPath);
1769
1784
  return _backendCache;
1770
1785
  } catch {
1771
1786
  _backendCache = false;
@@ -1890,20 +1905,47 @@ async function transition(path2, toStage) {
1890
1905
  throw new TransitionGateFailed(toStage, gateResult.blockers);
1891
1906
  }
1892
1907
  const nowIso = (/* @__PURE__ */ new Date()).toISOString();
1893
- const nextState = await mutate(path2, (s) => {
1894
- s.frontmatter.stage = toStage;
1895
- s.frontmatter.last_checkpoint = nowIso;
1896
- s.position.stage = toStage;
1897
- s.timestamps[`${toStage}_started_at`] = nowIso;
1898
- return s;
1899
- });
1900
- return { pass: true, blockers: gateResult.blockers, state: nextState };
1908
+ let lockedFailure = null;
1909
+ let lockedBlockers = gateResult.blockers;
1910
+ try {
1911
+ const nextState = await mutate(path2, (s) => {
1912
+ const fromNow = s.position.stage;
1913
+ if (!isStage(fromNow)) {
1914
+ lockedFailure = new TransitionGateFailed(toStage, [
1915
+ `Invalid transition: from="${fromNow}" is not a recognized Stage (changed under lock)`
1916
+ ]);
1917
+ throw lockedFailure;
1918
+ }
1919
+ const gateNow = gateFor(fromNow, toStage);
1920
+ if (gateNow === null) {
1921
+ lockedFailure = new TransitionGateFailed(toStage, [
1922
+ `Invalid transition: ${fromNow} \u2192 ${toStage} (changed under lock)`
1923
+ ]);
1924
+ throw lockedFailure;
1925
+ }
1926
+ const resultNow = gateNow(s);
1927
+ if (!resultNow.pass) {
1928
+ lockedFailure = new TransitionGateFailed(toStage, resultNow.blockers);
1929
+ throw lockedFailure;
1930
+ }
1931
+ lockedBlockers = resultNow.blockers;
1932
+ s.frontmatter.stage = toStage;
1933
+ s.frontmatter.last_checkpoint = nowIso;
1934
+ s.position.stage = toStage;
1935
+ s.timestamps[`${toStage}_started_at`] = nowIso;
1936
+ return s;
1937
+ });
1938
+ return { pass: true, blockers: lockedBlockers, state: nextState };
1939
+ } catch (err) {
1940
+ if (lockedFailure !== null && err === lockedFailure) throw lockedFailure;
1941
+ throw err;
1942
+ }
1901
1943
  }
1902
1944
 
1903
1945
  // sdk/mcp/gdd-state/tools/shared.ts
1904
1946
  var import_node_path3 = __toESM(require("node:path"));
1905
1947
  var import_node_fs4 = require("node:fs");
1906
- var import_node_module2 = require("node:module");
1948
+ var import_node_module3 = require("node:module");
1907
1949
 
1908
1950
  // sdk/event-stream/index.ts
1909
1951
  var import_node_os2 = require("node:os");
@@ -1952,40 +1994,64 @@ var EventBus = class extends import_node_events.EventEmitter {
1952
1994
  // sdk/event-stream/writer.ts
1953
1995
  var import_node_fs3 = require("node:fs");
1954
1996
  var import_node_path2 = require("node:path");
1955
- var import_node_module = require("node:module");
1997
+ var import_node_module2 = require("node:module");
1956
1998
  function _findRepoRoot() {
1957
- let dir = process.cwd();
1958
- for (let i = 0; i < 8; i++) {
1999
+ return _walkToPackageJson(process.cwd());
2000
+ }
2001
+ function _walkToPackageJson(startDir) {
2002
+ let dir = startDir;
2003
+ for (let i = 0; i < 12; i++) {
1959
2004
  if ((0, import_node_fs3.existsSync)((0, import_node_path2.join)(dir, "package.json"))) return dir;
1960
2005
  const parent = (0, import_node_path2.dirname)(dir);
1961
2006
  if (parent === dir) break;
1962
2007
  dir = parent;
1963
2008
  }
1964
- return process.cwd();
2009
+ return startDir;
1965
2010
  }
1966
- var _redact;
1967
- try {
1968
- const _root = _findRepoRoot();
1969
- const _candidate = (0, import_node_path2.resolve)(_root, "scripts/lib/redact.cjs");
1970
- if ((0, import_node_fs3.existsSync)(_candidate)) {
1971
- const _redactRequire = (0, import_node_module.createRequire)((0, import_node_path2.join)(_root, "package.json"));
1972
- const _mod = _redactRequire(_candidate);
1973
- _redact = _mod.redact;
1974
- } else {
1975
- const _altRoot = (0, import_node_path2.resolve)(_root, "..", "..");
1976
- const _altCandidate = (0, import_node_path2.resolve)(_altRoot, "scripts/lib/redact.cjs");
1977
- if ((0, import_node_fs3.existsSync)(_altCandidate)) {
1978
- const _altRequire = (0, import_node_module.createRequire)((0, import_node_path2.join)(_altRoot, "package.json"));
1979
- const _altMod = _altRequire(_altCandidate);
1980
- _redact = _altMod.redact;
1981
- } else {
1982
- _redact = (v) => v;
2011
+ var _redactWarned = false;
2012
+ function _warnRedactUnavailable() {
2013
+ if (_redactWarned) return;
2014
+ _redactWarned = true;
2015
+ try {
2016
+ process.stderr.write(
2017
+ "[event-stream] WARNING: scripts/lib/redact.cjs could not be loaded \u2014 failing CLOSED: event payloads are dropped (envelope-only) to avoid writing unscrubbed secrets. Run the event writer from inside the plugin tree or set the redact lib on PATH to restore full payloads.\n"
2018
+ );
2019
+ } catch {
2020
+ }
2021
+ }
2022
+ function _loadRedact() {
2023
+ const candidates = [];
2024
+ const entry = process.argv[1];
2025
+ if (typeof entry === "string" && entry.length > 0) {
2026
+ const entryAbs = (0, import_node_path2.isAbsolute)(entry) ? entry : (0, import_node_path2.resolve)(entry);
2027
+ const entryRoot = _walkToPackageJson((0, import_node_path2.dirname)(entryAbs));
2028
+ candidates.push((0, import_node_path2.resolve)(entryRoot, "scripts/lib/redact.cjs"));
2029
+ }
2030
+ const repoRoot = _findRepoRoot();
2031
+ candidates.push((0, import_node_path2.resolve)(repoRoot, "scripts/lib/redact.cjs"));
2032
+ candidates.push((0, import_node_path2.resolve)(repoRoot, "..", "..", "scripts/lib/redact.cjs"));
2033
+ for (const candidate of candidates) {
2034
+ try {
2035
+ if (!(0, import_node_fs3.existsSync)(candidate)) continue;
2036
+ const req = (0, import_node_module2.createRequire)(candidate);
2037
+ const mod = req(candidate);
2038
+ if (mod && typeof mod.redact === "function") return mod.redact;
2039
+ } catch {
1983
2040
  }
1984
2041
  }
1985
- } catch {
1986
- _redact = (v) => v;
2042
+ return null;
1987
2043
  }
1988
- var redact = _redact;
2044
+ var _realRedact = _loadRedact();
2045
+ var redact = _realRedact !== null ? _realRedact : (v) => {
2046
+ _warnRedactUnavailable();
2047
+ if (v !== null && typeof v === "object") {
2048
+ const ev = v;
2049
+ const out = { ...ev };
2050
+ out["payload"] = { _redaction_unavailable: true };
2051
+ return out;
2052
+ }
2053
+ return { _redaction_unavailable: true };
2054
+ };
1989
2055
  var DEFAULT_EVENTS_PATH = ".design/telemetry/events.jsonl";
1990
2056
  var DEFAULT_MAX_LINE_BYTES = 64 * 1024;
1991
2057
  var EventWriter = class {
@@ -2132,7 +2198,7 @@ var _worktree = (() => {
2132
2198
  const root = _findRepoRoot2();
2133
2199
  const candidate = import_node_path3.default.resolve(root, "scripts/lib/worktree-resolve.cjs");
2134
2200
  if (!(0, import_node_fs4.existsSync)(candidate)) return null;
2135
- const req = (0, import_node_module2.createRequire)(import_node_path3.default.join(root, "package.json"));
2201
+ const req = (0, import_node_module3.createRequire)(import_node_path3.default.join(root, "package.json"));
2136
2202
  return req(candidate);
2137
2203
  } catch {
2138
2204
  return null;
@@ -82,8 +82,16 @@ async function acquire(path, opts) {
82
82
  // reads (EACCES/EPERM/EBUSY), and clearing under that condition
83
83
  // would let two writers race and lose increments.
84
84
  if (parsed !== null && isStale(parsed, staleMs)) {
85
+ // Audit D3 (TOCTOU): confirm the on-disk bytes STILL match the exact
86
+ // stale payload we just read before unlinking. If a different writer
87
+ // replaced the lock in the read→unlink window, do NOT unlink (that
88
+ // would steal a fresh lock); loop and re-evaluate the new holder.
89
+ const confirm = readLockSafe(lockPath);
90
+ if (confirm === null) continue; // already gone — race for wx-create
91
+ if (confirm !== existing) { await sleep(pollMs); continue; }
85
92
  // Clear stale lock; race-tolerant — if it's already gone we get
86
- // ENOENT, no-op.
93
+ // ENOENT, no-op. The wx-create below is atomic (O_CREAT|O_EXCL), so
94
+ // even if two waiters both unlink, only one wins the recreate.
87
95
  try { fs.unlinkSync(lockPath); } catch { /* ignore */ }
88
96
  continue;
89
97
  }
@@ -155,10 +163,23 @@ function parseLock(raw) {
155
163
  }
156
164
 
157
165
  function isStale(payload, staleMs) {
158
- if (!isPidAlive(payload.pid, payload.host)) return true;
159
- const t = Date.parse(payload.acquired_at);
160
- if (!Number.isFinite(t)) return true;
161
- return Date.now() - t > staleMs;
166
+ // Audit D3: PID-liveness is AUTHORITATIVE. A lock whose holder PID is still
167
+ // alive on this host is NEVER stale, regardless of age — a legitimate
168
+ // long-running mutation must not have its lock stolen. The age-based
169
+ // fallback applies ONLY when liveness cannot be confirmed: a dead PID, or a
170
+ // missing/invalid pid field. (isPidAlive conservatively reports alive for
171
+ // cross-host and unsignalable holders, so those are also never aged out.)
172
+ const pidRecorded =
173
+ typeof payload.pid === 'number' &&
174
+ Number.isInteger(payload.pid) &&
175
+ payload.pid > 0;
176
+ if (!pidRecorded) {
177
+ const t = Date.parse(payload.acquired_at);
178
+ if (!Number.isFinite(t)) return true;
179
+ return Date.now() - t > staleMs;
180
+ }
181
+ if (isPidAlive(payload.pid, payload.host)) return false;
182
+ return true;
162
183
  }
163
184
 
164
185
  function isPidAlive(pid, host) {
@@ -37,6 +37,33 @@ import {
37
37
  } from 'node:fs';
38
38
  import { dirname, join, resolve } from 'node:path';
39
39
  import { pathToFileURL } from 'node:url';
40
+ import { createRequire } from 'node:module';
41
+
42
+ // Audit D1: this .ts compiles to CommonJS via tsc (Node16 module mode), where
43
+ // `import.meta` is FORBIDDEN (error TS1470). But under Node's
44
+ // --experimental-strip-types the same file runs as ESM, where the CommonJS
45
+ // globals `require` and `__dirname` are UNDEFINED -- a bare reference to either
46
+ // THROWS a ReferenceError, so we cannot name them directly. We satisfy BOTH
47
+ // targets by probing with `typeof` (safe in ESM) and falling back to the entry
48
+ // script (`process.argv[1]`) when the CJS globals are absent. This mirrors the
49
+ // process.argv[1] anchoring used by sibling `sdk/event-stream/writer.ts` and
50
+ // avoids `import.meta` entirely.
51
+ // * `_require` -- a CJS-style require, used to load the optional .cjs backend
52
+ // (state-backend.cjs) and package.json files. In compiled CJS output the
53
+ // real `require` is used; under strip-types ESM we synthesize one anchored
54
+ // on the entry script via createRequire.
55
+ // * `_moduleDir` -- the walk-up anchor formerly spelled `__dirname`. In
56
+ // compiled CJS `__dirname` is used; under ESM we derive a directory from
57
+ // the entry script. Either way `_loadBackend` can resolve the optional
58
+ // native backend in BOTH compiled and source modes.
59
+ const _moduleDir: string =
60
+ typeof __dirname !== 'undefined'
61
+ ? __dirname
62
+ : dirname(process.argv[1] || process.cwd());
63
+ const _require =
64
+ typeof require !== 'undefined'
65
+ ? require
66
+ : createRequire(process.argv[1] || process.cwd());
40
67
 
41
68
  import { acquire, acquireSqliteLock } from './lockfile.ts';
42
69
  import { parse } from './parser.ts';
@@ -69,8 +96,8 @@ function _findPackageRoot(startDir: string): string | null {
69
96
  const pkgPath = join(dir, 'package.json');
70
97
  if (existsSync(pkgPath)) {
71
98
  try {
72
- // eslint-disable-next-line @typescript-eslint/no-require-imports
73
- const pkg = require(pkgPath) as { name?: string };
99
+ // D1: createRequire-bound require (bare `require` is undefined in ESM).
100
+ const pkg = _require(pkgPath) as { name?: string };
74
101
  if (firstWithPkg === null) firstWithPkg = dir;
75
102
  if (pkg.name === '@hegemonart/get-design-done') return dir;
76
103
  } catch {
@@ -85,9 +112,16 @@ function _findPackageRoot(startDir: string): string | null {
85
112
  }
86
113
 
87
114
  // ---------------------------------------------------------------------------
88
- // Phase 57: backend probe (loaded once via require, memoized).
89
- // state-backend.cjs is a CommonJS module; require() works from .ts under
90
- // Node 22 --experimental-strip-types (only type-erasable syntax is used).
115
+ // Phase 57: backend probe (loaded once via createRequire, memoized).
116
+ // state-backend.cjs is a CommonJS module. Audit D1 correction: a BARE
117
+ // `require()` does NOT work from this .ts under Node's --experimental-strip-
118
+ // types — this module is loaded as ESM, where `require` is undefined and a
119
+ // bare call throws ReferenceError (silently caught below, killing the backend
120
+ // path). We load via `_require` -- the real CJS `require` in compiled output,
121
+ // or a createRequire anchored on the entry script under strip-types ESM --
122
+ // which DOES resolve the optional .cjs backend in both modes. The graceful-null
123
+ // fallback is preserved for the genuinely-absent
124
+ // dependency (e.g. better-sqlite3 not installed).
91
125
  // ---------------------------------------------------------------------------
92
126
 
93
127
  interface StateBackendMod {
@@ -111,12 +145,13 @@ let _backendCache: StateBackendMod | null | false = null;
111
145
  function _loadBackend(): StateBackendMod | null {
112
146
  if (_backendCache !== null) return _backendCache === false ? null : _backendCache as StateBackendMod;
113
147
  try {
114
- const pkgRoot = _findPackageRoot(__dirname);
148
+ const pkgRoot = _findPackageRoot(_moduleDir);
115
149
  if (pkgRoot === null) { _backendCache = false; return null; }
116
150
  const backendPath = join(pkgRoot, 'scripts', 'lib', 'state', 'state-backend.cjs');
117
151
  if (!existsSync(backendPath)) { _backendCache = false; return null; }
118
- // eslint-disable-next-line @typescript-eslint/no-require-imports
119
- _backendCache = require(backendPath) as StateBackendMod;
152
+ // D1: createRequire-bound require so the optional native backend can
153
+ // actually load from this ESM (.ts strip-types) context.
154
+ _backendCache = _require(backendPath) as StateBackendMod;
120
155
  return _backendCache as StateBackendMod;
121
156
  } catch {
122
157
  _backendCache = false;
@@ -144,7 +179,7 @@ let _storeCache: StateStoreMod | null | false = null;
144
179
  async function _loadStore(): Promise<StateStoreMod | null> {
145
180
  if (_storeCache !== null) return _storeCache === false ? null : _storeCache as StateStoreMod;
146
181
  try {
147
- const pkgRoot = _findPackageRoot(__dirname);
182
+ const pkgRoot = _findPackageRoot(_moduleDir);
148
183
  if (pkgRoot === null) { _storeCache = false; return null; }
149
184
  const storePath = join(pkgRoot, 'scripts', 'lib', 'state', 'state-store.cjs');
150
185
  if (!existsSync(storePath)) { _storeCache = false; return null; }
@@ -420,12 +455,51 @@ export async function transition(
420
455
  throw new TransitionGateFailed(toStage, gateResult.blockers);
421
456
  }
422
457
  const nowIso: string = new Date().toISOString();
423
- const nextState = await mutate(path, (s): ParsedState => {
424
- s.frontmatter.stage = toStage;
425
- s.frontmatter.last_checkpoint = nowIso;
426
- s.position.stage = toStage;
427
- s.timestamps[`${toStage}_started_at`] = nowIso;
428
- return s;
429
- });
430
- return { pass: true, blockers: gateResult.blockers, state: nextState };
458
+ // Audit D4: the gate above was evaluated against a PRE-LOCK read. A
459
+ // concurrent stage change between that read and the locked mutate could make
460
+ // the transition invalid (e.g. another writer already advanced the stage, so
461
+ // `from` is no longer the current stage, or the gate's preconditions no
462
+ // longer hold). Re-evaluate the gate INSIDE the locked mutate against the
463
+ // freshly-read `s`, and abort the transition if it no longer holds. The
464
+ // mutate() lock serializes us against other writers, so this re-check is
465
+ // race-free: nothing can change `s` between this check and the write.
466
+ //
467
+ // We capture the locked re-check failure and re-throw it OUTSIDE mutate so
468
+ // the caller sees a TransitionGateFailed rather than a generic mutate error.
469
+ let lockedFailure: TransitionGateFailed | null = null;
470
+ let lockedBlockers: string[] = gateResult.blockers;
471
+ try {
472
+ const nextState = await mutate(path, (s): ParsedState => {
473
+ const fromNow: string = s.position.stage;
474
+ if (!isStage(fromNow)) {
475
+ lockedFailure = new TransitionGateFailed(toStage, [
476
+ `Invalid transition: from="${fromNow}" is not a recognized Stage (changed under lock)`,
477
+ ]);
478
+ throw lockedFailure;
479
+ }
480
+ const gateNow = gateFor(fromNow, toStage);
481
+ if (gateNow === null) {
482
+ lockedFailure = new TransitionGateFailed(toStage, [
483
+ `Invalid transition: ${fromNow} → ${toStage} (changed under lock)`,
484
+ ]);
485
+ throw lockedFailure;
486
+ }
487
+ const resultNow = gateNow(s);
488
+ if (!resultNow.pass) {
489
+ lockedFailure = new TransitionGateFailed(toStage, resultNow.blockers);
490
+ throw lockedFailure;
491
+ }
492
+ lockedBlockers = resultNow.blockers;
493
+ s.frontmatter.stage = toStage;
494
+ s.frontmatter.last_checkpoint = nowIso;
495
+ s.position.stage = toStage;
496
+ s.timestamps[`${toStage}_started_at`] = nowIso;
497
+ return s;
498
+ });
499
+ return { pass: true, blockers: lockedBlockers, state: nextState };
500
+ } catch (err) {
501
+ // If the in-lock re-check vetoed, surface the gate failure verbatim.
502
+ if (lockedFailure !== null && err === lockedFailure) throw lockedFailure;
503
+ throw err;
504
+ }
431
505
  }
@@ -99,7 +99,25 @@ export async function acquire(
99
99
 
100
100
  const parsed: LockPayload | null = parseLock(existing);
101
101
  if (parsed !== null && isStale(parsed, staleMs)) {
102
- // Clear stale lock and retry.
102
+ // Audit D3 (TOCTOU): two waiters could each observe the same stale
103
+ // lock and both unlink+recreate, or one could unlink a DIFFERENT,
104
+ // freshly-acquired lock that replaced the stale one in the read→unlink
105
+ // window. Guard by confirming the on-disk bytes STILL match the exact
106
+ // stale payload we observed immediately before unlinking; if they
107
+ // changed (a new holder wrote a fresh lock), abandon the clear and
108
+ // loop — the next iteration re-reads and re-evaluates the new holder.
109
+ const confirm: string | null = readLockSafe(lockPath);
110
+ if (confirm === null) {
111
+ // Already gone — someone cleared it first. Retry immediately to
112
+ // race for the wx-create.
113
+ continue;
114
+ }
115
+ if (confirm !== existing) {
116
+ // A different writer replaced the lock between our read and now.
117
+ // Do NOT unlink — that would steal a (potentially fresh) lock.
118
+ await sleep(pollMs);
119
+ continue;
120
+ }
103
121
  try {
104
122
  unlinkSync(lockPath);
105
123
  } catch (delErr) {
@@ -108,6 +126,9 @@ export async function acquire(
108
126
  // Someone else cleared it first; fall through to retry.
109
127
  }
110
128
  }
129
+ // The wx-create on the next iteration is itself atomic (O_CREAT|O_EXCL),
130
+ // so even if two waiters both reach the unlink, only ONE wins the
131
+ // recreate; the loser sees EEXIST and re-evaluates.
111
132
  continue;
112
133
  }
113
134
 
@@ -181,13 +202,31 @@ function parseLock(raw: string): LockPayload | null {
181
202
  }
182
203
 
183
204
  function isStale(payload: LockPayload, staleMs: number): boolean {
184
- // 1) PID check if the process is dead, the lock is stale.
185
- if (!isPidAlive(payload.pid, payload.host)) return true;
186
- // 2) Age check acquired_at older than staleMs is stale even if the
187
- // PID is reused by something else.
188
- const acquiredAt = Date.parse(payload.acquired_at);
189
- if (!Number.isFinite(acquiredAt)) return true; // garbage timestamp
190
- return Date.now() - acquiredAt > staleMs;
205
+ // Audit D3: PID-liveness is AUTHORITATIVE. A lock whose holder PID is still
206
+ // alive on this host is NEVER stale, regardless of age — a legitimate
207
+ // long-running mutation (e.g. a >60s transaction) must not have its lock
208
+ // stolen out from under it. The age-based fallback only applies when we
209
+ // CANNOT confirm liveness: a dead PID, a missing/invalid pid field, a
210
+ // cross-host holder, or an unsignalable PID.
211
+ //
212
+ // Note: `isPidAlive` already returns true for the conservative
213
+ // can't-introspect cases (different host, EPERM). For those, the holder is
214
+ // treated as alive and the lock is held until released — we do NOT fall
215
+ // through to age-staleness, because doing so reintroduces the steal. Stale
216
+ // reclamation for genuinely-abandoned cross-host/unsignalable locks is left
217
+ // to manual cleanup, which is strictly safer than racing a live writer.
218
+ const pidRecorded =
219
+ typeof payload.pid === 'number' && Number.isInteger(payload.pid) && payload.pid > 0;
220
+ if (!pidRecorded) {
221
+ // No usable pid → cannot prove liveness. Fall back to age-staleness.
222
+ const acquiredAt = Date.parse(payload.acquired_at);
223
+ if (!Number.isFinite(acquiredAt)) return true; // garbage timestamp
224
+ return Date.now() - acquiredAt > staleMs;
225
+ }
226
+ // A recorded, live PID is decisive: NOT stale at any age.
227
+ if (isPidAlive(payload.pid, payload.host)) return false;
228
+ // PID is recorded but confirmed dead (ESRCH on this host) → stale.
229
+ return true;
191
230
  }
192
231
 
193
232
  /**
@@ -31,6 +31,8 @@ No posterior file found at `.design/telemetry/posterior.json` — nothing to res
31
31
  The next bandit pull with `adaptive_mode: full` will bootstrap a fresh posterior from informed priors. See `reference/bandit-integration.md`.
32
32
  ```
33
33
 
34
+ > Note: the posterior only learns (updates from outcomes) on the SDK / headless `session-runner` path. In interactive Claude Code with `adaptive_mode: full`, the bandit samples from the configured priors but does not currently update them in-session. A reset therefore re-bootstraps the priors the SDK path will subsequently learn from. See `reference/bandit-integration.md` ("Where adaptive routing actually learns").
35
+
34
36
  If present, count the arms (`arms.length`, treating a missing/non-array `arms` as `0`) so the confirmation and receipt can report what will be cleared. A corrupted/unparseable file is still resettable - report `arms: unknown (file unparseable)` and continue.
35
37
 
36
38
  ### 2. Require explicit confirmation
@@ -33,10 +33,13 @@ Possible reasons:
33
33
  - `adaptive_mode` is `static` or `hedge` (bandit silent — see `.design/budget.json`).
34
34
  - No spawns have fired since Phase 27.5 wiring landed.
35
35
  - Posterior was cleared via `/gdd:bandit-reset`.
36
+ - You are running in interactive Claude Code: the posterior is updated (learns) only on the SDK / headless `session-runner` path. In interactive `adaptive_mode: full` the bandit samples from configured priors but does not learn from in-session outcomes.
36
37
 
37
- See `reference/bandit-integration.md` for setup guidance.
38
+ See `reference/bandit-integration.md` ("Where adaptive routing actually learns") for setup guidance.
38
39
  ```
39
40
 
41
+ > Note: the posterior only moves (learns) on the SDK / headless `session-runner` path. In interactive Claude Code with `adaptive_mode: full`, the bandit samples from the configured priors but does not currently update them in-session. See `reference/bandit-integration.md`.
42
+
40
43
  Skip to Section 4 (Record). Parse failure (truncated/corrupted) → emit `Posterior file exists but is unparseable. Run /gdd:bandit-reset to start fresh, or restore from a backup.`
41
44
 
42
45
  ### 2. Parse the posterior
@@ -29,7 +29,7 @@ Output artifact prefix `DARKMODE-AUDIT` is distinct from the pipeline namespace
29
29
 
30
30
  ## Pre-Flight
31
31
 
32
- Confirm source root exists. Try in order: `src/` (preferred), `app/` (Next.js App Router), `lib/` (libraries), `pages/` (Next.js Pages Router). Set `SRC_ROOT` to the first that exists. If none exist, abort: `"No source directory detected. Run /get-design-done scan first."`
32
+ Confirm source root exists. Try in order: `src/` (preferred), `app/` (Next.js App Router), `lib/` (libraries), `pages/` (Next.js Pages Router). Set `SRC_ROOT` to the first that exists. If none exist, abort: `"No source directory detected. Run /get-design-done explore first."`
33
33
 
34
34
  Confirm `.design/` exists (create if absent: `mkdir -p .design/`).
35
35
 
@@ -5,7 +5,7 @@ description: "Manage the Graphify knowledge graph for the current project. Build
5
5
 
6
6
  # gdd-graphify
7
7
 
8
- Thin command wrapper around the GSD graphify tools integration.
8
+ Thin command wrapper around the get-design-done (GDD) graphify tools integration.
9
9
 
10
10
  ## Usage
11
11
 
@@ -30,10 +30,10 @@ Thin command wrapper around the GSD graphify tools integration.
30
30
  ```
31
31
  STOP.
32
32
  4. Execute the requested subcommand via the native CLI:
33
- - build: `node bin/gdd-graph build`
34
- - query: `node bin/gdd-graph query "<term>" --budget 2000`
35
- - status: `node bin/gdd-graph status`
36
- - diff: `node bin/gdd-graph diff`
33
+ - build: `node "${CLAUDE_PLUGIN_ROOT}/bin/gdd-graph" build`
34
+ - query: `node "${CLAUDE_PLUGIN_ROOT}/bin/gdd-graph" query "<term>" --budget 2000`
35
+ - status: `node "${CLAUDE_PLUGIN_ROOT}/bin/gdd-graph" status`
36
+ - diff: `node "${CLAUDE_PLUGIN_ROOT}/bin/gdd-graph" diff`
37
37
  5. After `build` completes, update `.design/STATE.md` `<connections>`: `graphify: available`
38
38
 
39
39
  ## Required Reading
@@ -43,7 +43,7 @@ Thin command wrapper around the GSD graphify tools integration.
43
43
 
44
44
  ## Notes
45
45
 
46
- - Graphify is optional. The native CLI ships in this repo at `bin/gdd-graph` (no external install - Node only).
46
+ - Graphify is optional. The native CLI ships with the plugin at `${CLAUDE_PLUGIN_ROOT}/bin/gdd-graph` (no external install - Node only).
47
47
  - Graph is stored at `.design/graph/graph.json` (Ajv-validated against `scripts/lib/graph/schema.json`).
48
48
  - Graph covers source code (`src/`, `components/`). It does NOT index `.design/` artifacts by default.
49
49
  - Use `query` with node IDs from the graph schema: `component:<name>`, `token:color/<name>`, `decision:D-<nn>`, etc.
@@ -26,10 +26,12 @@ Fast pipeline run. Skips optional-quality agents for speed while keeping the cor
26
26
  - Optional stage name (defaults to full pipeline from the current STATE.md position).
27
27
  - `--skip <agent-name>` (repeatable) adds to the skip list.
28
28
  2. Read `.design/STATE.md` to determine entry stage if none was passed.
29
- 3. For each stage to execute, spawn the stage skill with a `quick_mode: true` flag and the effective skip list in the spawn context. Stage skills read this flag and route around the listed agents.
29
+ 3. For each stage to execute, invoke the stage skill but spawn it with the optional agents in the effective skip list **omitted from the spawn graph** - this skill is the orchestrator, so it simply does not call those agents (the stage skills do not read a `quick_mode` flag; the skipping happens here, by not spawning them). The kept agents run exactly as in the full pipeline.
30
30
  4. After each stage, print: "Stage <name> done. Skipped: <list>."
31
31
  5. Final summary prints which agents were skipped across the full run.
32
32
 
33
+ Mechanism note: `/gdd:quick` is a lighter-touch *invocation* of the normal stages, not a special stage mode. It reduces ceremony by leaving the listed optional-quality agents out of the spawn graph it orchestrates. There is no flag the stage skills parse - if invoked directly (not via this skill) the stages run their full agent set.
34
+
33
35
  ## Use When
34
36
 
35
37
  - You trust the problem scope (no need for fresh research).
@@ -37,7 +37,7 @@ Run `design-reflector` on demand against the current (or specified) cycle. Produ
37
37
  See @skills/reflect/procedures/capability-gap-scan.md for the full procedure.
38
38
  The `design-reflector` agent runs the scan automatically as part of its reflection pass; this step lets users dry-run it independently with:
39
39
  ```
40
- node scripts/lib/reflector/capability-gap-scan.cjs --dry-run
40
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/reflector/capability-gap-scan.cjs" --dry-run
41
41
  ```
42
42
  The scan emits `capability_gap` events (`source: "reflector_pattern"`) for recurring patterns lacking a dedicated executable owner; Plan 29-03 aggregates these for `/gdd:apply-reflections`.
43
43
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: gdd-router
3
- description: "Routes a /gdd command to fast|quick|full path + S|M|L|XL complexity_class and returns {path, complexity_class, model_tier_overrides, resolved_models, estimated_cost_usd, cache_hits}. Deterministic - no model call. Invoked once at command entry before any Agent spawn. Read by hooks/budget-enforcer.ts."
3
+ description: "Routes a /gdd command to fast|quick|full path + S|M|L|XL complexity_class and returns {path, complexity_class, model_tier_overrides, resolved_models, estimated_cost_usd, cache_hits}. A SKILL.md prompt the model executes to emit a routing-decision JSON from rule tables (no separate agent spawn). Optional/advisory - invoked only by the skills that opt into routing; the budget-enforcer hook tolerates its absence. Read by hooks/budget-enforcer.ts."
4
4
  argument-hint: "<intent-string> [<target-artifacts-csv>]"
5
5
  tools: Read, Bash, Grep
6
6
  ---
@@ -69,7 +69,9 @@ Delegate to `skills/cache-manager/SKILL.md` (Plan 10.1-02). The router lists can
69
69
 
70
70
  ## Integration Point
71
71
 
72
- Every `/gdd:*` SKILL.md's first substantive step is: spawn the router via `Task` or inline invocation; receive the JSON blob; pass it to downstream agents as context so the budget-enforcer hook has the router decision available in tool_input metadata when the first Agent spawn fires.
72
+ The router is **optional and advisory**, not a universal first step. Only the handful of skills that explicitly opt into routing reference it (today: the root pipeline `SKILL.md` / `/gdd:handoff`, and `/gdd:style` documents that it deliberately does *not* invoke the router because it is a leaf invocation). The pipeline stage skills (explore / plan / design / verify) do **not** spawn the router. When a skill does invoke it, the flow is: invoke the router via `Task` or inline invocation; receive the JSON blob; pass it to downstream agents as context so the budget-enforcer hook has the router decision available in tool_input metadata when the first Agent spawn fires.
73
+
74
+ When no skill supplies a router decision, the budget-enforcer hook reads `tool_input.context.router_decision` as absent and falls back to its legacy back-compat path - the router's absence is tolerated by design, never an error.
73
75
 
74
76
  ## Failure Modes
75
77