@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 +14 -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/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.
|
|
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.
|