@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 +29 -0
- package/README.md +1 -1
- package/bin/gsd-t.js +33 -4
- package/package.json +1 -1
- package/scripts/gsd-t-worktree-guard.js +183 -0
- package/templates/CLAUDE-global.md +2 -0
- package/templates/workflows/gsd-t-debug.workflow.js +28 -2
- package/templates/workflows/gsd-t-execute.workflow.js +28 -2
- package/templates/workflows/gsd-t-integrate.workflow.js +28 -2
- package/templates/workflows/gsd-t-phase.workflow.js +77 -12
- package/templates/workflows/gsd-t-quick.workflow.js +28 -2
- package/templates/workflows/gsd-t-verify.workflow.js +28 -2
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.
|
|
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(
|
|
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 =
|
|
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(
|
|
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.
|
|
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: {
|
|
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: "
|
|
180
|
-
plan: "
|
|
237
|
+
impact: "cluster",
|
|
238
|
+
plan: "cluster",
|
|
181
239
|
partition: "cluster",
|
|
182
|
-
feature: "
|
|
240
|
+
feature: "cluster",
|
|
183
241
|
"gap-analysis": "dead-code",
|
|
184
242
|
project: "cluster",
|
|
185
|
-
populate: "
|
|
186
|
-
"promote-debt": "
|
|
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
|
|
207
|
-
//
|
|
208
|
-
//
|
|
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,
|
|
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
|