@opengsd/gsd-core 1.20.1 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +234 -126
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/gsd-core/bin/gsd-tools.cjs +450 -753
  11. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  12. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  13. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  15. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  16. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  17. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  18. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  19. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  20. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  21. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  22. package/gsd-core/bin/lib/commands.cjs +80 -2
  23. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  24. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  25. package/gsd-core/bin/lib/config.cjs +139 -101
  26. package/gsd-core/bin/lib/configuration.cjs +41 -2
  27. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  28. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  29. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  30. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  33. package/gsd-core/bin/lib/graphify.cjs +19 -2
  34. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  35. package/gsd-core/bin/lib/init.cjs +13 -11
  36. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  37. package/gsd-core/bin/lib/install-engine.cjs +11 -19
  38. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  39. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  40. package/gsd-core/bin/lib/io.cjs +6 -0
  41. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  42. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  43. package/gsd-core/bin/lib/milestone.cjs +8 -6
  44. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  45. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  46. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  47. package/gsd-core/bin/lib/phase.cjs +107 -31
  48. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  49. package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
  50. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  51. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  52. package/gsd-core/bin/lib/project-context.cjs +439 -0
  53. package/gsd-core/bin/lib/project-root.cjs +9 -230
  54. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  55. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  56. package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
  57. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  58. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  59. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
  60. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  61. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  62. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  63. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  64. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  65. package/gsd-core/bin/lib/state-transition.cjs +91 -41
  66. package/gsd-core/bin/lib/state.cjs +446 -61
  67. package/gsd-core/bin/lib/surface.cjs +4 -7
  68. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  69. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  70. package/gsd-core/bin/lib/verify.cjs +49 -6
  71. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  73. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  74. package/gsd-core/references/checkpoints.md +1 -1
  75. package/gsd-core/references/planner-guidance.md +11 -0
  76. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  77. package/gsd-core/references/planner-reversibility.md +2 -0
  78. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  79. package/gsd-core/workflows/autonomous.md +4 -4
  80. package/gsd-core/workflows/code-review-fix.md +5 -7
  81. package/gsd-core/workflows/execute-phase.md +21 -7
  82. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  83. package/gsd-core/workflows/plan-phase.md +18 -19
  84. package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
  85. package/hooks/dist/gsd-config-reload.js +2 -2
  86. package/hooks/dist/gsd-context-monitor.js +2 -1
  87. package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
  88. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  89. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-workflow-guard.js +2 -1
  92. package/hooks/dist/lib/config-read.js +49 -0
  93. package/hooks/dist/lib/git-cmd.js +3 -1
  94. package/hooks/dist/lib/git-head-tree.js +474 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +97 -17
  96. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  97. package/hooks/gsd-agent-isolation-guard.js +3 -2
  98. package/hooks/gsd-config-reload.js +2 -2
  99. package/hooks/gsd-context-monitor.js +2 -1
  100. package/hooks/gsd-cursor-subagent-start.js +8 -5
  101. package/hooks/gsd-read-injection-scanner.js +2 -2
  102. package/hooks/gsd-secret-read-guard.js +141 -6
  103. package/hooks/gsd-statusline.js +3 -2
  104. package/hooks/gsd-workflow-guard.js +2 -1
  105. package/hooks/lib/config-read.js +49 -0
  106. package/hooks/lib/git-cmd.js +3 -1
  107. package/hooks/lib/git-head-tree.js +474 -0
  108. package/hooks/lib/isolation-sentinel.js +97 -17
  109. package/hooks/lib/secret-read-exemptions.js +161 -0
  110. package/package.json +7 -2
  111. package/pi/package-agents/gsd-executor.md +1 -1
  112. package/pi/package-agents/gsd-plan-checker.md +1 -1
  113. package/pi/package-agents/gsd-planner.md +3 -3
  114. package/pi/package-agents/gsd-verifier.md +2 -2
  115. package/pi/package-agents/manifest.json +4 -4
  116. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  117. package/scripts/changeset/cli.cjs +2 -2
  118. package/scripts/changeset/serialize.cjs +12 -2
  119. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  120. package/scripts/ci-fix-next-rerun.cjs +149 -0
  121. package/scripts/ci-next-health.cjs +52 -9
  122. package/scripts/ci-test-scope.cjs +77 -71
  123. package/scripts/command-contract-helpers.cjs +56 -7
  124. package/scripts/docs-guard-registry.cjs +3 -0
  125. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  126. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  127. package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
  128. package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
  129. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  130. package/scripts/lint-removed-but-needed.cjs +562 -50
  131. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  132. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  133. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  134. package/skills/gsd-autonomous/SKILL.md +1 -1
  135. package/vscode/package.json +1 -1
