instar 0.28.52 → 0.28.53

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 (84) hide show
  1. package/dist/cli.js +40 -0
  2. package/dist/cli.js.map +1 -1
  3. package/dist/commands/gate.d.ts +33 -0
  4. package/dist/commands/gate.d.ts.map +1 -0
  5. package/dist/commands/gate.js +171 -0
  6. package/dist/commands/gate.js.map +1 -0
  7. package/dist/commands/init.d.ts.map +1 -1
  8. package/dist/commands/init.js +29 -2
  9. package/dist/commands/init.js.map +1 -1
  10. package/dist/core/MessageSentinel.d.ts +40 -0
  11. package/dist/core/MessageSentinel.d.ts.map +1 -1
  12. package/dist/core/MessageSentinel.js +80 -0
  13. package/dist/core/MessageSentinel.js.map +1 -1
  14. package/dist/core/PostUpdateMigrator.d.ts +7 -0
  15. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  16. package/dist/core/PostUpdateMigrator.js +114 -0
  17. package/dist/core/PostUpdateMigrator.js.map +1 -1
  18. package/dist/core/SessionManager.d.ts +29 -0
  19. package/dist/core/SessionManager.d.ts.map +1 -1
  20. package/dist/core/SessionManager.js +104 -1
  21. package/dist/core/SessionManager.js.map +1 -1
  22. package/dist/core/StopGateDb.d.ts +97 -0
  23. package/dist/core/StopGateDb.d.ts.map +1 -0
  24. package/dist/core/StopGateDb.js +282 -0
  25. package/dist/core/StopGateDb.js.map +1 -0
  26. package/dist/core/UnjustifiedStopGate.d.ts +145 -0
  27. package/dist/core/UnjustifiedStopGate.d.ts.map +1 -0
  28. package/dist/core/UnjustifiedStopGate.js +325 -0
  29. package/dist/core/UnjustifiedStopGate.js.map +1 -0
  30. package/dist/core/WorktreeKeyVault.d.ts +50 -0
  31. package/dist/core/WorktreeKeyVault.d.ts.map +1 -0
  32. package/dist/core/WorktreeKeyVault.js +242 -0
  33. package/dist/core/WorktreeKeyVault.js.map +1 -0
  34. package/dist/core/WorktreeManager.d.ts +250 -0
  35. package/dist/core/WorktreeManager.d.ts.map +1 -0
  36. package/dist/core/WorktreeManager.js +833 -0
  37. package/dist/core/WorktreeManager.js.map +1 -0
  38. package/dist/monitoring/DegradationReporter.d.ts +15 -0
  39. package/dist/monitoring/DegradationReporter.d.ts.map +1 -1
  40. package/dist/monitoring/DegradationReporter.js +27 -0
  41. package/dist/monitoring/DegradationReporter.js.map +1 -1
  42. package/dist/monitoring/WorktreeReaper.d.ts +52 -0
  43. package/dist/monitoring/WorktreeReaper.d.ts.map +1 -0
  44. package/dist/monitoring/WorktreeReaper.js +199 -0
  45. package/dist/monitoring/WorktreeReaper.js.map +1 -0
  46. package/dist/scaffold/templates.d.ts.map +1 -1
  47. package/dist/scaffold/templates.js +8 -0
  48. package/dist/scaffold/templates.js.map +1 -1
  49. package/dist/server/AgentServer.d.ts +18 -0
  50. package/dist/server/AgentServer.d.ts.map +1 -1
  51. package/dist/server/AgentServer.js +24 -0
  52. package/dist/server/AgentServer.js.map +1 -1
  53. package/dist/server/middleware.d.ts.map +1 -1
  54. package/dist/server/middleware.js +20 -3
  55. package/dist/server/middleware.js.map +1 -1
  56. package/dist/server/routes.d.ts +10 -0
  57. package/dist/server/routes.d.ts.map +1 -1
  58. package/dist/server/routes.js +432 -0
  59. package/dist/server/routes.js.map +1 -1
  60. package/dist/server/stopGate.d.ts +77 -0
  61. package/dist/server/stopGate.d.ts.map +1 -0
  62. package/dist/server/stopGate.js +161 -0
  63. package/dist/server/stopGate.js.map +1 -0
  64. package/dist/server/worktreeRoutes.d.ts +48 -0
  65. package/dist/server/worktreeRoutes.d.ts.map +1 -0
  66. package/dist/server/worktreeRoutes.js +247 -0
  67. package/dist/server/worktreeRoutes.js.map +1 -0
  68. package/package.json +1 -1
  69. package/scripts/destructive-command-shim.js +218 -0
  70. package/scripts/gh-ruleset-install.mjs +143 -0
  71. package/scripts/migrate-incident-2026-04-17.mjs +138 -0
  72. package/scripts/worktree-commit-msg-hook.js +168 -0
  73. package/scripts/worktree-precommit-gate.js +144 -0
  74. package/src/data/builtin-manifest.json +93 -93
  75. package/upgrades/0.28.53.md +70 -0
  76. package/upgrades/side-effects/context-death-pr0a-server-infra.md +131 -0
  77. package/upgrades/side-effects/context-death-pr0b-sentinel-intent.md +130 -0
  78. package/upgrades/side-effects/context-death-pr0c-guardian-pulse-degradation-consumer.md +118 -0
  79. package/upgrades/side-effects/context-death-pr0d-e2e-compaction-harness.md +113 -0
  80. package/upgrades/side-effects/context-death-pr1-identity-text.md +110 -0
  81. package/upgrades/side-effects/context-death-pr2-e2e-compaction-recovery-test.md +82 -0
  82. package/upgrades/side-effects/context-death-pr3-gate-authority.md +193 -0
  83. package/upgrades/side-effects/context-death-pr4-gate-cli.md +91 -0
  84. package/upgrades/side-effects/parallel-dev-isolation.md +129 -0
