@tekyzinc/gsd-t 5.6.11 → 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,20 @@
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
+
5
19
  ## [5.6.11] - 2026-08-02
6
20
 
7
21
  ### Fixed — four bugs that reported a healthy code graph as broken (M104)
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.6.11** - 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.11",
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.