@tekyzinc/gsd-t 5.6.10 → 5.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.7.10] - 2026-08-03
6
+
7
+ ### Added — one session per working tree, enforced (M105)
8
+
9
+ Several sessions ran against one project, each told to use its own git worktree. Three did. One worked directly in the main project folder, and its uncommitted work interleaved with another session's on the same branch — neither side able to commit or merge without dragging in or losing the other's half-finished milestone. The instruction was given; nothing enforced it.
10
+
11
+ - `scripts/gsd-t-worktree-guard.js`: a PreToolUse hook on Write/Edit. Blocks an edit in the MAIN working tree when another GSD-T session is live there, and prints the exact `git worktree add` command — including the stash-and-carry form when the session already has uncommitted work. Silent when alone in main (working there solo is fine), silent inside a worktree, silent outside a git repo, and fail-open on any internal error: it detects a collision, it does not gate correctness.
12
+ - Liveness reuses the per-session `.gsd-t/heartbeat-<id>.jsonl` file that the SessionStart/Stop/SessionEnd hooks already write INTO the tree the session works in. Its location is the claim; its modification time is the signal. No new bookkeeping.
13
+ - **The liveness window is the load-bearing detail.** A first pass using two hours read three long-closed sessions as live and would have fired on a user working alone — the false positive that teaches someone to disable a guard. The window is five minutes: a working session writes constantly.
14
+ - `bin/gsd-t.js`: the architect-hook registrar is generalized to take a marker + command, so registering a second Write|Edit hook needed no duplicated code. Registered on install, stripped on uninstall.
15
+ - `test/m105-worktree-guard.test.js`: 13 tests. The fail-open case caught a real hole — unparseable hook input fell back to `process.cwd()`, so the guard judged whichever directory it happened to launch from rather than the one under edit. Unreadable input now means no decision.
16
+
17
+ Opt out per project with `.gsd-t/worktree-guard-config.json` `{"enabled": false}`. An invalid config leaves the guard ON. Suite 3120/0/13-skip.
18
+
19
+ ## [5.6.11] - 2026-08-02
20
+
21
+ ### Fixed — four bugs that reported a healthy code graph as broken (M104)
22
+
23
+ A partition and plan phase halted four separate times with `graph BROKEN` while the graph was fine — exit 0, `ok:true`, compiler-accurate. Four distinct bugs, all producing the identical message, each sitting downstream of the last, so every fix only revealed the next.
24
+
25
+ - `templates/workflows/gsd-t-phase.workflow.js`:
26
+ - **The verb map pointed five phases at graph queries that require a target.** `plan`, `impact`, `feature`, `populate` and `promote-debt` mapped to `who-imports` / `blast-radius`, which return `{"ok":false,"reason":"missing-target"}` when called without one. A comment above the map asserted these "return the full graph slice" without a target — that was never true, and nobody checked it against the CLI. **These five phases never once completed their graph query in any project since the map shipped.** All now use `cluster`, which answers target-free.
27
+ - **The verb travelled in the wrong argument.** `runCli` builds the local-bin command from `argv` alone, so with the verb in the subcommand string and `argv` empty, any project carrying its own `bin/gsd-t-graph-query-cli.cjs` ran it with no verb at all → `{"ok":false,"reason":"no-verb"}`. This failed only in projects with a local copy, which is why it stayed invisible elsewhere.
28
+ - `gsd-t-phase`, `-integrate`, `-debug`, `-execute`, `-quick`, `-verify`: the CLI-result envelope was typed as `{}` — a schema accepting anything. A helper agent that returned the raw JSON *text*, or omitted the field and left the JSON in `stdout`, validated as cleanly as a correct parse; callers then read `ok` on a string or on nothing, got `undefined`, and failed closed on a healthy command. The field is now typed `object|null`, and `_coerceCliResult` normalizes both shapes at the single point every caller passes through. A genuinely failing command still fails; non-JSON output is left alone.
29
+ - `test/m104-phase-graph-verb-map.test.js`: 24 tests. Every mapped verb must answer target-free; the false comment cannot return; the verb must travel in `argv`; all six workflows must type their envelope and actually *call* the normalizer (defined-but-unwired fails).
30
+ - `test/m94-d10-reader-wiring.test.js`: rewritten. It previously hardcoded the broken pairs (`plan → who-imports`, `impact → blast-radius`) and asserted they were present — passing on every run while those phases were dead. Restating a buggy value in a test is what let the bug live; it now asserts coverage and leaves verb correctness to the M104 tests, which check the CLI's real behaviour.
31
+
32
+ Five other workflows carried the same permissive schema and could have failed identically — the phase workflow simply makes the largest graph query and hit it first. Suite 3107/0/13-skip.
33
+
5
34
  ## [5.6.10] - 2026-08-02
6
35
 
7
36
  ### Changed — the Environment Registry now fires (M103)
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.6.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.7.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
4
4
 
5
5
  **Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
6
6
  **Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
package/bin/gsd-t.js CHANGED
@@ -494,6 +494,17 @@ const ARCHITECT_HOOK_MARKER = "gsd-t-architect-oversight-guard";
494
494
  const ARCHITECT_HOOK_COMMAND =
495
495
  'bash -c \'[ -f "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-architect-oversight-guard.js" ] && node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-architect-oversight-guard.js" || true\'';
496
496
 
497
+ // M105 — worktree-collision PreToolUse hook on Write|Edit. Same global-package
498
+ // pattern. Blocks an edit when ANOTHER GSD-T session is live in the SAME working
499
+ // tree (detected from the per-session heartbeat files already written into
500
+ // .gsd-t/), and tells the user the exact `git worktree add` command to run.
501
+ // Silent when working alone, silent inside a worktree, silent outside a repo,
502
+ // and fail-open on any internal error — it detects a collision, it does not gate
503
+ // correctness.
504
+ const WORKTREE_HOOK_MARKER = "gsd-t-worktree-guard";
505
+ const WORKTREE_HOOK_COMMAND =
506
+ 'bash -c \'[ -f "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-worktree-guard.js" ] && node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-worktree-guard.js" || true\'';
507
+
497
508
  // Append entries to {projectDir}/.gitignore. Each entry added only if absent.
