@hegemonart/get-design-done 1.59.6 → 1.59.8

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 +55 -0
  4. package/README.md +4 -13
  5. package/SKILL.md +1 -1
  6. package/agents/design-authority-watcher.md +24 -5
  7. package/bin/gdd-graph +4 -1
  8. package/docs/i18n/README.de.md +210 -527
  9. package/docs/i18n/README.fr.md +201 -518
  10. package/docs/i18n/README.it.md +209 -526
  11. package/docs/i18n/README.ja.md +207 -524
  12. package/docs/i18n/README.ko.md +208 -525
  13. package/docs/i18n/README.zh-CN.md +213 -551
  14. package/hooks/_hook-emit.js +113 -29
  15. package/hooks/budget-enforcer.ts +44 -5
  16. package/hooks/gdd-mcp-circuit-breaker.js +72 -3
  17. package/hooks/gdd-sessionstart-recap.js +23 -14
  18. package/hooks/hooks.json +2 -2
  19. package/package.json +2 -2
  20. package/reference/bandit-integration.md +13 -2
  21. package/scripts/bootstrap.cjs +40 -8
  22. package/scripts/install.cjs +23 -1
  23. package/scripts/lib/bandit-router.cjs +47 -5
  24. package/scripts/lib/detect/cli.cjs +13 -3
  25. package/scripts/lib/install/converters/cursor.cjs +11 -19
  26. package/scripts/lib/install/doctor-codex-plugin.cjs +1 -1
  27. package/scripts/lib/install/doctor-cursor-marketplace.cjs +2 -2
  28. package/scripts/lib/install/installer.cjs +72 -21
  29. package/scripts/lib/install/merge.cjs +31 -3
  30. package/scripts/lib/install/runtime-artifact-layout.cjs +42 -8
  31. package/scripts/lib/manifest/harnesses.json +29 -1
  32. package/scripts/lib/manifest/skills.json +1 -1
  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 +114 -47
  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
@@ -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: `{{command_prefix}}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 `{{command_prefix}}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 `{{command_prefix}}*` 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` / `{{command_prefix}}handoff`, and `{{command_prefix}}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
 