@@ -0,0 +1,833 @@
1
+ /**
2
+ * WorktreeManager — Topic-bound parallel-dev isolation (PARALLEL-DEV-ISOLATION-SPEC.md iter 4).
3
+ *
4
+ * Source of truth for: topic→worktree bindings, exclusive locks, fencing tokens,
5
+ * commit-trailer signing, force-take protocol, state reconciliation matrix.
6
+ *
7
+ * Two storage layers:
8
+ * - Machine-local (gitignored): bindings, locks, fencing, binding-history.db, snapshots/
9
+ * - Git-synced (signed): topic-branch-map (path-free), binding-history-log.jsonl
10
+ *
11
+ * Authority model:
12
+ * - Local advisory: pre-commit gate (cwd-vs-binding, lock-owner)
13
+ * - Local authoritative-on-trailer: commit-msg hook (Ed25519 sign tree+nonce+parent)
14
+ * - Origin authoritative: GitHub Repository Ruleset + workflow check (Ed25519 verify offline)
15
+ *
16
+ * Same-topic concurrency: EXCLUSIVE — one session per topic worktree.
17
+ */
18
+ import crypto from 'node:crypto';
19
+ import { execFile, execFileSync } from 'node:child_process';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { promisify } from 'node:util';
23
+ import { EventEmitter } from 'node:events';
24
+ const execFileAsync = promisify(execFile);
25
+ // ── Constants ────────────────────────────────────────────────────────
26
+ const SCHEMA_BINDINGS = 'v1';
27
+ const SCHEMA_LOCK = 'v2';
28
+ const DEFAULT_MAX_PUSH_DELAY_S = 7 * 24 * 3600;
29
+ const TRAILER_NONCE_BYTES = 16;
30
+ const HEARTBEAT_STALE_MS = 60_000;
31
+ const FORCE_TAKE_TIMEOUT_MS = 10_000;
32
+ const MAX_ACTIVE_BINDINGS = 30;
33
+ // File modes
34
+ const SECURE_FILE_MODE = 0o600;
35
+ // ── Helpers ──────────────────────────────────────────────────────────
36
+ function slugify(input) {
37
+ return (input || '')
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9]+/g, '-')
40
+ .replace(/^-+|-+$/g, '')
41
+ .slice(0, 30) || 'topic';
42
+ }
43
+ function sanitizeTopicId(topicId) {
44
+ if (topicId === 'platform')
45
+ return 'platform';
46
+ const n = Number(topicId);
47
+ if (!Number.isInteger(n) || n <= 0 || n > 1e12) {
48
+ throw new Error(`Invalid topicId: ${String(topicId)} — must be positive integer or "platform"`);
49
+ }
50
+ return n;
51
+ }
52
+ function detectBootId() {
53
+ try {
54
+ if (process.platform === 'darwin') {
55
+ return execFileSync('sysctl', ['-n', 'kern.boottime'], { encoding: 'utf-8', timeout: 2000 }).trim();
56
+ }
57
+ if (process.platform === 'linux') {
58
+ return fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf-8').trim();
59
+ }
60
+ if (process.platform === 'win32') {
61
+ return execFileSync('wmic', ['os', 'get', 'lastbootuptime'], { encoding: 'utf-8', timeout: 2000 }).trim();
62
+ }
63
+ }
64
+ catch {
65
+ /* @silent-fallback-ok */
66
+ }
67
+ return `unknown:${process.pid}`;
68
+ }
69
+ async function detectFsType(workingPath) {
70
+ try {
71
+ if (process.platform === 'darwin') {
72
+ const { stdout } = await execFileAsync('df', ['-T', 'apfs,hfs', workingPath], { timeout: 3000 });
73
+ if (stdout.includes('apfs'))
74
+ return 'apfs';
75
+ if (stdout.includes('hfs'))
76
+ return 'hfs+';
77
+ return 'unknown';
78
+ }
79
+ if (process.platform === 'linux') {
80
+ const { stdout } = await execFileAsync('stat', ['-f', '-c', '%T', workingPath], { timeout: 3000 });
81
+ const t = stdout.trim().toLowerCase();
82
+ if (t.includes('btrfs'))
83
+ return 'btrfs';
84
+ if (t.includes('xfs'))
85
+ return 'xfs';
86
+ if (t.includes('ext'))
87
+ return 'ext4';
88
+ if (t.includes('tmpfs'))
89
+ return 'tmpfs';
90
+ return 'unknown';
91
+ }
92
+ if (process.platform === 'win32')
93
+ return 'ntfs';
94
+ }
95
+ catch {
96
+ /* @silent-fallback-ok */
97
+ }
98
+ return 'unknown';
99
+ }
100
+ // ── WorktreeManager ──────────────────────────────────────────────────
101
+ export class WorktreeManager extends EventEmitter {
102
+ opts;
103
+ // In-memory state (mirrors machine-local files)
104
+ bindings = new Map(); // key = `${topicId}:${mode}`
105
+ locks = new Map(); // key = worktreePath
106
+ fencingCounter = new Map(); // machineId → counter
107
+ bindingsFile;
108
+ fencingFile;
109
+ bindingHistoryLog;
110
+ worktreesRoot;
111
+ snapshotsDir;
112
+ quarantineDir;
113
+ constructor(options) {
114
+ super();
115
+ const stateDir = options.stateDir;
116
+ this.opts = {
117
+ ...options,
118
+ maxPushDelaySeconds: options.maxPushDelaySeconds ?? DEFAULT_MAX_PUSH_DELAY_S,
119
+ fsTypeProbe: options.fsTypeProbe ?? detectFsType,
120
+ };
121
+ const localStateDir = path.join(stateDir, 'local-state');
122
+ const stateSyncDir = path.join(stateDir, 'state');
123
+ this.bindingsFile = path.join(localStateDir, 'topic-worktree-bindings.json');
124
+ this.fencingFile = path.join(localStateDir, 'fencing.json');
125
+ this.bindingHistoryLog = path.join(stateSyncDir, 'binding-history-log.jsonl');
126
+ this.worktreesRoot = path.join(stateDir, 'worktrees');
127
+ this.snapshotsDir = path.join(this.worktreesRoot, '.snapshots');
128
+ this.quarantineDir = path.join(this.worktreesRoot, '.quarantine');
129
+ }
130
+ /**
131
+ * Load persistent state from disk. Idempotent.
132
+ */
133
+ initialize() {
134
+ fs.mkdirSync(this.worktreesRoot, { recursive: true });
135
+ fs.mkdirSync(this.snapshotsDir, { recursive: true });
136
+ fs.mkdirSync(this.quarantineDir, { recursive: true });
137
+ fs.mkdirSync(path.dirname(this.bindingsFile), { recursive: true });
138
+ fs.mkdirSync(path.dirname(this.bindingHistoryLog), { recursive: true });
139
+ this.loadBindings();
140
+ this.loadFencing();
141
+ this.loadLocks();
142
+ }
143
+ // ── Bindings I/O ───────────────────────────────────────────────────
144
+ loadBindings() {
145
+ if (!fs.existsSync(this.bindingsFile))
146
+ return;
147
+ try {
148
+ const raw = JSON.parse(fs.readFileSync(this.bindingsFile, 'utf-8'));
149
+ if (raw.schema !== SCHEMA_BINDINGS) {
150
+ throw new Error(`Bindings schema mismatch: ${raw.schema} ≠ ${SCHEMA_BINDINGS}`);
151
+ }
152
+ for (const b of raw.bindings ?? []) {
153
+ if (!this.verifySignature(b)) {
154
+ this.emit('tamper', { kind: 'binding', binding: b });
155
+ continue;
156
+ }
157
+ this.bindings.set(this.bindingKey(b.topicId, b.mode), b);
158
+ }
159
+ }
160
+ catch (err) {
161
+ this.emit('error', new Error(`Failed to load bindings: ${err.message}`));
162
+ }
163
+ }
164
+ saveBindings() {
165
+ const tmp = `${this.bindingsFile}.tmp`;
166
+ const data = {
167
+ schema: SCHEMA_BINDINGS,
168
+ bindings: [...this.bindings.values()],
169
+ };
170
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: SECURE_FILE_MODE });
171
+ fs.renameSync(tmp, this.bindingsFile);
172
+ }
173
+ loadFencing() {
174
+ if (!fs.existsSync(this.fencingFile))
175
+ return;
176
+ try {
177
+ const raw = JSON.parse(fs.readFileSync(this.fencingFile, 'utf-8'));
178
+ // verify HMAC over each {machineId, counter}
179
+ for (const [machineId, entry] of Object.entries(raw.counters ?? {})) {
180
+ const e = entry;
181
+ const want = this.hmacHex(`fencing:${machineId}:${e.counter}`);
182
+ if (want === e.signature) {
183
+ this.fencingCounter.set(machineId, e.counter);
184
+ }
185
+ else {
186
+ this.emit('tamper', { kind: 'fencing', machineId });
187
+ }
188
+ }
189
+ }
190
+ catch (err) {
191
+ this.emit('error', new Error(`Failed to load fencing: ${err.message}`));
192
+ }
193
+ }
194
+ saveFencing() {
195
+ const counters = {};
196
+ for (const [machineId, counter] of this.fencingCounter) {
197
+ counters[machineId] = { counter, signature: this.hmacHex(`fencing:${machineId}:${counter}`) };
198
+ }
199
+ const tmp = `${this.fencingFile}.tmp`;
200
+ fs.writeFileSync(tmp, JSON.stringify({ schema: 'v1', counters }, null, 2), { mode: SECURE_FILE_MODE });
201
+ fs.renameSync(tmp, this.fencingFile);
202
+ }
203
+ loadLocks() {
204
+ // Walk worktrees/ for .session.lock files
205
+ if (!fs.existsSync(this.worktreesRoot))
206
+ return;
207
+ for (const entry of fs.readdirSync(this.worktreesRoot, { withFileTypes: true })) {
208
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
209
+ continue;
210
+ const lockFile = path.join(this.worktreesRoot, entry.name, '.session.lock');
211
+ if (!fs.existsSync(lockFile))
212
+ continue;
213
+ try {
214
+ const lock = JSON.parse(fs.readFileSync(lockFile, 'utf-8'));
215
+ if (this.verifyLockSignature(lock)) {
216
+ this.locks.set(path.join(this.worktreesRoot, entry.name), lock);
217
+ }
218
+ }
219
+ catch {
220
+ /* @silent-fallback-ok */
221
+ }
222
+ }
223
+ }
224
+ // ── Signing ────────────────────────────────────────────────────────
225
+ hmacHex(input) {
226
+ return crypto.createHmac('sha256', this.opts.hmacKey).update(input).digest('hex');
227
+ }
228
+ signBinding(b) {
229
+ const payload = `binding:${b.topicId}:${b.branch}:${b.worktreePath}:${b.mode}:${b.machineId}:${b.fencingToken}:${b.createdAt}:${b.createdBy}:${b.status}`;
230
+ return { ...b, serverSignature: this.hmacHex(payload) };
231
+ }
232
+ verifySignature(b) {
233
+ if (!b.serverSignature)
234
+ return false;
235
+ const payload = `binding:${b.topicId}:${b.branch}:${b.worktreePath}:${b.mode}:${b.machineId}:${b.fencingToken}:${b.createdAt}:${b.createdBy}:${b.status}`;
236
+ return this.hmacHex(payload) === b.serverSignature;
237
+ }
238
+ signLock(lock) {
239
+ const payload = `lock:${lock.machineId}:${lock.bootId}:${lock.pid}:${lock.processStartTime}:${lock.sessionId}:${lock.fencingToken}:${lock.topicId}:${lock.acquiredAt}:${lock.heartbeatAt}`;
240
+ return { ...lock, serverSignature: this.hmacHex(payload) };
241
+ }
242
+ verifyLockSignature(lock) {
243
+ if (!lock.serverSignature)
244
+ return false;
245
+ const payload = `lock:${lock.machineId}:${lock.bootId}:${lock.pid}:${lock.processStartTime}:${lock.sessionId}:${lock.fencingToken}:${lock.topicId}:${lock.acquiredAt}:${lock.heartbeatAt}`;
246
+ return this.hmacHex(payload) === lock.serverSignature;
247
+ }
248
+ // ── Fencing tokens ─────────────────────────────────────────────────
249
+ nextFencingToken() {
250
+ const machineId = this.opts.machineId;
251
+ const current = this.fencingCounter.get(machineId) ?? 0;
252
+ const next = current + 1;
253
+ this.fencingCounter.set(machineId, next);
254
+ this.saveFencing();
255
+ return `${machineId}:${next}`;
256
+ }
257
+ // ── Resolve / spawn-time entry point ───────────────────────────────
258
+ /**
259
+ * Atomically: ensure binding for (topicId, mode) exists, ensure worktree exists,
260
+ * acquire exclusive lock, return the cwd to spawn into.
261
+ *
262
+ * Throws on lock contention with `code: 'LOCK_HELD'`.
263
+ */
264
+ async resolve(args) {
265
+ const topicId = sanitizeTopicId(args.topicId);
266
+ const slug = args.slug ? slugify(args.slug) : `topic-${topicId}`;
267
+ const key = this.bindingKey(topicId, args.mode);
268
+ let binding = this.bindings.get(key);
269
+ if (!binding) {
270
+ binding = await this.createBinding({ topicId, mode: args.mode, slug, sessionId: args.sessionId });
271
+ }
272
+ // Lock-acquire
273
+ const existingLock = this.locks.get(binding.worktreePath);
274
+ if (existingLock && this.isLockLive(existingLock)) {
275
+ const ageMs = Date.now() - new Date(existingLock.heartbeatAt).getTime();
276
+ const err = new Error(`Lock held by session ${existingLock.sessionId} (machine ${existingLock.machineId}, age ${ageMs}ms)`);
277
+ err.code = 'LOCK_HELD';
278
+ err.holder = { sessionId: existingLock.sessionId, machineId: existingLock.machineId, ageMs };
279
+ throw err;
280
+ }
281
+ const fencingToken = this.nextFencingToken();
282
+ const now = new Date().toISOString();
283
+ const lock = this.signLock({
284
+ schema: SCHEMA_LOCK,
285
+ machineId: this.opts.machineId,
286
+ bootId: this.opts.bootId,
287
+ pid: args.pid,
288
+ processStartTime: args.processStartTime,
289
+ sessionId: args.sessionId,
290
+ fencingToken,
291
+ topicId,
292
+ acquiredAt: now,
293
+ heartbeatAt: now,
294
+ });
295
+ this.writeLockAtomic(binding.worktreePath, lock);
296
+ this.locks.set(binding.worktreePath, lock);
297
+ // Update binding fencing token
298
+ binding = this.signBinding({ ...binding, fencingToken });
299
+ this.bindings.set(key, binding);
300
+ this.saveBindings();
301
+ // Write session-context.json
302
+ const sessionContextPath = path.join(binding.worktreePath, '.instar', 'session-context.json');
303
+ fs.mkdirSync(path.dirname(sessionContextPath), { recursive: true });
304
+ const ctx = {
305
+ schema: 'v1',
306
+ sessionId: args.sessionId,
307
+ topicId,
308
+ mode: args.mode,
309
+ branch: binding.branch,
310
+ fencingToken,
311
+ worktreePath: binding.worktreePath,
312
+ machineId: this.opts.machineId,
313
+ uid: process.getuid?.() ?? 0,
314
+ pid: args.pid,
315
+ issuedAt: now,
316
+ };
317
+ const ctxSig = this.hmacHex(`session-context:${JSON.stringify(ctx)}`);
318
+ fs.writeFileSync(sessionContextPath, JSON.stringify({ ...ctx, serverSignature: ctxSig }, null, 2), { mode: SECURE_FILE_MODE });
319
+ this.emit('lock:acquired', { binding, lock });
320
+ return {
321
+ cwd: binding.worktreePath,
322
+ branch: binding.branch,
323
+ fencingToken,
324
+ sessionContextPath,
325
+ mode: args.mode,
326
+ binding,
327
+ };
328
+ }
329
+ /**
330
+ * Release the lock held by (sessionId, fencingToken). No-op if not the holder.
331
+ */
332
+ release(args) {
333
+ for (const [worktreePath, lock] of this.locks) {
334
+ if (lock.sessionId === args.sessionId && lock.fencingToken === args.fencingToken) {
335
+ this.locks.delete(worktreePath);
336
+ const lockFile = path.join(worktreePath, '.session.lock');
337
+ try {
338
+ fs.unlinkSync(lockFile);
339
+ }
340
+ catch { /* @silent-fallback-ok */ }
341
+ this.emit('lock:released', { worktreePath, sessionId: args.sessionId });
342
+ return { released: true };
343
+ }
344
+ }
345
+ return { released: false };
346
+ }
347
+ /**
348
+ * Server-stamped heartbeat. Validates fencing token before accepting.
349
+ */
350
+ heartbeat(args) {
351
+ for (const [worktreePath, lock] of this.locks) {
352
+ if (lock.sessionId === args.sessionId) {
353
+ if (lock.fencingToken !== args.fencingToken) {
354
+ return { ok: false, reason: 'fencing-token-superseded' };
355
+ }
356
+ const now = new Date().toISOString();
357
+ const updated = this.signLock({ ...lock, heartbeatAt: now });
358
+ this.writeLockAtomic(worktreePath, updated);
359
+ this.locks.set(worktreePath, updated);
360
+ return { ok: true };
361
+ }
362
+ }
363
+ return { ok: false, reason: 'no-such-session' };
364
+ }
365
+ /**
366
+ * Force-take an existing lock (per spec section "Force-take protocol iter 4").
367
+ * Performs FS snapshot + scoped stash (no --include-ignored), then bumps fencing token.
368
+ */
369
+ async forceTake(args) {
370
+ const key = this.bindingKey(args.topicId, args.mode);
371
+ const binding = this.bindings.get(key);
372
+ if (!binding)
373
+ throw new Error(`No binding for ${args.topicId}/${args.mode}`);
374
+ const previousLock = this.locks.get(binding.worktreePath) ?? null;
375
+ // FS snapshot tarball (excluding obvious build dirs; .env preserved)
376
+ const snapshotPath = path.join(this.snapshotsDir, `${path.basename(binding.worktreePath)}-${Date.now()}.tar.zst`);
377
+ await this.snapshotWorktree(binding.worktreePath, snapshotPath);
378
+ // git stash --include-untracked (NOT --include-ignored — would bloat .git/objects)
379
+ let stashRef = null;
380
+ try {
381
+ const { stdout } = await execFileAsync('git', ['-C', binding.worktreePath, 'stash', 'push', '--include-untracked',
382
+ '-m', `instar-force-take from ${previousLock?.sessionId ?? '<none>'} by ${args.bySessionId} at ${new Date().toISOString()}`], { timeout: FORCE_TAKE_TIMEOUT_MS });
383
+ const refMatch = stdout.match(/stash@\{\d+\}/);
384
+ if (refMatch)
385
+ stashRef = refMatch[0];
386
+ }
387
+ catch {
388
+ // stash may fail on a clean tree — that's ok
389
+ }
390
+ if (previousLock &&
391
+ previousLock.machineId === this.opts.machineId &&
392
+ previousLock.bootId === this.opts.bootId &&
393
+ previousLock.pid !== process.pid // never SIGTERM ourselves
394
+ ) {
395
+ try {
396
+ process.kill(previousLock.pid, 'SIGTERM');
397
+ }
398
+ catch { /* @silent-fallback-ok */ }
399
+ }
400
+ this.locks.delete(binding.worktreePath);
401
+ try {
402
+ fs.unlinkSync(path.join(binding.worktreePath, '.session.lock'));
403
+ }
404
+ catch { /* @silent-fallback-ok */ }
405
+ this.appendHistoryEvent({
406
+ kind: 'force-take',
407
+ topicId: args.topicId,
408
+ bySessionId: args.bySessionId,
409
+ previousSessionId: previousLock?.sessionId ?? null,
410
+ snapshotPath,
411
+ stashRef,
412
+ });
413
+ this.emit('force-take', { binding, snapshotPath, stashRef, previousLock });
414
+ return { snapshotPath, stashRef, previousLock };
415
+ }
416
+ // ── Trailer signing (commit-msg path) ──────────────────────────────
417
+ /**
418
+ * Issue a signed trailer set for a new commit.
419
+ * The hook calls this after computing treeHash + parents.
420
+ */
421
+ signTrailer(args) {
422
+ // Validate session lock is current
423
+ let binding = null;
424
+ let lock = null;
425
+ for (const [worktreePath, l] of this.locks) {
426
+ if (l.sessionId === args.sessionId && l.fencingToken === args.fencingToken) {
427
+ lock = l;
428
+ for (const b of this.bindings.values()) {
429
+ if (b.worktreePath === worktreePath) {
430
+ binding = b;
431
+ break;
432
+ }
433
+ }
434
+ break;
435
+ }
436
+ }
437
+ if (!lock || !binding)
438
+ throw new Error('No matching lock for session/fencingToken');
439
+ const nonce = crypto.randomBytes(TRAILER_NONCE_BYTES).toString('base64url');
440
+ const issued = Math.floor(Date.now() / 1000);
441
+ const maxPushDelay = this.opts.maxPushDelaySeconds;
442
+ const keyVersion = this.opts.signingKey.keyVersion;
443
+ const payload = [
444
+ args.treeHash,
445
+ String(binding.topicId),
446
+ args.sessionId,
447
+ nonce,
448
+ args.parents.join(','),
449
+ String(issued),
450
+ String(maxPushDelay),
451
+ String(keyVersion),
452
+ this.opts.repoOriginUrl,
453
+ ].join('|');
454
+ const signature = crypto.sign(null, Buffer.from(crypto.createHash('sha256').update(payload).digest()), {
455
+ key: this.opts.signingKey.privateKeyPem,
456
+ }).toString('base64url');
457
+ const trailers = [
458
+ `Instar-Topic-Id: ${binding.topicId}`,
459
+ `Instar-Session: ${args.sessionId}`,
460
+ `Instar-Worktree-Branch: ${binding.branch}`,
461
+ `Instar-Trailer-Nonce: ${nonce}`,
462
+ `Instar-Trailer-Parent: ${args.parents.join(',')}`,
463
+ `Instar-Trailer-Issued: ${issued}`,
464
+ `Instar-Trailer-MaxPushDelay: ${maxPushDelay}`,
465
+ `Instar-Trailer-KeyVersion: ${keyVersion}`,
466
+ `Instar-Trailer-Sig: ${signature}`,
467
+ ];
468
+ this.appendHistoryEvent({
469
+ kind: 'trailer-issued',
470
+ topicId: binding.topicId,
471
+ sessionId: args.sessionId,
472
+ treeHash: args.treeHash,
473
+ parents: args.parents,
474
+ nonce,
475
+ issued,
476
+ });
477
+ return { trailers, nonce, issued, maxPushDelay, keyVersion };
478
+ }
479
+ /**
480
+ * Verify a trailer set (used by GH check via /gh-check/verify-nonce).
481
+ * Returns ok plus a reason on failure (uniform error for oracle protection).
482
+ */
483
+ verifyTrailer(args) {
484
+ const t = args.trailers;
485
+ const required = [
486
+ 'Instar-Topic-Id', 'Instar-Session', 'Instar-Worktree-Branch',
487
+ 'Instar-Trailer-Nonce', 'Instar-Trailer-Parent', 'Instar-Trailer-Issued',
488
+ 'Instar-Trailer-MaxPushDelay', 'Instar-Trailer-KeyVersion', 'Instar-Trailer-Sig',
489
+ ];
490
+ for (const k of required) {
491
+ if (!t[k])
492
+ return { ok: false, reason: 'verifier_says_no' };
493
+ }
494
+ const issued = Number(t['Instar-Trailer-Issued']);
495
+ const maxPushDelay = Number(t['Instar-Trailer-MaxPushDelay']);
496
+ const now = args.pushReceivedAt ?? Math.floor(Date.now() / 1000);
497
+ if (now < issued || now > issued + maxPushDelay) {
498
+ return { ok: false, reason: 'verifier_says_no' };
499
+ }
500
+ // Signature verification handled by GH workflow offline using public key.
501
+ // Server-side only checks nonce uniqueness.
502
+ return { ok: true };
503
+ }
504
+ // ── Binding history log (git-synced; future: K3 Merkle chain) ──────
505
+ /**
506
+ * Append-only signed log with Merkle chain (K3 hardening).
507
+ *
508
+ * Each entry includes `prevEntrySha = sha256(previousLine)`, so a malicious
509
+ * `git rebase -i` that drops or reorders entries breaks the chain — detectable
510
+ * by `verifyHistoryChain()`. The chain head is periodically anchored to a
511
+ * GitHub Repo Variable for cross-machine tamper detection.
512
+ */
513
+ appendHistoryEvent(event) {
514
+ const prevEntrySha = this.computeChainHead();
515
+ const entry = {
516
+ ts: Date.now(),
517
+ machineId: this.opts.machineId,
518
+ prevEntrySha,
519
+ ...event,
520
+ };
521
+ const line = JSON.stringify(entry);
522
+ const sig = this.hmacHex(line);
523
+ fs.appendFileSync(this.bindingHistoryLog, `${line}\t${sig}\n`, { mode: SECURE_FILE_MODE });
524
+ }
525
+ /**
526
+ * Compute SHA-256 of the most recent log line (Merkle chain head).
527
+ * Returns null on empty log.
528
+ */
529
+ computeChainHead() {
530
+ if (!fs.existsSync(this.bindingHistoryLog))
531
+ return null;
532
+ const data = fs.readFileSync(this.bindingHistoryLog, 'utf-8');
533
+ const lines = data.split('\n').filter(Boolean);
534
+ if (lines.length === 0)
535
+ return null;
536
+ const lastLine = lines[lines.length - 1];
537
+ return crypto.createHash('sha256').update(lastLine).digest('hex');
538
+ }
539
+ /**
540
+ * Walk the entire log and verify (a) HMAC of each line, and (b) prevEntrySha
541
+ * chain integrity. Returns null on success, or the first detected breach.
542
+ */
543
+ verifyHistoryChain() {
544
+ if (!fs.existsSync(this.bindingHistoryLog))
545
+ return null;
546
+ const data = fs.readFileSync(this.bindingHistoryLog, 'utf-8');
547
+ const lines = data.split('\n').filter(Boolean);
548
+ let prevSha = null;
549
+ for (let i = 0; i < lines.length; i++) {
550
+ const [line, sig] = lines[i].split('\t');
551
+ if (this.hmacHex(line) !== sig)
552
+ return { lineNumber: i, reason: 'hmac-mismatch' };
553
+ try {
554
+ const parsed = JSON.parse(line);
555
+ if ((parsed.prevEntrySha ?? null) !== prevSha) {
556
+ return { lineNumber: i, reason: 'merkle-chain-break' };
557
+ }
558
+ prevSha = crypto.createHash('sha256').update(lines[i]).digest('hex');
559
+ }
560
+ catch {
561
+ return { lineNumber: i, reason: 'invalid-json' };
562
+ }
563
+ }
564
+ return null;
565
+ }
566
+ /**
567
+ * Check whether nonce was previously used for a different commit.
568
+ * Idempotent: returns 'unseen' or 'seen-for-same-commit' (allowed retry) or 'seen-for-different-commit'.
569
+ */
570
+ checkNonceUnique(args) {
571
+ if (!fs.existsSync(this.bindingHistoryLog))
572
+ return 'unseen';
573
+ const data = fs.readFileSync(this.bindingHistoryLog, 'utf-8');
574
+ for (const rawLine of data.split('\n')) {
575
+ if (!rawLine)
576
+ continue;
577
+ const [line] = rawLine.split('\t');
578
+ try {
579
+ const e = JSON.parse(line);
580
+ // Only `nonce-bound` events establish commit bindings. `trailer-issued` events
581
+ // record issuance but don't bind a commitSha yet (the commit hasn't happened).
582
+ if (e.kind !== 'nonce-bound')
583
+ continue;
584
+ if (e.nonce === args.nonce) {
585
+ if (e.commitSha === args.commitSha)
586
+ return 'seen-for-same-commit';
587
+ return 'seen-for-different-commit';
588
+ }
589
+ }
590
+ catch { /* @silent-fallback-ok */ }
591
+ }
592
+ return 'unseen';
593
+ }
594
+ /**
595
+ * Bind a commit SHA to a previously-issued trailer (called post-push).
596
+ */
597
+ recordCommitForNonce(args) {
598
+ this.appendHistoryEvent({ kind: 'nonce-bound', ...args });
599
+ }
600
+ // ── Worktree creation (cross-platform) ─────────────────────────────
601
+ async createBinding(args) {
602
+ if (this.bindings.size >= MAX_ACTIVE_BINDINGS) {
603
+ // LRU evict merged/abandoned first
604
+ let evicted = false;
605
+ for (const [k, b] of this.bindings) {
606
+ if (b.status === 'merged' || b.status === 'abandoned') {
607
+ this.bindings.delete(k);
608
+ evicted = true;
609
+ break;
610
+ }
611
+ }
612
+ if (!evicted)
613
+ throw new Error(`Max active bindings (${MAX_ACTIVE_BINDINGS}) reached`);
614
+ }
615
+ const branch = args.mode === 'platform'
616
+ ? `platform/${args.slug}`
617
+ : args.mode === 'doc-fix'
618
+ ? `topic/${args.topicId}-doc-fix`
619
+ : `topic/${args.topicId}-${args.slug}`;
620
+ const dirName = args.mode === 'read-only'
621
+ ? `topic-${args.topicId}-readonly`
622
+ : args.mode === 'doc-fix'
623
+ ? `topic-${args.topicId}-doc-fix`
624
+ : args.mode === 'platform'
625
+ ? `topic-platform-${args.slug}`
626
+ : `topic-${args.topicId}-${args.slug}`;
627
+ const worktreePath = path.join(this.worktreesRoot, dirName);
628
+ // Refuse if exists
629
+ if (fs.existsSync(worktreePath)) {
630
+ // Adopt existing if .git is valid
631
+ try {
632
+ execFileSync('git', ['-C', worktreePath, 'rev-parse', 'HEAD'], { stdio: 'pipe', timeout: 3000 });
633
+ }
634
+ catch {
635
+ throw new Error(`Worktree path ${worktreePath} exists but is not a valid git worktree`);
636
+ }
637
+ }
638
+ else {
639
+ // Create branch if needed; then `git worktree add`
640
+ try {
641
+ execFileSync('git', ['-C', this.opts.projectDir, 'rev-parse', '--verify', branch], { stdio: 'pipe', timeout: 3000 });
642
+ }
643
+ catch {
644
+ execFileSync('git', ['-C', this.opts.projectDir, 'branch', branch], { timeout: 5000 });
645
+ }
646
+ execFileSync('git', ['-C', this.opts.projectDir, 'worktree', 'add', worktreePath, branch], { timeout: 30_000 });
647
+ // Cross-platform fast-copy node_modules from main if present (avoid `cp -al` per K-fix; use clonefile/reflink only)
648
+ await this.fastCopyDeps(worktreePath);
649
+ }
650
+ const fencingToken = this.nextFencingToken();
651
+ const now = new Date().toISOString();
652
+ const binding = this.signBinding({
653
+ topicId: args.topicId,
654
+ slug: args.slug,
655
+ branch,
656
+ worktreePath,
657
+ mode: args.mode,
658
+ status: 'active',
659
+ createdAt: now,
660
+ createdBy: args.sessionId,
661
+ machineId: this.opts.machineId,
662
+ fencingToken,
663
+ });
664
+ this.bindings.set(this.bindingKey(args.topicId, args.mode), binding);
665
+ this.saveBindings();
666
+ this.appendHistoryEvent({
667
+ kind: 'binding-created',
668
+ topicId: args.topicId,
669
+ mode: args.mode,
670
+ branch,
671
+ worktreePath,
672
+ });
673
+ this.emit('binding:created', binding);
674
+ return binding;
675
+ }
676
+ async fastCopyDeps(worktreePath) {
677
+ const mainNodeModules = path.join(this.opts.projectDir, 'node_modules');
678
+ const wtNodeModules = path.join(worktreePath, 'node_modules');
679
+ if (!fs.existsSync(mainNodeModules) || fs.existsSync(wtNodeModules))
680
+ return;
681
+ const fsType = await this.opts.fsTypeProbe(worktreePath);
682
+ try {
683
+ if (fsType === 'apfs') {
684
+ // CoW clone — sub-second, isolation-safe (writes diverge)
685
+ execFileSync('cp', ['-c', '-R', mainNodeModules, wtNodeModules], { timeout: 60_000 });
686
+ }
687
+ else if (fsType === 'btrfs' || fsType === 'xfs') {
688
+ execFileSync('cp', ['-R', '--reflink=auto', mainNodeModules, wtNodeModules], { timeout: 60_000 });
689
+ }
690
+ else {
691
+ // ext4, HFS+, NTFS, tmpfs, unknown — full copy. NEVER `cp -al` (inode aliasing breaks isolation).
692
+ execFileSync('cp', ['-R', mainNodeModules, wtNodeModules], { timeout: 600_000 });
693
+ }
694
+ }
695
+ catch (err) {
696
+ this.emit('warn', `node_modules copy failed (${err.message}); will need fresh install`);
697
+ }
698
+ }
699
+ // ── Snapshot ───────────────────────────────────────────────────────
700
+ async snapshotWorktree(worktreePath, snapshotPath) {
701
+ const exclusions = ['node_modules', 'dist', '.next', 'build', 'target', '.cache'];
702
+ const excludeArgs = [];
703
+ for (const x of exclusions)
704
+ excludeArgs.push('--exclude', x);
705
+ // Use `tar` (BSD or GNU); pipe through zstd if available
706
+ const zstdAvailable = (() => {
707
+ try {
708
+ execFileSync('zstd', ['--version'], { stdio: 'pipe' });
709
+ return true;
710
+ }
711
+ catch {
712
+ return false;
713
+ }
714
+ })();
715
+ if (zstdAvailable) {
716
+ await execFileAsync('sh', ['-c',
717
+ `tar -C "${path.dirname(worktreePath)}" ${excludeArgs.map(a => `'${a}'`).join(' ')} -cf - "${path.basename(worktreePath)}" | zstd -o "${snapshotPath}"`,
718
+ ], { timeout: FORCE_TAKE_TIMEOUT_MS });
719
+ }
720
+ else {
721
+ const fallback = snapshotPath.replace(/\.zst$/, '.gz');
722
+ await execFileAsync('tar', [
723
+ '-C', path.dirname(worktreePath),
724
+ ...excludeArgs,
725
+ '-czf', fallback,
726
+ path.basename(worktreePath),
727
+ ], { timeout: FORCE_TAKE_TIMEOUT_MS });
728
+ }
729
+ fs.chmodSync(zstdAvailable ? snapshotPath : snapshotPath.replace(/\.zst$/, '.gz'), SECURE_FILE_MODE);
730
+ }
731
+ // ── Lock helpers ───────────────────────────────────────────────────
732
+ writeLockAtomic(worktreePath, lock) {
733
+ const lockFile = path.join(worktreePath, '.session.lock');
734
+ const tmp = `${lockFile}.tmp.${process.pid}`;
735
+ fs.writeFileSync(tmp, JSON.stringify(lock, null, 2), { flag: 'wx', mode: SECURE_FILE_MODE });
736
+ fs.renameSync(tmp, lockFile);
737
+ }
738
+ isLockLive(lock) {
739
+ const ageMs = Date.now() - new Date(lock.heartbeatAt).getTime();
740
+ if (ageMs > HEARTBEAT_STALE_MS)
741
+ return false;
742
+ if (lock.machineId !== this.opts.machineId)
743
+ return true; // can't verify other machine's PID
744
+ if (lock.bootId !== this.opts.bootId)
745
+ return false; // PID reuse risk after reboot
746
+ try {
747
+ process.kill(lock.pid, 0);
748
+ return true;
749
+ }
750
+ catch {
751
+ return false;
752
+ }
753
+ }
754
+ // ── State reconciliation matrix ────────────────────────────────────
755
+ /**
756
+ * Walk worktree state from three sources (bindings ∪ filesystem ∪ git worktree list)
757
+ * and return the rows that need action per the matrix.
758
+ */
759
+ reconcile() {
760
+ const out = [];
761
+ let gitWorktrees = [];
762
+ try {
763
+ const stdout = execFileSync('git', ['-C', this.opts.projectDir, 'worktree', 'list', '--porcelain', '-z'], { encoding: 'utf-8', timeout: 5000 });
764
+ for (const block of stdout.split('\0\0')) {
765
+ const m = block.match(/worktree\s+(.+)/);
766
+ if (m)
767
+ gitWorktrees.push({ path: m[1] });
768
+ }
769
+ }
770
+ catch { /* @silent-fallback-ok */ }
771
+ const fsWorktrees = fs.existsSync(this.worktreesRoot)
772
+ ? fs.readdirSync(this.worktreesRoot, { withFileTypes: true })
773
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
774
+ .map(e => path.join(this.worktreesRoot, e.name))
775
+ : [];
776
+ const bindingByPath = new Map();
777
+ for (const b of this.bindings.values())
778
+ bindingByPath.set(b.worktreePath, b);
779
+ const allPaths = new Set([
780
+ ...bindingByPath.keys(),
781
+ ...gitWorktrees.map(w => w.path),
782
+ ...fsWorktrees,
783
+ ]);
784
+ for (const p of allPaths) {
785
+ const binding = bindingByPath.get(p) ?? null;
786
+ const gw = gitWorktrees.find(w => w.path === p) ?? null;
787
+ const onFs = fsWorktrees.includes(p) || fs.existsSync(p);
788
+ if (binding && onFs && gw) {
789
+ out.push({ row: 'binding+fs+git', binding, gitWorktree: gw, fsPath: p, action: 'normal' });
790
+ }
791
+ else if (binding && onFs && !gw) {
792
+ out.push({ row: 'binding+fs-no-git', binding, gitWorktree: null, fsPath: p, action: 'repair-worktree-add' });
793
+ }
794
+ else if (binding && !onFs) {
795
+ out.push({ row: 'binding-no-fs', binding, gitWorktree: gw, fsPath: null, action: 'quarantine-binding' });
796
+ }
797
+ else if (!binding && onFs && p.startsWith(this.worktreesRoot) && gw) {
798
+ out.push({ row: 'no-binding+fs+git', binding: null, gitWorktree: gw, fsPath: p, action: 'adopt-binding' });
799
+ }
800
+ else if (!binding && onFs && p.startsWith(this.worktreesRoot) && !gw) {
801
+ out.push({ row: 'no-binding+fs-no-git', binding: null, gitWorktree: null, fsPath: p, action: 'quarantine-orphan' });
802
+ }
803
+ else if (!binding && !p.startsWith(this.worktreesRoot) && gw) {
804
+ out.push({ row: 'external-worktree', binding: null, gitWorktree: gw, fsPath: p, action: 'adopt-external-alert-once' });
805
+ }
806
+ }
807
+ return out;
808
+ }
809
+ // ── Internal ───────────────────────────────────────────────────────
810
+ bindingKey(topicId, mode) {
811
+ return `${topicId}:${mode}`;
812
+ }
813
+ // ── Read accessors ─────────────────────────────────────────────────
814
+ getBinding(topicId, mode) {
815
+ return this.bindings.get(this.bindingKey(topicId, mode));
816
+ }
817
+ listBindings() { return [...this.bindings.values()]; }
818
+ getLock(worktreePath) { return this.locks.get(worktreePath); }
819
+ }
820
+ // ── Factory ──────────────────────────────────────────────────────────
821
+ /**
822
+ * Build a WorktreeManager from an instar agent config + machine identity.
823
+ * Generates an HMAC key on first start; persists to keychain (or flat file fallback).
824
+ */
825
+ export function createWorktreeManager(args) {
826
+ const mgr = new WorktreeManager({
827
+ ...args,
828
+ bootId: detectBootId(),
829
+ });
830
+ mgr.initialize();
831
+ return mgr;
832
+ }
833
+ //# sourceMappingURL=WorktreeManager.js.map