@@ -0,0 +1,474 @@
1
+ // gsd-hook-version: {{GSD_VERSION}}
2
+ // Read a path's entry out of the commit HEAD names, straight from `.git`.
3
+ //
4
+ // `headTreeEntry(startDir, relPath, limits?)` answers "what mode and object id
5
+ // does the tree of HEAD hold at this path?" without spawning git and without a
6
+ // timer: the verdict depends only on the repository's bytes, never on machine
7
+ // load or the wall clock (#4432).
8
+ //
9
+ // What it reads, in order:
10
+ // 1. the git dir: walk up from startDir to the nearest `.git`; a `.git` FILE
11
+ // (linked worktree, submodule) holds `gitdir: <path>`; a `commondir` file
12
+ // points at the directory that holds the shared objects and refs.
13
+ // 2. HEAD: a `ref:` chain through loose refs, then `packed-refs`, ending at a
14
+ // 40-hex object id (a detached HEAD holds the id directly).
15
+ // 3. objects: a loose object (`objects/xx/yyyy…`, zlib) or an object in a
16
+ // pack (`objects/pack/*.idx` version 2 + `.pack`, including OFS_DELTA and
17
+ // REF_DELTA chains).
18
+ // 4. the commit's `tree`, then each path segment through nested trees.
19
+ //
20
+ // Fail closed: ANY problem — no repository, no commits, a SHA-256 repository,
21
+ // a missing object (shallow/partial clone, objects only in alternates), an
22
+ // unknown index version, corruption, a structural limit — returns null. This
23
+ // module never throws and never answers from a guess. It is a pure reader: it
24
+ // spawns nothing and sets no timer, so every bound below is structural (a
25
+ // count or a byte size), not a duration.
26
+ //
27
+ // Standalone on purpose (node built-ins `fs`, `path`, `zlib` only): hooks are
28
+ // installed as loose files and cannot require `src/`.
29
+
30
+ 'use strict';
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+ const zlib = require('zlib');
35
+
36
+ const LIMITS = Object.freeze({
37
+ maxRefHops: 8,
38
+ maxParentDirs: 64,
39
+ maxObjectBytes: 1024 * 1024,
40
+ maxDeltaDepth: 50,
41
+ maxRefsFileBytes: 4 * 1024 * 1024,
42
+ maxPackFiles: 64,
43
+ });
44
+
45
+ const HEX40 = /^[0-9a-f]{40}$/;
46
+
47
+ // Internal bail-out: caught once at the entry point and turned into null.
48
+ class Unreadable extends Error {}
49
+
50
+ function fail(why) {
51
+ throw new Unreadable(why);
52
+ }
53
+
54
+ function resolveLimits(limits) {
55
+ const out = { ...LIMITS };
56
+ if (limits !== null && typeof limits === 'object') {
57
+ for (const key of Object.keys(LIMITS)) {
58
+ const v = limits[key];
59
+ if (Number.isInteger(v) && v >= 0) out[key] = v;
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ // ─── bounded file reads ─────────────────────────────────────────────────────
66
+
67
+ function readExactAt(fd, pos, len) {
68
+ const buf = Buffer.alloc(len);
69
+ const n = fs.readSync(fd, buf, 0, len, pos);
70
+ if (n !== len) fail('short read');
71
+ return buf;
72
+ }
73
+
74
+ function readFileBounded(file, maxBytes) {
75
+ const fd = fs.openSync(file, 'r');
76
+ try {
77
+ const st = fs.fstatSync(fd);
78
+ if (!st.isFile()) fail('not a regular file');
79
+ if (st.size > maxBytes) fail('file too large');
80
+ return st.size === 0 ? Buffer.alloc(0) : readExactAt(fd, 0, st.size);
81
+ } finally {
82
+ fs.closeSync(fd);
83
+ }
84
+ }
85
+
86
+ function readTextOrNull(file, maxBytes) {
87
+ try {
88
+ return readFileBounded(file, maxBytes).toString('utf8');
89
+ } catch (err) {
90
+ if (err instanceof Unreadable) throw err;
91
+ return null; // missing or not a file: the caller decides what that means
92
+ }
93
+ }
94
+
95
+ // ─── locating the repository ────────────────────────────────────────────────
96
+
97
+ // realpathSync.native, not the JS realpath: on Windows the JS form leaves an
98
+ // 8.3 short name (RUNNER~1) as written, so the same directory reached through a
99
+ // short and a long name compared unequal and a real linked worktree was refused.
100
+ function sameRealPath(a, b) {
101
+ try {
102
+ return fs.realpathSync.native(a) === fs.realpathSync.native(b);
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ // A `.git` file is followed only when it is the one `git worktree add` wrote:
109
+ // its gitdir is <commondir>/worktrees/<name> and that directory's `gitdir`
110
+ // back-pointer names this very `.git` file. A planted file fails one of the two.
111
+ function linkedWorktree(dir, dotGit, gitDir) {
112
+ const common = readTextOrNull(path.join(gitDir, 'commondir'), 4096);
113
+ if (common === null || common.trim() === '') fail('worktree git dir has no commondir');
114
+ const commonDir = path.resolve(gitDir, common.trim());
115
+ if (!sameRealPath(path.dirname(gitDir), path.join(commonDir, 'worktrees'))) fail('git dir is not a linked worktree');
116
+ const back = readTextOrNull(path.join(gitDir, 'gitdir'), 4096);
117
+ if (back === null || back.trim() === '') fail('worktree git dir has no back-pointer');
118
+ if (!sameRealPath(path.resolve(gitDir, back.trim()), dotGit)) fail('worktree back-pointer names another .git file');
119
+ return { top: dir, gitDir, commonDir };
120
+ }
121
+
122
+ function locateGit(startDir, limits) {
123
+ let dir = path.resolve(startDir);
124
+ for (let i = 0; i <= limits.maxParentDirs; i++) {
125
+ const dotGit = path.join(dir, '.git');
126
+ let st = null;
127
+ try {
128
+ st = fs.statSync(dotGit);
129
+ } catch {
130
+ st = null;
131
+ }
132
+ if (st !== null) {
133
+ const gitDir = dotGit;
134
+ if (st.isFile()) {
135
+ const text = readTextOrNull(dotGit, 4096);
136
+ const m = text === null ? null : /^gitdir:\s*(.+?)\s*$/m.exec(text);
137
+ if (m === null) fail('unreadable .git file');
138
+ return linkedWorktree(dir, dotGit, path.resolve(dir, m[1]));
139
+ } else if (!st.isDirectory()) {
140
+ fail('unexpected .git entry');
141
+ }
142
+ let commonDir = gitDir;
143
+ const common = readTextOrNull(path.join(gitDir, 'commondir'), 4096);
144
+ if (common !== null && common.trim() !== '') commonDir = path.resolve(gitDir, common.trim());
145
+ return { top: dir, gitDir, commonDir };
146
+ }
147
+ const parent = path.dirname(dir);
148
+ if (parent === dir) break;
149
+ dir = parent;
150
+ }
151
+ return fail('no repository');
152
+ }
153
+
154
+ function assertSha1Repository(commonDir, limits) {
155
+ const config = readTextOrNull(path.join(commonDir, 'config'), limits.maxRefsFileBytes);
156
+ if (config !== null && /^\s*objectformat\s*=\s*sha256\s*$/im.test(config)) fail('sha256 repository');
157
+ }
158
+
159
+ // ─── refs ───────────────────────────────────────────────────────────────────
160
+
161
+ function isSafeRefName(name) {
162
+ return name !== '' && !name.startsWith('/') && !name.includes('\0') && !name.includes('\\') &&
163
+ !name.split('/').some((seg) => seg === '' || seg === '.' || seg === '..');
164
+ }
165
+
166
+ // packed-refs is read at most once per repository per call, however many ref hops follow.
167
+ function readPackedRefs(base, repo, limits) {
168
+ if (repo.packedRefsCache === undefined) repo.packedRefsCache = new Map();
169
+ if (!repo.packedRefsCache.has(base)) {
170
+ repo.packedRefsCache.set(base, readTextOrNull(path.join(base, 'packed-refs'), limits.maxRefsFileBytes));
171
+ }
172
+ return repo.packedRefsCache.get(base);
173
+ }
174
+
175
+ function lookupRef(name, repo, limits) {
176
+ if (!isSafeRefName(name)) fail('bad ref name');
177
+ for (const base of repo.gitDir === repo.commonDir ? [repo.gitDir] : [repo.gitDir, repo.commonDir]) {
178
+ const loose = readTextOrNull(path.join(base, ...name.split('/')), 4096);
179
+ if (loose !== null && loose.trim() !== '') return loose.trim();
180
+ }
181
+ for (const base of repo.gitDir === repo.commonDir ? [repo.gitDir] : [repo.commonDir, repo.gitDir]) {
182
+ const packed = readPackedRefs(base, repo, limits);
183
+ if (packed === null) continue;
184
+ for (const line of packed.split('\n')) {
185
+ if (line === '' || line[0] === '#' || line[0] === '^') continue;
186
+ const space = line.indexOf(' ');
187
+ if (space === 40 && line.slice(space + 1).trim() === name) return line.slice(0, 40);
188
+ }
189
+ }
190
+ return fail('ref not found');
191
+ }
192
+
193
+ function resolveHead(repo, limits) {
194
+ const head = readTextOrNull(path.join(repo.gitDir, 'HEAD'), 4096);
195
+ if (head === null) fail('no HEAD');
196
+ let content = head.trim();
197
+ let hops = 0;
198
+ while (content.startsWith('ref:')) {
199
+ hops++;
200
+ if (hops > limits.maxRefHops) fail('too many ref hops');
201
+ content = lookupRef(content.slice(4).trim(), repo, limits);
202
+ }
203
+ if (!HEX40.test(content)) fail('HEAD does not name a SHA-1 object');
204
+ return content;
205
+ }
206
+
207
+ // ─── objects ────────────────────────────────────────────────────────────────
208
+
209
+ const TYPE_NAMES = { 1: 'commit', 2: 'tree', 3: 'blob', 4: 'tag' };
210
+
211
+ // Worst-case deflate expansion is 5 bytes per 64 KiB block plus a few bytes of
212
+ // framing; this bound is only a memory cap on how much compressed input to read.
213
+ function compressedCap(inflatedBytes) {
214
+ return inflatedBytes + (inflatedBytes >> 8) + 64;
215
+ }
216
+
217
+ function readLoose(oid, repo, limits) {
218
+ const file = path.join(repo.commonDir, 'objects', oid.slice(0, 2), oid.slice(2));
219
+ let raw;
220
+ try {
221
+ raw = readFileBounded(file, compressedCap(limits.maxObjectBytes));
222
+ } catch (err) {
223
+ if (err instanceof Unreadable) throw err;
224
+ if (err && err.code === 'ENOENT') return null;
225
+ return fail('unreadable loose object');
226
+ }
227
+ const inflated = zlib.inflateSync(raw, { maxOutputLength: limits.maxObjectBytes });
228
+ const nul = inflated.indexOf(0);
229
+ if (nul === -1) fail('loose object has no header');
230
+ const m = /^(commit|tree|blob|tag) (\d+)$/.exec(inflated.toString('latin1', 0, nul));
231
+ if (m === null) fail('bad loose object header');
232
+ const data = inflated.subarray(nul + 1);
233
+ if (data.length !== Number(m[2])) fail('loose object size mismatch');
234
+ return { type: m[1], data, depth: 0 };
235
+ }
236
+
237
+ function applyDelta(base, delta, limits) {
238
+ let pos = 0;
239
+ const varint = () => {
240
+ let value = 0;
241
+ let shift = 0;
242
+ for (;;) {
243
+ if (pos >= delta.length) fail('truncated delta');
244
+ const b = delta[pos++];
245
+ value += (b & 0x7f) * 2 ** shift;
246
+ shift += 7;
247
+ if ((b & 0x80) === 0) return value;
248
+ if (shift > 56) fail('delta size overflow');
249
+ }
250
+ };
251
+ const srcSize = varint();
252
+ const dstSize = varint();
253
+ if (srcSize !== base.length) fail('delta base size mismatch');
254
+ if (dstSize > limits.maxObjectBytes) fail('delta result too large');
255
+ const out = Buffer.alloc(dstSize);
256
+ let outPos = 0;
257
+ while (pos < delta.length) {
258
+ const cmd = delta[pos++];
259
+ if ((cmd & 0x80) !== 0) {
260
+ let offset = 0;
261
+ let size = 0;
262
+ for (let i = 0; i < 4; i++) {
263
+ if ((cmd & (1 << i)) !== 0) {
264
+ if (pos >= delta.length) fail('truncated delta copy');
265
+ offset += delta[pos++] * 2 ** (8 * i);
266
+ }
267
+ }
268
+ for (let i = 0; i < 3; i++) {
269
+ if ((cmd & (0x10 << i)) !== 0) {
270
+ if (pos >= delta.length) fail('truncated delta copy');
271
+ size += delta[pos++] * 2 ** (8 * i);
272
+ }
273
+ }
274
+ if (size === 0) size = 0x10000;
275
+ if (offset + size > base.length || outPos + size > dstSize) fail('delta copy out of range');
276
+ base.copy(out, outPos, offset, offset + size);
277
+ outPos += size;
278
+ } else if (cmd !== 0) {
279
+ if (pos + cmd > delta.length || outPos + cmd > dstSize) fail('delta insert out of range');
280
+ delta.copy(out, outPos, pos, pos + cmd);
281
+ pos += cmd;
282
+ outPos += cmd;
283
+ } else {
284
+ fail('reserved delta opcode');
285
+ }
286
+ }
287
+ if (outPos !== dstSize) fail('delta result size mismatch');
288
+ return out;
289
+ }
290
+
291
+ function packEntry(packFd, packSize, offset, level, repo, limits) {
292
+ if (level > limits.maxDeltaDepth) fail('delta chain too deep');
293
+ if (offset < 12 || offset >= packSize) fail('pack offset out of range');
294
+ const header = readExactAt(packFd, offset, Math.min(32, packSize - offset));
295
+ let pos = 0;
296
+ let byte = header[pos++];
297
+ const typeCode = (byte >> 4) & 7;
298
+ let size = byte & 0x0f;
299
+ let shift = 4;
300
+ while ((byte & 0x80) !== 0) {
301
+ if (pos >= header.length || shift > 56) fail('bad pack entry header');
302
+ byte = header[pos++];
303
+ size += (byte & 0x7f) * 2 ** shift;
304
+ shift += 7;
305
+ }
306
+ if (size > limits.maxObjectBytes) fail('pack object too large');
307
+
308
+ let baseOffset = -1;
309
+ let baseOid = null;
310
+ if (typeCode === 6) {
311
+ if (pos >= header.length) fail('bad ofs-delta header');
312
+ byte = header[pos++];
313
+ let rel = byte & 0x7f;
314
+ while ((byte & 0x80) !== 0) {
315
+ if (pos >= header.length) fail('bad ofs-delta header');
316
+ byte = header[pos++];
317
+ rel = (rel + 1) * 128 + (byte & 0x7f);
318
+ }
319
+ baseOffset = offset - rel;
320
+ } else if (typeCode === 7) {
321
+ if (pos + 20 > header.length) fail('bad ref-delta header');
322
+ baseOid = header.toString('hex', pos, pos + 20);
323
+ pos += 20;
324
+ } else if (TYPE_NAMES[typeCode] === undefined) {
325
+ fail('unknown pack object type');
326
+ }
327
+
328
+ const start = offset + pos;
329
+ const want = Math.min(compressedCap(size), packSize - start);
330
+ if (want <= 0) fail('pack entry has no data');
331
+ const inflated = zlib.inflateSync(readExactAt(packFd, start, want), { maxOutputLength: Math.max(size, 1) });
332
+ if (inflated.length !== size) fail('pack entry size mismatch');
333
+
334
+ if (typeCode !== 6 && typeCode !== 7) return { type: TYPE_NAMES[typeCode], data: inflated, depth: 0 };
335
+
336
+ let base;
337
+ if (typeCode === 6) {
338
+ if (baseOffset < 12) fail('bad ofs-delta base');
339
+ base = packEntry(packFd, packSize, baseOffset, level + 1, repo, limits);
340
+ } else {
341
+ base = readObject(baseOid, repo, limits, level + 1);
342
+ }
343
+ return { type: base.type, data: applyDelta(base.data, inflated, limits), depth: base.depth + 1 };
344
+ }
345
+
346
+ function idxLookup(idxFd, idxSize, oid) {
347
+ const head = readExactAt(idxFd, 0, 8 + 1024);
348
+ if (head.readUInt32BE(0) !== 0xff744f63 || head.readUInt32BE(4) !== 2) fail('unsupported pack index');
349
+ const fan = (i) => head.readUInt32BE(8 + 4 * i);
350
+ const count = fan(255);
351
+ if (idxSize < 8 + 1024 + count * 28 + 40) fail('truncated pack index');
352
+ const want = Buffer.from(oid, 'hex');
353
+ let lo = want[0] === 0 ? 0 : fan(want[0] - 1);
354
+ let hi = fan(want[0]);
355
+ if (lo > hi || hi > count) fail('corrupt pack index fanout');
356
+ while (lo < hi) {
357
+ const mid = (lo + hi) >>> 1;
358
+ const cmp = Buffer.compare(readExactAt(idxFd, 1032 + mid * 20, 20), want);
359
+ if (cmp === 0) {
360
+ const off32 = readExactAt(idxFd, 1032 + count * 24 + mid * 4, 4).readUInt32BE(0);
361
+ if ((off32 & 0x80000000) === 0) return off32;
362
+ const slot = off32 & 0x7fffffff;
363
+ if (idxSize < 1032 + count * 28 + slot * 8 + 8 + 40) fail('truncated pack index');
364
+ const big = readExactAt(idxFd, 1032 + count * 28 + slot * 8, 8).readBigUInt64BE(0);
365
+ if (big > BigInt(Number.MAX_SAFE_INTEGER)) fail('pack offset too large');
366
+ return Number(big);
367
+ }
368
+ if (cmp < 0) lo = mid + 1;
369
+ else hi = mid;
370
+ }
371
+ return -1;
372
+ }
373
+
374
+ function readPacked(oid, repo, limits, level) {
375
+ const packDir = path.join(repo.commonDir, 'objects', 'pack');
376
+ let names;
377
+ try {
378
+ names = fs.readdirSync(packDir).filter((n) => n.endsWith('.idx')).sort();
379
+ } catch {
380
+ return null;
381
+ }
382
+ if (names.length > limits.maxPackFiles) fail('too many pack files');
383
+ for (const name of names) {
384
+ const idxFd = fs.openSync(path.join(packDir, name), 'r');
385
+ try {
386
+ const offset = idxLookup(idxFd, fs.fstatSync(idxFd).size, oid);
387
+ if (offset === -1) continue;
388
+ const packFd = fs.openSync(path.join(packDir, `${name.slice(0, -4)}.pack`), 'r');
389
+ try {
390
+ return packEntry(packFd, fs.fstatSync(packFd).size, offset, level, repo, limits);
391
+ } finally {
392
+ fs.closeSync(packFd);
393
+ }
394
+ } finally {
395
+ fs.closeSync(idxFd);
396
+ }
397
+ }
398
+ return null;
399
+ }
400
+
401
+ function readObject(oid, repo, limits, level) {
402
+ if (!HEX40.test(oid)) fail('bad object id');
403
+ if (level > limits.maxDeltaDepth) fail('delta chain too deep');
404
+ const loose = readLoose(oid, repo, limits);
405
+ if (loose !== null) return loose;
406
+ const packed = readPacked(oid, repo, limits, level);
407
+ if (packed !== null) return packed;
408
+ return fail('object not found');
409
+ }
410
+
411
+ // ─── trees ──────────────────────────────────────────────────────────────────
412
+
413
+ function findInTree(data, name) {
414
+ const want = Buffer.from(name, 'utf8');
415
+ let pos = 0;
416
+ while (pos < data.length) {
417
+ const space = data.indexOf(0x20, pos);
418
+ const nul = space === -1 ? -1 : data.indexOf(0, space + 1);
419
+ if (space === -1 || nul === -1 || nul + 21 > data.length) fail('corrupt tree');
420
+ if (data.subarray(space + 1, nul).equals(want)) {
421
+ return { mode: data.toString('latin1', pos, space), oid: data.toString('hex', nul + 1, nul + 21) };
422
+ }
423
+ pos = nul + 21;
424
+ }
425
+ return null;
426
+ }
427
+
428
+ function lookup(startDir, relPath, limits) {
429
+ if (typeof startDir !== 'string' || startDir === '' || typeof relPath !== 'string' || relPath === '') return null;
430
+ if (startDir.includes('\0') || relPath.includes('\0')) return null;
431
+ const repo = locateGit(startDir, limits);
432
+ const rel = path.relative(repo.top, path.resolve(startDir, relPath));
433
+ if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null; // allow-handrolled-containment: an installed hook cannot require src/security.cts
434
+ const segments = rel.split(path.sep);
435
+ if (segments.some((seg) => seg === '' || seg === '.' || seg === '..')) return null;
436
+
437
+ assertSha1Repository(repo.commonDir, limits);
438
+ const commit = readObject(resolveHead(repo, limits), repo, limits, 0);
439
+ if (commit.type !== 'commit') fail('HEAD is not a commit');
440
+ const m = /^tree ([0-9a-f]{40})\n/.exec(commit.data.toString('latin1', 0, Math.min(commit.data.length, 64)));
441
+ if (m === null) fail('commit has no tree');
442
+
443
+ let treeOid = m[1];
444
+ for (let i = 0; i < segments.length; i++) {
445
+ const tree = readObject(treeOid, repo, limits, 0);
446
+ if (tree.type !== 'tree') fail('not a tree');
447
+ const entry = findInTree(tree.data, segments[i]);
448
+ if (entry === null) return null;
449
+ if (i === segments.length - 1) return entry;
450
+ if (entry.mode !== '40000') return null;
451
+ treeOid = entry.oid;
452
+ }
453
+ return null;
454
+ }
455
+
456
+ /**
457
+ * @param {string} startDir directory the walk up to `.git` starts from
458
+ * @param {string} relPath path relative to startDir (a path below the repo top)
459
+ * @param {Partial<typeof LIMITS>} [limits] structural bounds, for tests
460
+ * @returns {{mode: string, oid: string} | null} the tree entry HEAD holds at the
461
+ * path, or null when it is absent or the repository cannot be read with confidence
462
+ */
463
+ function headTreeEntry(startDir, relPath, limits) {
464
+ try {
465
+ return lookup(startDir, relPath, resolveLimits(limits));
466
+ } catch {
467
+ return null;
468
+ }
469
+ }
470
+
471
+ // Exposed so tests can drive the parsers with hand-built buffers; not a public API.
472
+ const _internal = { applyDelta, idxLookup, packEntry, lookupRef, locateGit, resolveLimits };
473
+
474
+ module.exports = { headTreeEntry, LIMITS, _internal };
@@ -74,6 +74,10 @@ const SENTINEL_RELATIVE_PATH = path.join('.gsd', 'dispatch-isolation-sentinel.js
74
74
  const KEYED_SENTINEL_DIR = path.join('.gsd', 'dispatch-isolation');
75
75
  const SAFE_SENTINEL_KEY_RE = /^[A-Za-z0-9._-]{1,100}$/;
76
76
 
77
+ // #5758: a real sentinel payload is under 300 bytes. Anything larger is not one, and the cap bounds
78
+ // the read so a hostile or runaway record cannot be pulled into the hook.
79
+ const SENTINEL_MAX_BYTES = 4096;
80
+
77
81
  /** #5682: strict allow-list for a phase/plan string that becomes a path segment. */
78
82
  function isSafeSentinelKey(s) {
79
83
  return typeof s === 'string' && SAFE_SENTINEL_KEY_RE.test(s) && s !== '.' && s !== '..';
@@ -134,10 +138,10 @@ function sentinelPath(cwd) {
134
138
  /**
135
139
  * Resolve the project root a sentinel should be read from/written to, using
136
140
  * the SAME derivation gsd-tools.cjs's dispatcher applies to every `--cwd`
137
- * before invoking a route handler: `findProjectRoot(resolveMainWorktreeCwd(cwd))`
138
- * (gsd-core/bin/gsd-tools.cjs main(), :3506/:3603 — `record-dispatch-isolation`
139
- * and `dispatch-isolation` are not in SKIP_ROOT_RESOLUTION, so every write
140
- * goes through both steps).
141
+ * before invoking a route handler: `resolveProjectContext(cwd).projectRoot`
142
+ * (gsd-core/bin/gsd-tools.cjs main() — `record-dispatch-isolation` and
143
+ * `dispatch-isolation` are not in SKIP_ROOT_RESOLUTION, so every write goes
144
+ * through it).
141
145
  *
142
146
  * #3045 MINOR fix: the guard hooks previously read the sentinel from the raw
143
147
  * `data.cwd` / `workspace_roots[i]` the harness reports, with NO equivalent
@@ -150,7 +154,7 @@ function sentinelPath(cwd) {
150
154
  * written where it looked. Deriving both sides through this one function
151
155
  * closes that divergence.
152
156
  *
153
- * `findProjectRoot`/`resolveWorktreeRoot` are read from the sibling
157
+ * `resolveProjectContext` is read from the sibling
154
158
  * `gsd-core/bin/lib/*.cjs` modules staged alongside these hooks at install
155
159
  * time (same pattern the guard hooks already use for
156
160
  * capability-registry.cjs/runtime-name-policy.cjs) — two directories up from
@@ -176,10 +180,10 @@ function resolveSentinelRoot(cwd) {
176
180
  // cold-tree encounter.
177
181
  const { ensureRuntimeBuild } = require('../../gsd-core/bin/ensure-runtime-build.cjs');
178
182
  ensureRuntimeBuild();
179
- const { resolveWorktreeRoot } = require('../../gsd-core/bin/lib/worktree-safety.cjs');
180
- const { root } = resolveWorktreeRoot(cwd);
181
- const { findProjectRoot } = require('../../gsd-core/bin/lib/project-root.cjs');
182
- return findProjectRoot(root);
183
+ // #6043 (ADR-4908): the dispatcher resolves non-skip commands through the
184
+ // same seam, so the reader looks where the writer wrote.
185
+ const { resolveProjectContext } = require('../../gsd-core/bin/lib/project-context.cjs');
186
+ return resolveProjectContext(cwd).projectRoot;
183
187
  } catch {
184
188
  return cwd;
185
189
  }
@@ -213,21 +217,23 @@ function readSentinel(cwd, { clock = Date, dispatchIds = null } = {}) {
213
217
  // well-formed and its contents agree with its own filename key; the legacy slot only when it
214
218
  // applies to this dispatch. Among FRESH candidates the latest `writtenAt` wins, so a newer
215
219
  // phase-level decision is not shadowed by an older plan-scoped one (and vice versa).
220
+ const gsdDir = path.join(root, '.gsd');
221
+ const keyedDirs = [gsdDir, path.join(root, KEYED_SENTINEL_DIR)];
216
222
  const keyedCandidates = [];
217
223
  if (dispatchIds) {
218
224
  const planFile = keyedSentinelPath(root, dispatchIds.phase, dispatchIds.plan);
219
225
  if (planFile) {
220
- const c = parseSentinelFile(planFile, clock);
226
+ const c = parseSentinelFile(planFile, clock, keyedDirs);
221
227
  if (c.present && !c.malformed && c.phase === dispatchIds.phase && c.plan === dispatchIds.plan) keyedCandidates.push(c);
222
228
  }
223
229
  const phaseFile = phaseSentinelPath(root, dispatchIds.phase);
224
230
  if (phaseFile) {
225
- const c = parseSentinelFile(phaseFile, clock);
231
+ const c = parseSentinelFile(phaseFile, clock, keyedDirs);
226
232
  if (c.present && !c.malformed && c.phase === dispatchIds.phase && c.plan === null) keyedCandidates.push(c);
227
233
  }
228
234
  }
229
235
 
230
- const legacy = parseSentinelFile(sentinelPath(root), clock);
236
+ const legacy = parseSentinelFile(sentinelPath(root), clock, [gsdDir]);
231
237
  const legacyApplies = legacy.present && !legacy.malformed && sentinelAppliesToDispatch(legacy, dispatchIds);
232
238
 
233
239
  const fresh = keyedCandidates.filter((c) => !c.stale);
@@ -245,14 +251,87 @@ function latestWritten(candidates) {
245
251
  return candidates.reduce((best, c) => (c.writtenAt > best.writtenAt ? c : best));
246
252
  }
247
253
 
248
- /** Parse and validate one sentinel file. Never throws. Shape: see `readSentinel`. */
249
- function parseSentinelFile(file, clock) {
250
- let raw;
254
+ /**
255
+ * lstat of `dir` when it is a real directory, not a link (lstat does not follow); otherwise null.
256
+ * Never throws.
257
+ */
258
+ function lstatRealDirectory(dir) {
251
259
  try {
252
- raw = fs.readFileSync(file, 'utf-8');
260
+ // bigint: an NTFS file id is 64 bits and a JS number keeps 53, so ids are compared exactly.
261
+ const st = fs.lstatSync(dir, { bigint: true });
262
+ return st.isDirectory() && !st.isSymbolicLink() ? st : null;
253
263
  } catch {
254
- return { present: false };
264
+ return null;
265
+ }
266
+ }
267
+
268
+ /** Same filesystem object: equal dev and ino. A zero ino (some Windows volumes) cannot be compared. */
269
+ function sameObject(a, b) {
270
+ return a.ino === 0n || b.ino === 0n || (a.dev === b.dev && a.ino === b.ino);
271
+ }
272
+
273
+ /**
274
+ * #5758: read one sentinel file as a bounded, non-blocking, link-refusing read of a regular file.
275
+ * Returns the text, or null for EVERY refusal (missing, link, FIFO, device, directory, oversized,
276
+ * short read, a hard link to another file, a parent directory that is a link), so a hostile record
277
+ * is exactly as absent as a missing one. `readFileSync` would block forever on a FIFO with no
278
+ * writer, follow a link to an endless device or to a file outside the project, and read without
279
+ * bound. The writers only ever produce a fresh temp file renamed into place (link count 1). A
280
+ * sentinel renamed over between the open and the fstat reads as nlink 0 and is refused for that one
281
+ * dispatch, which is the conservative no-sentinel decision.
282
+ *
283
+ * Parent directories are lstat-checked before the open and re-checked (same dev/ino, still a real
284
+ * directory) after it, and the opened file must be the object the pre-open lstat saw. Node has no
285
+ * openat, so this NARROWS the window for a parent swapped to a link between check and open; it does
286
+ * not close it.
287
+ */
288
+ function readSentinelText(file, parentDirs) {
289
+ const parents = [];
290
+ for (const dir of parentDirs) {
291
+ const st = lstatRealDirectory(dir);
292
+ if (!st) return null;
293
+ parents.push({ dir, st });
255
294
  }
295
+ let fd;
296
+ try {
297
+ const before = fs.lstatSync(file, { bigint: true });
298
+ if (!before.isFile() || before.size > BigInt(SENTINEL_MAX_BYTES)) return null;
299
+ // O_NONBLOCK makes an open of a FIFO swapped in after the lstat return at once; O_NOFOLLOW
300
+ // refuses a link swapped in. Windows lacks both flags (`|| 0`); lstat/fstat still guard.
301
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
302
+ const st = fs.fstatSync(fd, { bigint: true });
303
+ if (!st.isFile() || st.nlink !== 1n || st.size > BigInt(SENTINEL_MAX_BYTES)) return null;
304
+ if (!sameObject(before, st)) return null;
305
+ const size = Number(st.size);
306
+ for (const p of parents) {
307
+ const after = lstatRealDirectory(p.dir);
308
+ if (!after || !sameObject(p.st, after)) return null;
309
+ }
310
+ const buf = Buffer.alloc(size);
311
+ let offset = 0;
312
+ while (offset < size) {
313
+ const n = fs.readSync(fd, buf, offset, size - offset, offset);
314
+ if (n === 0) return null;
315
+ offset += n;
316
+ }
317
+ return buf.toString('utf-8');
318
+ } catch {
319
+ return null;
320
+ } finally {
321
+ if (fd !== undefined) {
322
+ try { fs.closeSync(fd); } catch { /* nothing left to release */ }
323
+ }
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Parse and validate one sentinel file. Never throws. Shape: see `readSentinel`.
329
+ * `parentDirs` are the directories between the project root and the file that must be real
330
+ * directories (not links); a refusal anywhere reads as `{ present: false }`.
331
+ */
332
+ function parseSentinelFile(file, clock, parentDirs = []) {
333
+ const raw = readSentinelText(file, parentDirs);
334
+ if (raw === null) return { present: false };
256
335
 
257
336
  let parsed;
258
337
  try {
@@ -424,6 +503,7 @@ module.exports = {
424
503
  keyedSentinelPath,
425
504
  phaseSentinelPath,
426
505
  SENTINEL_STALE_MS,
506
+ SENTINEL_MAX_BYTES,
427
507
  AGED_NONE_MAX_MS,
428
508
  sentinelPath,
429
509
  resolveSentinelRoot,