498
509
  // Idempotent. Returns true if any entries were added, false otherwise.
499
510
  function ensureGitignoreEntries(projectDir, entries) {
@@ -942,6 +953,17 @@ function configureReadInterceptHook(settingsPath) {
942
953
  // read-intercept installer but on PreToolUse. The script fails-open so this is
943
954
  // safe globally (silent in non-GSD-T projects and on prose/doc writes).
944
955
  function configureArchitectHook(settingsPath) {
956
+ return configureWriteEditHook(settingsPath, ARCHITECT_HOOK_MARKER, ARCHITECT_HOOK_COMMAND, "architect");
957
+ }
958
+
959
+ // M105 — register the worktree-collision guard. Reuses the same registrar rather
960
+ // than copying it (the function below was architect-specific; generalized to take
961
+ // the marker + command, so a third Write|Edit hook needs no new code).
962
+ function configureWorktreeGuardHook(settingsPath) {
963
+ return configureWriteEditHook(settingsPath, WORKTREE_HOOK_MARKER, WORKTREE_HOOK_COMMAND, "worktree guard");
964
+ }
965
+
966
+ function configureWriteEditHook(settingsPath, marker, command, label) {
945
967
  const targetPath = settingsPath || SETTINGS_JSON;
946
968
  let settings = {};
947
969
  if (fs.existsSync(targetPath)) {
@@ -949,21 +971,21 @@ function configureArchitectHook(settingsPath) {
949
971
  settings = JSON.parse(fs.readFileSync(targetPath, "utf8"));
950
972
  if (!settings || typeof settings !== "object") settings = {};
951
973
  } catch {
952
- warn("settings.json has invalid JSON — cannot configure architect hook");
974
+ warn(`settings.json has invalid JSON — cannot configure ${label} hook`);
953
975
  return { installed: false, action: "noop" };
954
976
  }
955
977
  }
956
978
  if (!settings.hooks) settings.hooks = {};
957
979
  if (!Array.isArray(settings.hooks.PreToolUse)) settings.hooks.PreToolUse = [];
958
980
 
959
- const cmd = ARCHITECT_HOOK_COMMAND;
981
+ const cmd = command;
960
982
  let action = "noop";
961
983
  let found = false;
962
984
  for (const entry of settings.hooks.PreToolUse) {
963
985
  if (!entry || !Array.isArray(entry.hooks)) continue;
964
986
  for (const h of entry.hooks) {
965
987
  if (!h || typeof h.command !== "string") continue;
966
- if (h.command === cmd || h.command.includes(ARCHITECT_HOOK_MARKER)) {
988
+ if (h.command === cmd || h.command.includes(marker)) {
967
989
  found = true;
968
990
  if (h.command !== cmd) { h.command = cmd; action = "updated"; }
969
991
  if (entry.matcher !== "Write|Edit") { entry.matcher = "Write|Edit"; action = action === "noop" ? "updated" : action; }
@@ -1007,7 +1029,7 @@ function removeInterceptHooks(settingsPath) {
1007
1029
  if (!settings.hooks) return false;
1008
1030
 
1009
1031
  const postMarkers = [GRAPH_INTERCEPT_HOOK_MARKER, READ_INTERCEPT_HOOK_MARKER];
1010
- const preMarkers = [ARCHITECT_HOOK_MARKER];
1032
+ const preMarkers = [ARCHITECT_HOOK_MARKER, WORKTREE_HOOK_MARKER];
1011
1033
  let removed = 0;
1012
1034
 
1013
1035
  const stripByMarkers = (arr, markers) => {
@@ -2006,6 +2028,13 @@ async function doInstall(opts = {}) {
2006
2028
  else info("Architect-oversight hook already configured");
2007
2029
  }
2008
2030
 
2031
+ const wtHook = configureWorktreeGuardHook(SETTINGS_JSON);
2032
+ if (wtHook.installed) {
2033
+ if (wtHook.action === "added") success("Worktree-collision guard added (blocks a second session editing the same tree — M105)");
2034
+ else if (wtHook.action === "updated") success("Worktree-collision guard refreshed");
2035
+ else info("Worktree-collision guard already configured");
2036
+ }
2037
+
2009
2038
  heading("Graph Engine (CGC)");
2010
2039
  installCgc();
2011
2040
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.6.10",
3
+ "version": "5.7.10",
4
4
  "description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
5
5
  "author": "Tekyz, Inc.",
6
6
  "license": "MIT",
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * gsd-t-worktree-guard.js — PreToolUse(Write|Edit)
6
+ *
7
+ * Stops two sessions from editing the SAME working tree at the same time.
8
+ *
9
+ * The problem it solves: a session opened in the main project folder edits files
10
+ * there; a second session opens in the same folder and does the same. Their
11
+ * uncommitted work interleaves in one tree, on one branch, and neither can commit
12
+ * or merge without dragging in the other's half-finished milestone. Telling each
13
+ * session "use a worktree" does not prevent it — nothing enforces the instruction.
14
+ *
15
+ * How it detects a live session, and why: every GSD-T session writes
16
+ * `.gsd-t/heartbeat-<session-id>.jsonl` INSIDE the tree it is working in, via the
17
+ * SessionStart/Stop/SessionEnd hooks. The file's location IS the claim (a session
18
+ * in a worktree writes into that worktree's own .gsd-t/), and its modification
19
+ * time is the liveness signal. No new bookkeeping — the file already exists.
20
+ *
21
+ * Liveness is a SHORT window (default 5 min). A working session writes constantly;
22
+ * one silent for longer is idle or closed. This matters more than it looks: a
23
+ * 2-hour window reads three closed sessions as live and fires on a user working
24
+ * alone, which trains them to disable the guard.
25
+ *
26
+ * Behaviour:
27
+ * - alone in any tree → silent, no guard (working in main alone is allowed)
28
+ * - main tree, another session live → BLOCK with the exact worktree command to run
29
+ * - inside a worktree → silent (already isolated)
30
+ *
31
+ * Fail-open by design: a guard that cannot read its inputs must not block edits.
32
+ * It is a collision detector, not a correctness gate — a crash here would stop
33
+ * legitimate work for no safety benefit.
34
+ *
35
+ * Opt out per project: .gsd-t/worktree-guard-config.json {"enabled": false}
36
+ */
37
+
38
+ const fs = require("fs");
39
+ const path = require("path");
40
+ const { execFileSync } = require("child_process");
41
+
42
+ const LIVE_WINDOW_MS = 5 * 60 * 1000;
43
+
44
+ // Returns null when the hook payload cannot be read or parsed.
45
+ //
46
+ // This must NOT fall back to an empty object: `cwd` would then default to
47
+ // process.cwd() and the guard would judge whatever directory it happened to be
48
+ // launched from — deciding about the wrong repository entirely. Caught by the
49
+ // fail-open test, which saw a deny emitted for garbage input.
50
+ // Unreadable input means NO DECISION, never a decision about the wrong tree.
51
+ function readHookInput() {
52
+ try {
53
+ const raw = fs.readFileSync(0, "utf8");
54
+ if (!raw || !raw.trim()) return null;
55
+ const parsed = JSON.parse(raw);
56
+ return parsed && typeof parsed === "object" ? parsed : null;
57
+ } catch (_) {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function git(args, cwd) {
63
+ try {
64
+ return execFileSync("git", args, {
65
+ cwd,
66
+ encoding: "utf8",
67
+ stdio: ["ignore", "pipe", "ignore"],
68
+ timeout: 5000,
69
+ }).trim();
70
+ } catch (_) {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ // The MAIN working tree is the one whose .git is a directory. A linked worktree's
76
+ // .git is a FILE containing a gitdir: pointer — that is the distinction git itself
77
+ // uses, so it needs no parsing of `git worktree list` output.
78
+ function isMainWorktree(root) {
79
+ try {
80
+ return fs.statSync(path.join(root, ".git")).isDirectory();
81
+ } catch (_) {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function guardEnabled(root) {
87
+ try {
88
+ const cfg = JSON.parse(
89
+ fs.readFileSync(path.join(root, ".gsd-t", "worktree-guard-config.json"), "utf8")
90
+ );
91
+ return cfg.enabled !== false;
92
+ } catch (_) {
93
+ return true; // absent/invalid config → guard on
94
+ }
95
+ }
96
+
97
+ // Sessions whose heartbeat is fresh, excluding this one.
98
+ function liveSessions(root, selfSid, now) {
99
+ const dir = path.join(root, ".gsd-t");
100
+ let names;
101
+ try {
102
+ names = fs.readdirSync(dir);
103
+ } catch (_) {
104
+ return [];
105
+ }
106
+ const live = [];
107
+ for (const f of names) {
108
+ if (!f.startsWith("heartbeat-") || !f.endsWith(".jsonl")) continue;
109
+ const sid = f.slice("heartbeat-".length, -".jsonl".length);
110
+ if (selfSid && sid === selfSid) continue;
111
+ try {
112
+ const age = now - fs.statSync(path.join(dir, f)).mtimeMs;
113
+ if (age < LIVE_WINDOW_MS) live.push({ sid, ageMs: age });
114
+ } catch (_) { /* unreadable → not evidence of a live session */ }
115
+ }
116
+ return live.sort((a, b) => a.ageMs - b.ageMs);
117
+ }
118
+
119
+ function suggestWorktreeName(branch) {
120
+ const base = (branch || "work").replace(/[^A-Za-z0-9._-]/g, "-").replace(/^-+|-+$/g, "");
121
+ return base || "work";
122
+ }
123
+
124
+ function main() {
125
+ const hook = readHookInput();
126
+ if (!hook) return; // no readable payload → no decision (see readHookInput)
127
+ const cwd = hook.cwd;
128
+ if (!cwd || typeof cwd !== "string") return; // no stated directory → nothing to judge
129
+
130
+ const root = git(["rev-parse", "--show-toplevel"], cwd);
131
+ if (!root) return; // not a git repo → nothing to guard
132
+
133
+ if (!isMainWorktree(root)) return; // already isolated in a worktree
134
+ if (!guardEnabled(root)) return;
135
+
136
+ const selfSid = hook.session_id || hook.sessionId || null;
137
+ const others = liveSessions(root, selfSid, Date.now());
138
+ if (others.length === 0) return; // alone → working in main is allowed
139
+
140
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], root) || "HEAD";
141
+ const project = path.basename(root);
142
+ const name = suggestWorktreeName(branch);
143
+ const wt = `${process.env.HOME}/Worktrees/${project}/${name}`;
144
+ const mins = Math.max(1, Math.round(others[0].ageMs / 60000));
145
+ const plural = others.length === 1 ? "session" : "sessions";
146
+
147
+ const reason = [
148
+ `Another GSD-T ${plural} (${others.length}) is working in this same folder right now — the most recent wrote ${mins} minute(s) ago.`,
149
+ ``,
150
+ `Editing here means two sessions share one working tree and one branch. Their uncommitted changes interleave, and neither can commit or merge without dragging in the other's half-finished work.`,
151
+ ``,
152
+ `Move to your own worktree first:`,
153
+ ``,
154
+ ` mkdir -p ${process.env.HOME}/Worktrees/${project}`,
155
+ ` git worktree add ${wt} -b ${name}-$(date +%H%M)`,
156
+ ` cd ${wt}`,
157
+ ``,
158
+ `If you already have uncommitted work in this folder, carry it across:`,
159
+ ``,
160
+ ` git stash push -u -m "moving to a worktree"`,
161
+ ` git worktree add ${wt} -b ${name}-$(date +%H%M)`,
162
+ ` cd ${wt} && git stash pop`,
163
+ ``,
164
+ `Working alone in the main folder is fine — this only fires when a second session is live.`,
165
+ `To turn it off for this project: .gsd-t/worktree-guard-config.json {"enabled": false}`,
166
+ ].join("\n");
167
+
168
+ process.stdout.write(
169
+ JSON.stringify({
170
+ hookSpecificOutput: {
171
+ hookEventName: "PreToolUse",
172
+ permissionDecision: "deny",
173
+ permissionDecisionReason: reason,
174
+ },
175
+ }) + "\n"
176
+ );
177
+ }
178
+
179
+ try {
180
+ main();
181
+ } catch (_) {
182
+ // Fail open — never block an edit because the guard itself broke.
183
+ }
@@ -150,6 +150,8 @@ WHEN creating a worktree directly (git worktree add, isolation: "worktree", etc.
150
150
  - Clean up with `git worktree remove` when the branch/task is done — don't leave prunable stragglers.
151
151
  - **Exception**: harness-managed worktrees the Agent/Workflow runtime creates under the project's gitignored `.claude/worktrees/` path are the harness's own convention — leave those alone. This rule governs worktrees *you* create directly via Bash or the `isolation: "worktree"` option.
152
152
 
153
+ **One session per working tree (M105 — enforced).** Two sessions editing the same folder interleave their uncommitted work on one branch: neither can commit or merge without dragging in or losing the other's half-finished milestone. A PreToolUse guard (`scripts/gsd-t-worktree-guard.js`) BLOCKS a Write/Edit in the main tree when another GSD-T session is live there, and prints the exact `git worktree add` command (including the stash-and-carry form when you already have uncommitted work). Liveness comes from the per-session `.gsd-t/heartbeat-<id>.jsonl` file — location is the claim, mtime is liveness, 5-minute window. It is SILENT when you are alone in the main tree (working there solo is fine), silent inside a worktree, and fails open. Opt out per project: `.gsd-t/worktree-guard-config.json` `{"enabled": false}`.
154
+
153
155
  # Destructive Action Guard (MANDATORY)
154
156
 
155
157
  **NEVER perform destructive or structural changes without explicit user approval.** This applies at ALL autonomy levels, including Level 3.
@@ -61,8 +61,34 @@ const _args = (typeof args === "string") ? (() => { try { return JSON.parse(args
61
61
  const overrides = (_args.overrides && typeof _args.overrides === "object") ? _args.overrides : {};
62
62
  const _CLI_ENVELOPE_SCHEMA = {
63
63
  type: "object", required: ["ok", "exitCode"], additionalProperties: true,
64
- properties: { ok: { type: "boolean" }, exitCode: { type: "integer" }, envelope: {}, stdout: { type: "string" }, stderr: { type: "string" }, via: { type: "string" } },
64
+ properties: { ok: { type: "boolean" }, exitCode: { type: "integer" }, envelope: { type: ["object", "null"], description: "stdout PARSED as JSON — an object, never the raw text" }, stdout: { type: "string" }, stderr: { type: "string" }, via: { type: "string" } },
65
65
  };
66
+ // ─── Normalize a helper-agent CLI result (M104) ──────────────────────────────
67
+ //
68
+ // runCli asks a haiku helper to run a command and hand back the parsed JSON in
69
+ // `envelope`. A model doing that job is not perfectly reliable, and two broken
70
+ // shapes both read `env.ok === undefined` — which every caller treats as failure,
71
+ // so a HEALTHY CLI reports as broken:
72
+ // (a) `envelope` holds the raw JSON TEXT rather than the parsed object
73
+ // (b) `envelope` is absent and the JSON only reached `stdout`
74
+ // Observed live 2026-08-02: a partition/plan halted four times on "graph BROKEN"
75
+ // while the graph was fine (exit 0, ok:true).
76
+ //
77
+ // NOT a fallback: it continues past no failure and substitutes no guess — it reads
78
+ // the result the CLI actually returned. A failing CLI still returns ok:false and
79
+ // still fails; non-JSON stdout is left alone.
80
+ function _coerceCliResult(x) {
81
+ if (!x) return x;
82
+ if (typeof x.envelope === "string") {
83
+ try { x.envelope = JSON.parse(x.envelope); } catch (_) { /* genuinely not JSON */ }
84
+ }
85
+ if ((x.envelope === undefined || x.envelope === null) &&
86
+ typeof x.stdout === "string" && x.stdout.trim().startsWith("{")) {
87
+ try { x.envelope = JSON.parse(x.stdout); } catch (_) { /* not JSON */ }
88
+ }
89
+ return x;
90
+ }
91
+
66
92
  async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = true, phaseName) {
67
93
  const argStr = (argv || []).map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ");
68
94
  const prompt = [
@@ -75,7 +101,7 @@ async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = tru
75
101
  const opts = { label, schema: _CLI_ENVELOPE_SCHEMA, model: "haiku" };
76
102
  if (phaseName) opts.phase = phaseName;
77
103
  const r = await agent(prompt, opts).catch((e) => ({ ok: false, exitCode: -1, envelope: null, stderr: String(e && e.message), via: "error" }));
78
- return r || { ok: false, exitCode: -1, envelope: null, via: "error" };
104
+ return _coerceCliResult(r) || { ok: false, exitCode: -1, envelope: null, via: "error" };
79
105
  }
80
106
  async function runPreflight(projectDir, label = "preflight", phaseName) { return runCli(projectDir, "preflight", ["--json"], "cli-preflight.cjs", label, true, phaseName); }
81
107
  async function generateBrief(projectDir, { kind = "execute", milestone, domain, id, label = "brief", phaseName } = {}) {
@@ -190,8 +190,34 @@ const INTEGRATE_RESULT_SCHEMA = {
190
190
  const _args = (typeof args === "string") ? (() => { try { return JSON.parse(args); } catch { return {}; } })() : (args || {});
191
191
  const _CLI_ENVELOPE_SCHEMA = {
192
192
  type: "object", required: ["ok", "exitCode"], additionalProperties: true,
193
- properties: { ok: { type: "boolean" }, exitCode: { type: "integer" }, envelope: {}, stdout: { type: "string" }, stderr: { type: "string" }, via: { type: "string" } },
193
+ properties: { ok: { type: "boolean" }, exitCode: { type: "integer" }, envelope: { type: ["object", "null"], description: "stdout PARSED as JSON — an object, never the raw text" }, stdout: { type: "string" }, stderr: { type: "string" }, via: { type: "string" } },
194
194
  };
195
+ // ─── Normalize a helper-agent CLI result (M104) ──────────────────────────────
196
+ //
197
+ // runCli asks a haiku helper to run a command and hand back the parsed JSON in
198
+ // `envelope`. A model doing that job is not perfectly reliable, and two broken
199
+ // shapes both read `env.ok === undefined` — which every caller treats as failure,
200
+ // so a HEALTHY CLI reports as broken:
201
+ // (a) `envelope` holds the raw JSON TEXT rather than the parsed object
202
+ // (b) `envelope` is absent and the JSON only reached `stdout`
203
+ // Observed live 2026-08-02: a partition/plan halted four times on "graph BROKEN"
204
+ // while the graph was fine (exit 0, ok:true).
205
+ //
206
+ // NOT a fallback: it continues past no failure and substitutes no guess — it reads
207
+ // the result the CLI actually returned. A failing CLI still returns ok:false and
208
+ // still fails; non-JSON stdout is left alone.
209
+ function _coerceCliResult(x) {
210
+ if (!x) return x;
211
+ if (typeof x.envelope === "string") {
212
+ try { x.envelope = JSON.parse(x.envelope); } catch (_) { /* genuinely not JSON */ }
213
+ }
214
+ if ((x.envelope === undefined || x.envelope === null) &&
215
+ typeof x.stdout === "string" && x.stdout.trim().startsWith("{")) {
216
+ try { x.envelope = JSON.parse(x.stdout); } catch (_) { /* not JSON */ }
217
+ }
218
+ return x;
219
+ }
220
+
195
221
  async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = true, phaseName) {
196
222
  const argStr = (argv || []).map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ");
197
223
  const prompt = [
@@ -204,7 +230,7 @@ async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = tru
204
230
  const opts = { label, schema: _CLI_ENVELOPE_SCHEMA, model: "haiku" };
205
231
  if (phaseName) opts.phase = phaseName;
206
232
  const r = await agent(prompt, opts).catch((e) => ({ ok: false, exitCode: -1, envelope: null, stderr: String(e && e.message), via: "error" }));
207
- return r || { ok: false, exitCode: -1, envelope: null, via: "error" };
233
+ return _coerceCliResult(r) || { ok: false, exitCode: -1, envelope: null, via: "error" };
208
234
  }
209
235
  async function runPreflight(projectDir, label = "preflight", phaseName) { return runCli(projectDir, "preflight", ["--json"], "cli-preflight.cjs", label, true, phaseName); }
210
236
  async function runVerifyGate(projectDir, label = "verify-gate", phaseName) { return runCli(projectDir, "verify-gate", ["--json"], "gsd-t-verify-gate.cjs", label, true, phaseName); }
@@ -22,8 +22,34 @@ export const meta = {
22
22
  const _args = (typeof args === "string") ? (() => { try { return JSON.parse(args); } catch { return {}; } })() : (args || {});
23
23
  const _CLI_ENVELOPE_SCHEMA = {
24
24
  type: "object", required: ["ok", "exitCode"], additionalProperties: true,
25
- properties: { ok: { type: "boolean" }, exitCode: { type: "integer" }, envelope: {}, stdout: { type: "string" }, stderr: { type: "string" }, via: { type: "string" } },
25
+ properties: { ok: { type: "boolean" }, exitCode: { type: "integer" }, envelope: { type: ["object", "null"], description: "stdout PARSED as JSON — an object, never the raw text" }, stdout: { type: "string" }, stderr: { type: "string" }, via: { type: "string" } },
26
26
  };
27
+ // ─── Normalize a helper-agent CLI result (M104) ──────────────────────────────
28
+ //
29
+ // runCli asks a haiku helper to run a command and hand back the parsed JSON in
30
+ // `envelope`. A model doing that job is not perfectly reliable, and two broken
31
+ // shapes both read `env.ok === undefined` — which every caller treats as failure,
32
+ // so a HEALTHY CLI reports as broken:
33
+ // (a) `envelope` holds the raw JSON TEXT rather than the parsed object
34
+ // (b) `envelope` is absent and the JSON only reached `stdout`
35
+ // Observed live 2026-08-02: a partition/plan halted four times on "graph BROKEN"
36
+ // while the graph was fine (exit 0, ok:true).
37
+ //
38
+ // NOT a fallback: it continues past no failure and substitutes no guess — it reads
39
+ // the result the CLI actually returned. A failing CLI still returns ok:false and
40
+ // still fails; non-JSON stdout is left alone.
41
+ function _coerceCliResult(x) {
42
+ if (!x) return x;
43
+ if (typeof x.envelope === "string") {
44
+ try { x.envelope = JSON.parse(x.envelope); } catch (_) { /* genuinely not JSON */ }
45
+ }
46
+ if ((x.envelope === undefined || x.envelope === null) &&
47
+ typeof x.stdout === "string" && x.stdout.trim().startsWith("{")) {
48
+ try { x.envelope = JSON.parse(x.stdout); } catch (_) { /* not JSON */ }
49
+ }
50
+ return x;
51
+ }
52
+
27
53
  async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = true, phaseName) {
28
54
  const argStr = (argv || []).map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ");
29
55
  const prompt = [
@@ -36,7 +62,7 @@ async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = tru
36
62
  const opts = { label, schema: _CLI_ENVELOPE_SCHEMA, model: "haiku" };
37
63
  if (phaseName) opts.phase = phaseName;
38
64
  const r = await agent(prompt, opts).catch((e) => ({ ok: false, exitCode: -1, envelope: null, stderr: String(e && e.message), via: "error" }));
39
- return r || { ok: false, exitCode: -1, envelope: null, via: "error" };
65
+ return _coerceCliResult(r) || { ok: false, exitCode: -1, envelope: null, via: "error" };
40
66
  }
41
67
  async function runPreflight(projectDir, label = "preflight", phaseName) { return runCli(projectDir, "preflight", ["--json"], "cli-preflight.cjs", label, true, phaseName); }
42
68
  // Broken-Graph-Halts (EXEMPT carve-out): integrate is additive/announced — it does not
@@ -85,12 +85,57 @@ const _args = (typeof args === "string") ? (() => { try { return JSON.parse(args
85
85
  // are tier ALIASES. The sandbox runtime accepts BOTH forms in model: — proven live for
86
86
  // the tier alias resolves to claude-opus-5 (Fable removed 2026-07-24).
87
87
  const overrides = (_args.overrides && typeof _args.overrides === "object") ? _args.overrides : {};
88
+ // `envelope` is typed as an OBJECT (or null), not "any".
89
+ //
90
+ // It used to be `envelope: {}` — a schema that accepts everything. A helper that
91
+ // returned the raw JSON *text*, or omitted the field entirely, passed validation
92
+ // just as cleanly as a correct parse; every caller then read `env.ok` on a string
93
+ // or on undefined, got undefined, and failed closed on a HEALTHY CLI. Three
94
+ // separate live halts traced back to this one permissive field.
95
+ //
96
+ // Typing it means the model is told the shape and a wrong shape is visible rather
97
+ // than silently equivalent. `_coerceCliResult` still normalizes the two shapes
98
+ // seen in the wild — the type is the specification, the coercion is the repair.
88
99
  const _CLI_ENVELOPE_SCHEMA = {
89
100
  type: "object", required: ["ok", "exitCode"], additionalProperties: true,
90
- properties: { ok: { type: "boolean" }, exitCode: { type: "integer" }, envelope: {}, stdout: { type: "string" }, stderr: { type: "string" }, via: { type: "string" } },
101
+ properties: {
102
+ ok: { type: "boolean" },
103
+ exitCode: { type: "integer" },
104
+ envelope: { type: ["object", "null"], description: "stdout PARSED as JSON — an object, never the raw text" },
105
+ stdout: { type: "string" },
106
+ stderr: { type: "string" },
107
+ via: { type: "string" },
108
+ },
91
109
  };
92
110
  // Single-quote a value for safe shell interpolation (Red Team MED-5).
93
111
  function _shq(s) { return `'${String(s).replace(/'/g, "'\\''")}'`; }
112
+ // ─── Normalize a helper-agent CLI result ─────────────────────────────────────
113
+ //
114
+ // `runCli` asks a haiku helper to run a command and hand back the parsed JSON in
115
+ // `envelope`. A language model doing that job is not perfectly reliable, and the
116
+ // schema types `envelope` as "any" — so THREE different shapes all validate and
117
+ // only one is usable. Both broken shapes read `env.ok === undefined`, which every
118
+ // caller treats as failure, so a HEALTHY CLI reports as broken:
119
+ // (a) `envelope` holds the raw JSON TEXT rather than the parsed object
120
+ // (b) `envelope` is absent and the JSON only reached `stdout`
121
+ // Observed in the wild 2026-08-02: a binvoice partition halted three times on
122
+ // "graph BROKEN" while the graph was fine (exit 0, ok:true).
123
+ //
124
+ // This normalizes both shapes at the ONE place every caller passes through. It is
125
+ // NOT a fallback: it does not continue past a failure or substitute a guess — it
126
+ // reads the result the CLI actually returned. A genuinely failing CLI still
127
+ // returns `ok:false` and still fails; a non-JSON stdout is left alone.
128
+ function _coerceCliResult(x) {
129
+ if (!x) return x;
130
+ if (typeof x.envelope === "string") {
131
+ try { x.envelope = JSON.parse(x.envelope); } catch (_) { /* genuinely not JSON — leave it */ }
132
+ }
133
+ if ((x.envelope === undefined || x.envelope === null) &&
134
+ typeof x.stdout === "string" && x.stdout.trim().startsWith("{")) {
135
+ try { x.envelope = JSON.parse(x.stdout); } catch (_) { /* not JSON — leave envelope absent */ }
136
+ }
137
+ return x;
138
+ }
94
139
  async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = true, phaseNameOpt) {
95
140
  const argStr = (argv || []).map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ");
96
141
  const prompt = [
@@ -108,14 +153,14 @@ async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = tru
108
153
  // while a transient helper miss is recovered. Only retry when JSON was expected (parseJson)
109
154
  // and the parsed result is absent; never retry on a clean exit that simply returned no JSON.
110
155
  const runOnce = () => agent(prompt, opts).catch((e) => ({ ok: false, exitCode: -1, envelope: null, stderr: String(e && e.message), via: "error" }));
111
- let r = await runOnce();
156
+ let r = _coerceCliResult(await runOnce());
112
157
  // Retry once when JSON was expected but no parsed result came back — covers both the
113
158
  // throw path (via="error") and a malformed return the loose schema let through (ok=false
114
159
  // with the result absent). A real CLI failure that returned valid JSON (envelope present,
115
160
  // ok=false) is NOT retried — that is a true result, not a transient miss.
116
161
  const missingResult = (x) => !x || (parseJson && (x.envelope === undefined || x.envelope === null) && x.ok !== true);
117
162
  if (missingResult(r)) {
118
- r = await runOnce();
163
+ r = _coerceCliResult(await runOnce());
119
164
  }
120
165
  return r || { ok: false, exitCode: -1, envelope: null, via: "error" };
121
166
  }
@@ -175,15 +220,28 @@ async function generateBrief(projectDir, { kind = "execute", milestone, domain,
175
220
  // promote-debt → blast-radius (scope a debt item's reach)
176
221
  // prd → cluster (structure-aware decomposition)
177
222
  // milestone/discuss/design-decompose/doc-ripple → no structural verb (no-op)
223
+ // A PHASE-level query takes NO target — it wants the whole structural picture, not
224
+ // one file's neighbourhood. Only `cluster` and `dead-code` answer target-free;
225
+ // `who-imports` / `blast-radius` / `who-calls` / `body` REQUIRE a target and return
226
+ // {"ok":false,"reason":"missing-target"} without one.
227
+ //
228
+ // The original map used who-imports/blast-radius for five phases, under a comment
229
+ // asserting they "return the full graph slice" without a target. That was never
230
+ // true — verified against the live CLI. So plan / impact / feature / populate /
231
+ // promote-debt failed their graph query on EVERY run in EVERY project since the map
232
+ // shipped, each one halting with "graph BROKEN" on a perfectly healthy graph.
233
+ // Fixed 2026-08-02 (binvoice S2-M14 plan halted on reason=missing-target).
234
+ //
235
+ // A mapped verb MUST answer target-free. Enforced by test/m104-phase-graph-verb-map.test.js.
178
236
  const PHASE_GRAPH_VERB_MAP = {
179
- impact: "blast-radius",
180
- plan: "who-imports",
237
+ impact: "cluster",
238
+ plan: "cluster",
181
239
  partition: "cluster",
182
- feature: "blast-radius",
240
+ feature: "cluster",
183
241
  "gap-analysis": "dead-code",
184
242
  project: "cluster",
185
- populate: "who-imports",
186
- "promote-debt": "blast-radius",
243
+ populate: "cluster",
244
+ "promote-debt": "cluster",
187
245
  prd: "cluster",
188
246
  };
189
247
 
@@ -203,11 +261,18 @@ async function queryStructuralSlice(projectDir, phaseName, phaseNameOpt, _rebuil
203
261
  // Phase has no mapped structural verb (milestone/discuss/design-decompose/doc-ripple) — no-op.
204
262
  return { ok: true, verb: null, slice: null, graphUnavailable: false, graphBroken: false, loudMessage: null };
205
263
  }
206
- // The graph query uses gsd-t-graph-query-cli.cjs (local) or `gsd-t graph <verb>` (global).
207
- // argv: only the verb (no target) for phase-level queries that return a global set
208
- // (cluster/dead-code/who-imports/blast-radius without a target returns the full graph slice).
264
+ // The graph query runs bin/gsd-t-graph-query-cli.cjs (local) or `gsd-t graph …` (global).
265
+ //
266
+ // The verb MUST travel in `argv`, not in the subcmd string: runCli builds the LOCAL-bin
267
+ // command from `argv` ALONE (the subcmd text is only used for the global `gsd-t …` form).
268
+ // With the verb in subcmd and argv empty, any project carrying a local graph CLI ran it
269
+ // with NO verb at all → {"ok":false,"reason":"no-verb"} → fail-closed to BROKEN. That is
270
+ // why this failed in a project with a local bin/ copy and worked everywhere else.
271
+ // Fixed 2026-08-02 (binvoice S2-M14 partition halted twice on this).
272
+ //
273
+ // No target is passed — every mapped verb answers target-free (see PHASE_GRAPH_VERB_MAP).
209
274
  const r = await runCli(
210
- projectDir, `graph ${verb}`, [], "gsd-t-graph-query-cli.cjs",
275
+ projectDir, "graph", [verb], "gsd-t-graph-query-cli.cjs",
211
276
  `graph:${verb}`, true, phaseNameOpt
212
277
  );
213
278
  const env = r.envelope || {};
@@ -44,11 +44,37 @@ const _CLI_ENVELOPE_SCHEMA = {
44
44
  type: "object", required: ["ok", "exitCode"], additionalProperties: true,
45
45
  properties: {
46
46
  ok: { type: "boolean" }, exitCode: { type: "integer" },
47
- envelope: {}, stdout: { type: "string" }, stderr: { type: "string" }, via: { type: "string" },
47
+ envelope: { type: ["object", "null"], description: "stdout PARSED as JSON — an object, never the raw text" }, stdout: { type: "string" }, stderr: { type: "string" }, via: { type: "string" },
48
48
  },
49
49
  };
50
50
  // Run a `gsd-t <subcmd>` CLI (or project-local bin/<localBin>) via an agent's Bash and
51
51
  // return { ok, exitCode, envelope, stderr, via }. parseJson=true parses stdout as the envelope.
52
+ // ─── Normalize a helper-agent CLI result (M104) ──────────────────────────────
53
+ //
54
+ // runCli asks a haiku helper to run a command and hand back the parsed JSON in
55
+ // `envelope`. A model doing that job is not perfectly reliable, and two broken
56
+ // shapes both read `env.ok === undefined` — which every caller treats as failure,
57
+ // so a HEALTHY CLI reports as broken:
58
+ // (a) `envelope` holds the raw JSON TEXT rather than the parsed object
59
+ // (b) `envelope` is absent and the JSON only reached `stdout`
60
+ // Observed live 2026-08-02: a partition/plan halted four times on "graph BROKEN"
61
+ // while the graph was fine (exit 0, ok:true).
62
+ //
63
+ // NOT a fallback: it continues past no failure and substitutes no guess — it reads
64
+ // the result the CLI actually returned. A failing CLI still returns ok:false and
65
+ // still fails; non-JSON stdout is left alone.
66
+ function _coerceCliResult(x) {
67
+ if (!x) return x;
68
+ if (typeof x.envelope === "string") {
69
+ try { x.envelope = JSON.parse(x.envelope); } catch (_) { /* genuinely not JSON */ }
70
+ }
71
+ if ((x.envelope === undefined || x.envelope === null) &&
72
+ typeof x.stdout === "string" && x.stdout.trim().startsWith("{")) {
73
+ try { x.envelope = JSON.parse(x.stdout); } catch (_) { /* not JSON */ }
74
+ }
75
+ return x;
76
+ }
77
+
52
78
  async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = true, phaseName) {
53
79
  const argStr = (argv || []).map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ");
54
80
  const prompt = [
@@ -66,7 +92,7 @@ async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = tru
66
92
  if (phaseName) opts.phase = phaseName; // opts.phase MUST be a string, never the phase() fn
67
93
  const r = await agent(prompt, opts)
68
94
  .catch((e) => ({ ok: false, exitCode: -1, envelope: null, stderr: String(e && e.message), via: "error" }));
69
- return r || { ok: false, exitCode: -1, envelope: null, via: "error" };
95
+ return _coerceCliResult(r) || { ok: false, exitCode: -1, envelope: null, via: "error" };
70
96
  }
71
97
  async function runPreflight(projectDir, label = "preflight", phaseName) {
72
98
  return runCli(projectDir, "preflight", ["--json"], "cli-preflight.cjs", label, true, phaseName);
@@ -60,8 +60,34 @@ const _args = (typeof args === "string") ? (() => { try { return JSON.parse(args
60
60
  const overrides = (_args.overrides && typeof _args.overrides === "object") ? _args.overrides : {};
61
61
  const _CLI_ENVELOPE_SCHEMA = {
62
62
  type: "object", required: ["ok", "exitCode"], additionalProperties: true,
63
- properties: { ok: { type: "boolean" }, exitCode: { type: "integer" }, envelope: {}, stdout: { type: "string" }, stderr: { type: "string" }, via: { type: "string" } },
63
+ properties: { ok: { type: "boolean" }, exitCode: { type: "integer" }, envelope: { type: ["object", "null"], description: "stdout PARSED as JSON — an object, never the raw text" }, stdout: { type: "string" }, stderr: { type: "string" }, via: { type: "string" } },
64
64
  };
65
+ // ─── Normalize a helper-agent CLI result (M104) ──────────────────────────────
66
+ //
67
+ // runCli asks a haiku helper to run a command and hand back the parsed JSON in
68
+ // `envelope`. A model doing that job is not perfectly reliable, and two broken
69
+ // shapes both read `env.ok === undefined` — which every caller treats as failure,
70
+ // so a HEALTHY CLI reports as broken:
71
+ // (a) `envelope` holds the raw JSON TEXT rather than the parsed object
72
+ // (b) `envelope` is absent and the JSON only reached `stdout`
73
+ // Observed live 2026-08-02: a partition/plan halted four times on "graph BROKEN"
74
+ // while the graph was fine (exit 0, ok:true).
75
+ //
76
+ // NOT a fallback: it continues past no failure and substitutes no guess — it reads
77
+ // the result the CLI actually returned. A failing CLI still returns ok:false and
78
+ // still fails; non-JSON stdout is left alone.
79
+ function _coerceCliResult(x) {
80
+ if (!x) return x;
81
+ if (typeof x.envelope === "string") {
82
+ try { x.envelope = JSON.parse(x.envelope); } catch (_) { /* genuinely not JSON */ }
83
+ }
84
+ if ((x.envelope === undefined || x.envelope === null) &&
85
+ typeof x.stdout === "string" && x.stdout.trim().startsWith("{")) {
86
+ try { x.envelope = JSON.parse(x.stdout); } catch (_) { /* not JSON */ }
87
+ }
88
+ return x;
89
+ }
90
+
65
91
  async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = true, phaseName) {
66
92
  const argStr = (argv || []).map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ");
67
93
  const prompt = [
@@ -74,7 +100,7 @@ async function runCli(projectDir, subcmd, argv, localBin, label, parseJson = tru
74
100
  const opts = { label, schema: _CLI_ENVELOPE_SCHEMA, model: "haiku" };
75
101
  if (phaseName) opts.phase = phaseName;
76
102
  const r = await agent(prompt, opts).catch((e) => ({ ok: false, exitCode: -1, envelope: null, stderr: String(e && e.message), via: "error" }));
77
- return r || { ok: false, exitCode: -1, envelope: null, via: "error" };
103
+ return _coerceCliResult(r) || { ok: false, exitCode: -1, envelope: null, via: "error" };
78
104
  }
79
105
  async function runPreflight(projectDir, label = "preflight", phaseName) { return runCli(projectDir, "preflight", ["--json"], "cli-preflight.cjs", label, true, phaseName); }
80
106
  // Broken-Graph-Halts (EXEMPT carve-out): verify's graph slice is additive/announced — it