package/sdk/cli/index.js CHANGED
@@ -82,44 +82,69 @@ var init_emitter = __esm({
82
82
 
83
83
  // sdk/event-stream/writer.ts
84
84
  function _findRepoRoot() {
85
- let dir = process.cwd();
86
- for (let i = 0; i < 8; i++) {
85
+ return _walkToPackageJson(process.cwd());
86
+ }
87
+ function _walkToPackageJson(startDir) {
88
+ let dir = startDir;
89
+ for (let i = 0; i < 12; i++) {
87
90
  if ((0, import_node_fs.existsSync)((0, import_node_path.join)(dir, "package.json"))) return dir;
88
91
  const parent = (0, import_node_path.dirname)(dir);
89
92
  if (parent === dir) break;
90
93
  dir = parent;
91
94
  }
92
- return process.cwd();
95
+ return startDir;
96
+ }
97
+ function _warnRedactUnavailable() {
98
+ if (_redactWarned) return;
99
+ _redactWarned = true;
100
+ try {
101
+ process.stderr.write(
102
+ "[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"
103
+ );
104
+ } catch {
105
+ }
93
106
  }
94
- var import_node_fs, import_node_path, import_node_module, _redact, redact, DEFAULT_EVENTS_PATH, DEFAULT_MAX_LINE_BYTES, EventWriter;
107
+ function _loadRedact() {
108
+ const candidates = [];
109
+ const entry = process.argv[1];
110
+ if (typeof entry === "string" && entry.length > 0) {
111
+ const entryAbs = (0, import_node_path.isAbsolute)(entry) ? entry : (0, import_node_path.resolve)(entry);
112
+ const entryRoot = _walkToPackageJson((0, import_node_path.dirname)(entryAbs));
113
+ candidates.push((0, import_node_path.resolve)(entryRoot, "scripts/lib/redact.cjs"));
114
+ }
115
+ const repoRoot = _findRepoRoot();
116
+ candidates.push((0, import_node_path.resolve)(repoRoot, "scripts/lib/redact.cjs"));
117
+ candidates.push((0, import_node_path.resolve)(repoRoot, "..", "..", "scripts/lib/redact.cjs"));
118
+ for (const candidate of candidates) {
119
+ try {
120
+ if (!(0, import_node_fs.existsSync)(candidate)) continue;
121
+ const req = (0, import_node_module.createRequire)(candidate);
122
+ const mod = req(candidate);
123
+ if (mod && typeof mod.redact === "function") return mod.redact;
124
+ } catch {
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+ var import_node_fs, import_node_path, import_node_module, _redactWarned, _realRedact, redact, DEFAULT_EVENTS_PATH, DEFAULT_MAX_LINE_BYTES, EventWriter;
95
130
  var init_writer = __esm({
96
131
  "sdk/event-stream/writer.ts"() {
97
132
  "use strict";
98
133
  import_node_fs = require("node:fs");
99
134
  import_node_path = require("node:path");
100
135
  import_node_module = require("node:module");
101
- try {
102
- const _root = _findRepoRoot();
103
- const _candidate = (0, import_node_path.resolve)(_root, "scripts/lib/redact.cjs");
104
- if ((0, import_node_fs.existsSync)(_candidate)) {
105
- const _redactRequire = (0, import_node_module.createRequire)((0, import_node_path.join)(_root, "package.json"));
106
- const _mod = _redactRequire(_candidate);
107
- _redact = _mod.redact;
108
- } else {
109
- const _altRoot = (0, import_node_path.resolve)(_root, "..", "..");
110
- const _altCandidate = (0, import_node_path.resolve)(_altRoot, "scripts/lib/redact.cjs");
111
- if ((0, import_node_fs.existsSync)(_altCandidate)) {
112
- const _altRequire = (0, import_node_module.createRequire)((0, import_node_path.join)(_altRoot, "package.json"));
113
- const _altMod = _altRequire(_altCandidate);
114
- _redact = _altMod.redact;
115
- } else {
116
- _redact = (v) => v;
117
- }
136
+ _redactWarned = false;
137
+ _realRedact = _loadRedact();
138
+ redact = _realRedact !== null ? _realRedact : (v) => {
139
+ _warnRedactUnavailable();
140
+ if (v !== null && typeof v === "object") {
141
+ const ev = v;
142
+ const out = { ...ev };
143
+ out["payload"] = { _redaction_unavailable: true };
144
+ return out;
118
145
  }
119
- } catch {
120
- _redact = (v) => v;
121
- }
122
- redact = _redact;
146
+ return { _redaction_unavailable: true };
147
+ };
123
148
  DEFAULT_EVENTS_PATH = ".design/telemetry/events.jsonl";
124
149
  DEFAULT_MAX_LINE_BYTES = 64 * 1024;
125
150
  EventWriter = class {
@@ -2435,6 +2460,7 @@ function getLogger() {
2435
2460
  // sdk/state/index.ts
2436
2461
  var import_node_fs5 = require("node:fs");
2437
2462
  var import_node_path4 = require("node:path");
2463
+ var import_node_module2 = require("node:module");
2438
2464
 
2439
2465
  // sdk/state/lockfile.ts
2440
2466
  var import_node_fs4 = require("node:fs");
@@ -2501,6 +2527,14 @@ async function acquire(path, opts = {}) {
2501
2527
  }
2502
2528
  const parsed = parseLock(existing);
2503
2529
  if (parsed !== null && isStale(parsed, staleMs)) {
2530
+ const confirm = readLockSafe(lockPath);
2531
+ if (confirm === null) {
2532
+ continue;
2533
+ }
2534
+ if (confirm !== existing) {
2535
+ await sleep(pollMs);
2536
+ continue;
2537
+ }
2504
2538
  try {
2505
2539
  (0, import_node_fs4.unlinkSync)(lockPath);
2506
2540
  } catch (delErr) {
@@ -2559,10 +2593,14 @@ function parseLock(raw) {
2559
2593
  }
2560
2594
  }
2561
2595
  function isStale(payload, staleMs) {
2562
- if (!isPidAlive(payload.pid, payload.host)) return true;
2563
- const acquiredAt = Date.parse(payload.acquired_at);
2564
- if (!Number.isFinite(acquiredAt)) return true;
2565
- return Date.now() - acquiredAt > staleMs;
2596
+ const pidRecorded = typeof payload.pid === "number" && Number.isInteger(payload.pid) && payload.pid > 0;
2597
+ if (!pidRecorded) {
2598
+ const acquiredAt = Date.parse(payload.acquired_at);
2599
+ if (!Number.isFinite(acquiredAt)) return true;
2600
+ return Date.now() - acquiredAt > staleMs;
2601
+ }
2602
+ if (isPidAlive(payload.pid, payload.host)) return false;
2603
+ return true;
2566
2604
  }
2567
2605
  function isPidAlive(pid, host) {
2568
2606
  if (host !== (0, import_node_os2.hostname)()) {
@@ -3969,6 +4007,8 @@ function gateFor(from, to) {
3969
4007
  }
3970
4008
 
3971
4009
  // sdk/state/index.ts
4010
+ var _moduleDir = typeof __dirname !== "undefined" ? __dirname : (0, import_node_path4.dirname)(process.argv[1] || process.cwd());
4011
+ var _require = typeof require !== "undefined" ? require : (0, import_node_module2.createRequire)(process.argv[1] || process.cwd());
3972
4012
  function _findPackageRoot(startDir) {
3973
4013
  let dir = (0, import_node_path4.resolve)(startDir);
3974
4014
  let firstWithPkg = null;
@@ -3976,7 +4016,7 @@ function _findPackageRoot(startDir) {
3976
4016
  const pkgPath = (0, import_node_path4.join)(dir, "package.json");
3977
4017
  if ((0, import_node_fs5.existsSync)(pkgPath)) {
3978
4018
  try {
3979
- const pkg = require(pkgPath);
4019
+ const pkg = _require(pkgPath);
3980
4020
  if (firstWithPkg === null) firstWithPkg = dir;
3981
4021
  if (pkg.name === "@hegemonart/get-design-done") return dir;
3982
4022
  } catch {
@@ -3993,7 +4033,7 @@ var _backendCache = null;
3993
4033
  function _loadBackend() {
3994
4034
  if (_backendCache !== null) return _backendCache === false ? null : _backendCache;
3995
4035
  try {
3996
- const pkgRoot = _findPackageRoot(__dirname);
4036
+ const pkgRoot = _findPackageRoot(_moduleDir);
3997
4037
  if (pkgRoot === null) {
3998
4038
  _backendCache = false;
3999
4039
  return null;
@@ -4003,7 +4043,7 @@ function _loadBackend() {
4003
4043
  _backendCache = false;
4004
4044
  return null;
4005
4045
  }
4006
- _backendCache = require(backendPath);
4046
+ _backendCache = _require(backendPath);
4007
4047
  return _backendCache;
4008
4048
  } catch {
4009
4049
  _backendCache = false;
@@ -4128,14 +4168,41 @@ async function transition(path, toStage) {
4128
4168
  throw new TransitionGateFailed(toStage, gateResult.blockers);
4129
4169
  }
4130
4170
  const nowIso = (/* @__PURE__ */ new Date()).toISOString();
4131
- const nextState = await mutate(path, (s) => {
4132
- s.frontmatter.stage = toStage;
4133
- s.frontmatter.last_checkpoint = nowIso;
4134
- s.position.stage = toStage;
4135
- s.timestamps[`${toStage}_started_at`] = nowIso;
4136
- return s;
4137
- });
4138
- return { pass: true, blockers: gateResult.blockers, state: nextState };
4171
+ let lockedFailure = null;
4172
+ let lockedBlockers = gateResult.blockers;
4173
+ try {
4174
+ const nextState = await mutate(path, (s) => {
4175
+ const fromNow = s.position.stage;
4176
+ if (!isStage(fromNow)) {
4177
+ lockedFailure = new TransitionGateFailed(toStage, [
4178
+ `Invalid transition: from="${fromNow}" is not a recognized Stage (changed under lock)`
4179
+ ]);
4180
+ throw lockedFailure;
4181
+ }
4182
+ const gateNow = gateFor(fromNow, toStage);
4183
+ if (gateNow === null) {
4184
+ lockedFailure = new TransitionGateFailed(toStage, [
4185
+ `Invalid transition: ${fromNow} \u2192 ${toStage} (changed under lock)`
4186
+ ]);
4187
+ throw lockedFailure;
4188
+ }
4189
+ const resultNow = gateNow(s);
4190
+ if (!resultNow.pass) {
4191
+ lockedFailure = new TransitionGateFailed(toStage, resultNow.blockers);
4192
+ throw lockedFailure;
4193
+ }
4194
+ lockedBlockers = resultNow.blockers;
4195
+ s.frontmatter.stage = toStage;
4196
+ s.frontmatter.last_checkpoint = nowIso;
4197
+ s.position.stage = toStage;
4198
+ s.timestamps[`${toStage}_started_at`] = nowIso;
4199
+ return s;
4200
+ });
4201
+ return { pass: true, blockers: lockedBlockers, state: nextState };
4202
+ } catch (err) {
4203
+ if (lockedFailure !== null && err === lockedFailure) throw lockedFailure;
4204
+ throw err;
4205
+ }
4139
4206
  }
4140
4207
 
4141
4208
  // scripts/lib/pipeline-runner/state-machine.ts
@@ -4982,7 +5049,7 @@ function collapseBlankLines(text) {
4982
5049
  }
4983
5050
 
4984
5051
  // scripts/lib/session-runner/errors.ts
4985
- var import_node_module2 = require("node:module");
5052
+ var import_node_module3 = require("node:module");
4986
5053
  var import_node_fs8 = require("node:fs");
4987
5054
  var import_node_path7 = require("node:path");
4988
5055
  function findRepoRoot() {
@@ -4996,7 +5063,7 @@ function findRepoRoot() {
4996
5063
  return process.cwd();
4997
5064
  }
4998
5065
  var REPO_ROOT = findRepoRoot();
4999
- var nodeRequire = (0, import_node_module2.createRequire)((0, import_node_path7.join)(REPO_ROOT, "package.json"));
5066
+ var nodeRequire = (0, import_node_module3.createRequire)((0, import_node_path7.join)(REPO_ROOT, "package.json"));
5000
5067
  var transportClassifier = nodeRequire(
5001
5068
  (0, import_node_path7.resolve)(REPO_ROOT, "sdk/primitives/error-classifier.cjs")
5002
5069
  );
@@ -5348,7 +5415,7 @@ var TranscriptWriter = class {
5348
5415
  };
5349
5416
 
5350
5417
  // scripts/lib/session-runner/index.ts
5351
- var import_node_module3 = require("node:module");
5418
+ var import_node_module4 = require("node:module");
5352
5419
  var import_node_fs10 = require("node:fs");
5353
5420
  var import_node_path9 = require("node:path");
5354
5421
  function _findRepoRoot2() {
@@ -5362,7 +5429,7 @@ function _findRepoRoot2() {
5362
5429
  return process.cwd();
5363
5430
  }
5364
5431
  var _REPO_ROOT = _findRepoRoot2();
5365
- var _nodeRequire = (0, import_node_module3.createRequire)((0, import_node_path9.join)(_REPO_ROOT, "package.json"));
5432
+ var _nodeRequire = (0, import_node_module4.createRequire)((0, import_node_path9.join)(_REPO_ROOT, "package.json"));
5366
5433
  var jitteredBackoff = _nodeRequire(
5367
5434
  (0, import_node_path9.resolve)(_REPO_ROOT, "sdk/primitives/jittered-backoff.cjs")
5368
5435
  );
@@ -9731,7 +9798,7 @@ ${BUILD_USAGE}`);
9731
9798
 
9732
9799
  // sdk/cli/commands/dashboard.ts
9733
9800
  var import_node_child_process2 = require("node:child_process");
9734
- var import_node_module4 = require("node:module");
9801
+ var import_node_module5 = require("node:module");
9735
9802
  var import_node_http = require("node:http");
9736
9803
  var import_node_fs23 = require("node:fs");
9737
9804
  var import_node_path22 = require("node:path");
@@ -9767,7 +9834,7 @@ function anchorDirs() {
9767
9834
  return out;
9768
9835
  }
9769
9836
  function climbToMarker(startDir) {
9770
- const req = (0, import_node_module4.createRequire)((0, import_node_path22.join)(startDir, "noop.js"));
9837
+ const req = (0, import_node_module5.createRequire)((0, import_node_path22.join)(startDir, "noop.js"));
9771
9838
  let dir = startDir;
9772
9839
  let firstWithPkg = null;
9773
9840
  for (let i = 0; i < 12; i++) {
@@ -9803,7 +9870,7 @@ function findPackageRoot() {
9803
9870
  }
9804
9871
  function requireFromRoot(relPath) {
9805
9872
  const root = findPackageRoot();
9806
- const req = (0, import_node_module4.createRequire)((0, import_node_path22.join)(root, "noop.js"));
9873
+ const req = (0, import_node_module5.createRequire)((0, import_node_path22.join)(root, "noop.js"));
9807
9874
  return req((0, import_node_path22.join)(root, relPath));
9808
9875
  }
9809
9876
  function resolveRoot(deps, flags) {
@@ -24,9 +24,10 @@
24
24
  * `degraded[]` (so gsd-health + the TUI can surface what is missing).
25
25
  * - Absent .design entirely -> every data section null/[] + degraded
26
26
  * populated, still no throw.
27
- * - Root resolution: opts.root || GDD_PROJECT_ROOT || package-root walk-up
28
- * || cwd. (Package-root walk-up resolves the GDD repo root, where
29
- * .design/.planning live.)
27
+ * - Root resolution: opts.root || GDD_PROJECT_ROOT || cwd marker walk-up
28
+ * (.design/.planning/.claude-plugin) || package-root walk-up. The cwd
29
+ * marker walk resolves the USER's project; package-root is a last-resort
30
+ * fallback (it would otherwise always pin the installed plugin's own dir).
30
31
  * - The .ts libs (sdk/state, sdk/event-stream) cannot be static-require()d
31
32
  * from a .cjs — they are loaded via dynamic import(pathToFileURL),
32
33
  * memoized once per process. The .cjs libs are require()d directly via the
@@ -100,15 +101,59 @@ function importEventStream() {
100
101
  // ---------------------------------------------------------------------------
101
102
  // Root resolution
102
103
  // ---------------------------------------------------------------------------
104
+ /**
105
+ * Walk UP from `startCwd` looking for a GDD project marker — `.design/` OR
106
+ * `.planning/` OR `.claude-plugin/plugin.json`. First match wins; returns the
107
+ * directory that holds the marker, or null if none is found before the
108
+ * filesystem root.
109
+ *
110
+ * This mirrors `resolveProjectRoot()` in
111
+ * `sdk/mcp/gdd-mcp/tools/shared.ts` (the canonical D-05 marker walk) so the
112
+ * dashboard resolves the USER's project, not the plugin's own install dir.
113
+ *
114
+ * @param {string} [startCwd]
115
+ * @returns {string|null}
116
+ */
117
+ function cwdMarkerWalk(startCwd = process.cwd()) {
118
+ let dir = path.resolve(startCwd);
119
+ // Bound the climb defensively (deep trees / odd mounts).
120
+ for (let i = 0; i < 64; i++) {
121
+ if (
122
+ fs.existsSync(path.join(dir, '.design')) ||
123
+ fs.existsSync(path.join(dir, '.planning')) ||
124
+ fs.existsSync(path.join(dir, '.claude-plugin', 'plugin.json'))
125
+ ) {
126
+ return dir;
127
+ }
128
+ const parent = path.dirname(dir);
129
+ if (parent === dir) return null; // reached filesystem root
130
+ dir = parent;
131
+ }
132
+ return null;
133
+ }
134
+
103
135
  /**
104
136
  * Resolve the project root the dashboard reads from:
105
- * opts.root || GDD_PROJECT_ROOT (env) || package-root walk-up || cwd.
137
+ * opts.root || GDD_PROJECT_ROOT (env) || cwd marker walk-up || package-root.
138
+ *
139
+ * D2 fix: `packageRoot()` ALWAYS succeeds (it walks up from __dirname and
140
+ * lands on the installed plugin's own package.json, e.g.
141
+ * node_modules/@hegemonart/get-design-done), so when it preceded the cwd
142
+ * resolution an installed `gdd-dashboard` always showed the PLUGIN's own
143
+ * (empty) data instead of the user's project. We now resolve the user's
144
+ * project FIRST via a cwd-upward marker walk (the same D-05 algorithm the
145
+ * gdd-mcp tools use), and only fall back to the package root as a last
146
+ * resort. Running the dashboard INSIDE the gdd repo still works — the marker
147
+ * walk finds the repo's own .design/.planning, which IS the project root.
148
+ *
106
149
  * @param {{root?: string}} [opts]
107
150
  * @returns {string}
108
151
  */
109
152
  function resolveRoot(opts = {}) {
110
153
  if (opts.root) return path.resolve(opts.root);
111
154
  if (process.env.GDD_PROJECT_ROOT) return path.resolve(process.env.GDD_PROJECT_ROOT);
155
+ const fromCwd = cwdMarkerWalk();
156
+ if (fromCwd) return fromCwd;
112
157
  try {
113
158
  return packageRoot();
114
159
  } catch {
@@ -610,6 +655,7 @@ module.exports = {
610
655
  loadDashboardModel,
611
656
  // Exposed for tests + sibling reuse (executors D/F may want the scrapers).
612
657
  resolveRoot,
658
+ cwdMarkerWalk,
613
659
  scrapeStateFile,
614
660
  scrapeEventsFile,
615
661
  };
@@ -38,46 +38,128 @@ import type { BaseEvent } from './types.ts';
38
38
  // anchor createRequire on the repo-root package.json discovered by
39
39
  // walking up from `process.cwd()`.
40
40
  function _findRepoRoot(): string {
41
- let dir = process.cwd();
42
- for (let i = 0; i < 8; i++) {
41
+ return _walkToPackageJson(process.cwd());
42
+ }
43
+
44
+ /**
45
+ * Walk up from `startDir` until a directory containing `package.json` is
46
+ * found; returns `startDir` itself if none is found within the bound.
47
+ */
48
+ function _walkToPackageJson(startDir: string): string {
49
+ let dir = startDir;
50
+ for (let i = 0; i < 12; i++) {
43
51
  if (existsSync(join(dir, 'package.json'))) return dir;
44
52
  const parent = dirname(dir);
45
53
  if (parent === dir) break;
46
54
  dir = parent;
47
55
  }
48
- return process.cwd();
56
+ return startDir;
57
+ }
58
+
59
+ // S2 fix: redaction now fails CLOSED. Previously, if redact.cjs could not be
60
+ // resolved from the runtime cwd, `_redact` fell through to the IDENTITY
61
+ // function and every event was written UNSCRUBBED — silently leaking secrets
62
+ // into events.jsonl whenever the writer ran outside the plugin tree (hook
63
+ // subprocesses, temp test dirs, unusual install layouts). That is a fail-open
64
+ // security hole.
65
+ //
66
+ // New contract:
67
+ // * redact.cjs loads → normal deep-walk scrubbing (unchanged behavior).
68
+ // * redact.cjs MISSING → fail closed: `redact` returns an envelope-only
69
+ // placeholder that DROPS the payload body (replacing it with
70
+ // `{ _redaction_unavailable: true }`) so no raw payload is ever persisted
71
+ // unscrubbed. A single visible stderr warning is emitted (once per
72
+ // process, guarded by `_redactWarned`) so the failure is observable.
73
+ //
74
+ // Resolution is also improved: we try createRequire anchored on THIS module
75
+ // (via the runtime-resolved module path) before the cwd-anchored walk, so it
76
+ // loads in more layouts.
77
+
78
+ /** Module-level guard so the fail-closed warning prints at most once. */
79
+ let _redactWarned = false;
80
+
81
+ /** Emit the one-time fail-closed warning to stderr (guarded, best-effort). */
82
+ function _warnRedactUnavailable(): void {
83
+ if (_redactWarned) return;
84
+ _redactWarned = true;
85
+ try {
86
+ process.stderr.write(
87
+ '[event-stream] WARNING: scripts/lib/redact.cjs could not be loaded — ' +
88
+ 'failing CLOSED: event payloads are dropped (envelope-only) to avoid ' +
89
+ 'writing unscrubbed secrets. Run the event writer from inside the ' +
90
+ 'plugin tree or set the redact lib on PATH to restore full payloads.\n',
91
+ );
92
+ } catch {
93
+ // If stderr itself is broken we have no recourse; swallow.
94
+ }
49
95
  }
50
96
 
51
- // Soft load: if redact.cjs is unreachable from the runtime cwd (e.g. a
52
- // hook subprocess running in a temp test dir three directories above
53
- // the plugin root), fall through to the identity function. The writer
54
- // keeps working — events just aren't scrubbed in that environment.
55
- // Production callers always run from inside the plugin tree.
56
- let _redact: (v: unknown) => unknown;
57
- try {
58
- const _root = _findRepoRoot();
59
- const _candidate = resolve(_root, 'scripts/lib/redact.cjs');
60
- if (existsSync(_candidate)) {
61
- const _redactRequire = createRequire(join(_root, 'package.json'));
62
- const _mod = _redactRequire(_candidate) as { redact: (v: unknown) => unknown };
63
- _redact = _mod.redact;
64
- } else {
65
- // Fallback: also try walking up from this source file's logical
66
- // position (3 dirs above writer.ts repo root).
67
- const _altRoot = resolve(_root, '..', '..');
68
- const _altCandidate = resolve(_altRoot, 'scripts/lib/redact.cjs');
69
- if (existsSync(_altCandidate)) {
70
- const _altRequire = createRequire(join(_altRoot, 'package.json'));
71
- const _altMod = _altRequire(_altCandidate) as { redact: (v: unknown) => unknown };
72
- _redact = _altMod.redact;
73
- } else {
74
- _redact = (v) => v;
97
+ /**
98
+ * Attempt to load redact.cjs from a set of candidate roots. Returns the
99
+ * real `redact` function on success, or `null` if no candidate resolves.
100
+ */
101
+ function _loadRedact(): ((v: unknown) => unknown) | null {
102
+ const candidates: string[] = [];
103
+
104
+ // We cannot use `import.meta.url` (tsc Node16 classifies this .ts as CJS for
105
+ // typecheck), so we probe several anchors so redact.cjs loads in as many
106
+ // layouts as possible BEFORE the fail-closed path engages:
107
+ //
108
+ // 1) The entry script (`process.argv[1]`). For hook subprocesses (e.g.
109
+ // budget-enforcer.ts spawned by the harness) the entry script lives
110
+ // INSIDE the plugin tree even when cwd is a detached temp dir — this is
111
+ // the same anchor the hook itself uses via resolveHookPath(). Walking up
112
+ // from the entry script to its package.json reliably lands on the plugin
113
+ // root regardless of cwd.
114
+ // 2) A cwd-walked repo root (works when cwd IS inside the plugin tree).
115
+ // 3) The source-relative layout (writer.ts → ../../scripts/lib/redact.cjs).
116
+ const entry = process.argv[1];
117
+ if (typeof entry === 'string' && entry.length > 0) {
118
+ const entryAbs = isAbsolute(entry) ? entry : resolve(entry);
119
+ const entryRoot = _walkToPackageJson(dirname(entryAbs));
120
+ candidates.push(resolve(entryRoot, 'scripts/lib/redact.cjs'));
121
+ }
122
+ const repoRoot = _findRepoRoot();
123
+ candidates.push(resolve(repoRoot, 'scripts/lib/redact.cjs'));
124
+ candidates.push(resolve(repoRoot, '..', '..', 'scripts/lib/redact.cjs'));
125
+
126
+ for (const candidate of candidates) {
127
+ try {
128
+ if (!existsSync(candidate)) continue;
129
+ // Anchor createRequire on the candidate file itself so resolution does
130
+ // not depend on a package.json being present at a particular ancestor.
131
+ const req = createRequire(candidate);
132
+ const mod = req(candidate) as { redact?: (v: unknown) => unknown };
133
+ if (mod && typeof mod.redact === 'function') return mod.redact;
134
+ } catch {
135
+ // Try the next candidate.
75
136
  }
76
137
  }
77
- } catch {
78
- _redact = (v) => v;
138
+ return null;
79
139
  }
80
- const redact = _redact;
140
+
141
+ const _realRedact = _loadRedact();
142
+
143
+ /**
144
+ * The redaction function used at the write boundary. When the real redactor
145
+ * loaded, this is it. When it did not, this is the FAIL-CLOSED shim: it warns
146
+ * once and returns an envelope-only object with the payload body dropped.
147
+ */
148
+ const redact: (v: unknown) => unknown =
149
+ _realRedact !== null
150
+ ? _realRedact
151
+ : (v: unknown): unknown => {
152
+ _warnRedactUnavailable();
153
+ if (v !== null && typeof v === 'object') {
154
+ // Preserve envelope metadata; drop the payload body entirely so no
155
+ // raw (potentially secret-bearing) content is persisted.
156
+ const ev = v as Record<string, unknown>;
157
+ const out: Record<string, unknown> = { ...ev };
158
+ out['payload'] = { _redaction_unavailable: true };
159
+ return out;
160
+ }
161
+ return { _redaction_unavailable: true };
162
+ };
81
163
 
82
164
  /** Default relative path for the persisted event stream. */
83
165
  export const DEFAULT_EVENTS_PATH = '.design/telemetry/events.jsonl';