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.
- package/dist/cli.js +40 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/gate.d.ts +33 -0
- package/dist/commands/gate.d.ts.map +1 -0
- package/dist/commands/gate.js +171 -0
- package/dist/commands/gate.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +29 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/core/MessageSentinel.d.ts +40 -0
- package/dist/core/MessageSentinel.d.ts.map +1 -1
- package/dist/core/MessageSentinel.js +80 -0
- package/dist/core/MessageSentinel.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts +7 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +114 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/SessionManager.d.ts +29 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +104 -1
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/StopGateDb.d.ts +97 -0
- package/dist/core/StopGateDb.d.ts.map +1 -0
- package/dist/core/StopGateDb.js +282 -0
- package/dist/core/StopGateDb.js.map +1 -0
- package/dist/core/UnjustifiedStopGate.d.ts +145 -0
- package/dist/core/UnjustifiedStopGate.d.ts.map +1 -0
- package/dist/core/UnjustifiedStopGate.js +325 -0
- package/dist/core/UnjustifiedStopGate.js.map +1 -0
- package/dist/core/WorktreeKeyVault.d.ts +50 -0
- package/dist/core/WorktreeKeyVault.d.ts.map +1 -0
- package/dist/core/WorktreeKeyVault.js +242 -0
- package/dist/core/WorktreeKeyVault.js.map +1 -0
- package/dist/core/WorktreeManager.d.ts +250 -0
- package/dist/core/WorktreeManager.d.ts.map +1 -0
- package/dist/core/WorktreeManager.js +833 -0
- package/dist/core/WorktreeManager.js.map +1 -0
- package/dist/monitoring/DegradationReporter.d.ts +15 -0
- package/dist/monitoring/DegradationReporter.d.ts.map +1 -1
- package/dist/monitoring/DegradationReporter.js +27 -0
- package/dist/monitoring/DegradationReporter.js.map +1 -1
- package/dist/monitoring/WorktreeReaper.d.ts +52 -0
- package/dist/monitoring/WorktreeReaper.d.ts.map +1 -0
- package/dist/monitoring/WorktreeReaper.js +199 -0
- package/dist/monitoring/WorktreeReaper.js.map +1 -0
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +8 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +18 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +24 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/middleware.d.ts.map +1 -1
- package/dist/server/middleware.js +20 -3
- package/dist/server/middleware.js.map +1 -1
- package/dist/server/routes.d.ts +10 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +432 -0
- package/dist/server/routes.js.map +1 -1
- package/dist/server/stopGate.d.ts +77 -0
- package/dist/server/stopGate.d.ts.map +1 -0
- package/dist/server/stopGate.js +161 -0
- package/dist/server/stopGate.js.map +1 -0
- package/dist/server/worktreeRoutes.d.ts +48 -0
- package/dist/server/worktreeRoutes.d.ts.map +1 -0
- package/dist/server/worktreeRoutes.js +247 -0
- package/dist/server/worktreeRoutes.js.map +1 -0
- package/package.json +1 -1
- package/scripts/destructive-command-shim.js +218 -0
- package/scripts/gh-ruleset-install.mjs +143 -0
- package/scripts/migrate-incident-2026-04-17.mjs +138 -0
- package/scripts/worktree-commit-msg-hook.js +168 -0
- package/scripts/worktree-precommit-gate.js +144 -0
- package/src/data/builtin-manifest.json +93 -93
- package/upgrades/0.28.53.md +70 -0
- package/upgrades/side-effects/context-death-pr0a-server-infra.md +131 -0
- package/upgrades/side-effects/context-death-pr0b-sentinel-intent.md +130 -0
- package/upgrades/side-effects/context-death-pr0c-guardian-pulse-degradation-consumer.md +118 -0
- package/upgrades/side-effects/context-death-pr0d-e2e-compaction-harness.md +113 -0
- package/upgrades/side-effects/context-death-pr1-identity-text.md +110 -0
- package/upgrades/side-effects/context-death-pr2-e2e-compaction-recovery-test.md +82 -0
- package/upgrades/side-effects/context-death-pr3-gate-authority.md +193 -0
- package/upgrades/side-effects/context-death-pr4-gate-cli.md +91 -0
- package/upgrades/side-effects/parallel-dev-isolation.md +129 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* destructive-command-shim.js — guard `git`/`rm` invocations inside topic worktrees.
|
|
4
|
+
*
|
|
5
|
+
* Per PARALLEL-DEV-ISOLATION-SPEC.md "Destructive-command interception (iter 4 — MANDATORY)".
|
|
6
|
+
*
|
|
7
|
+
* Behavior:
|
|
8
|
+
* - For non-destructive `git`/`rm` invocations: pass through transparently.
|
|
9
|
+
* - For destructive ones (git clean -fd, git reset --hard, rm -rf with >5 entries, etc.):
|
|
10
|
+
* 1. Compute would-affect file count (dry-run).
|
|
11
|
+
* 2. If snapshot needed: tar+zstd the worktree to .instar/worktrees/.snapshots/
|
|
12
|
+
* with chmod 0600. BLOCK if snapshot fails.
|
|
13
|
+
* 3. Append event to .instar/worktrees/.lock-history.jsonl
|
|
14
|
+
* 4. Forward to the real binary.
|
|
15
|
+
*
|
|
16
|
+
* Real binaries are resolved by stripping the shim dir from PATH and re-resolving.
|
|
17
|
+
*
|
|
18
|
+
* Usage: destructive-command-shim.js <git|rm> <original args...>
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const SECURE_FILE_MODE = 0o600;
|
|
27
|
+
const DESTRUCTIVE_FILE_THRESHOLD = 5;
|
|
28
|
+
|
|
29
|
+
function findWorktreeRoot(startCwd) {
|
|
30
|
+
let cwd = startCwd;
|
|
31
|
+
for (let i = 0; i < 10; i++) {
|
|
32
|
+
if (fs.existsSync(path.join(cwd, '.session.lock'))) return cwd;
|
|
33
|
+
const parent = path.dirname(cwd);
|
|
34
|
+
if (parent === cwd) break;
|
|
35
|
+
cwd = parent;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findStateRoot(worktreeRoot) {
|
|
41
|
+
// .instar/worktrees/<wt>/ → .instar/worktrees/.snapshots/
|
|
42
|
+
let cwd = worktreeRoot;
|
|
43
|
+
for (let i = 0; i < 5; i++) {
|
|
44
|
+
if (path.basename(cwd) === 'worktrees' && path.basename(path.dirname(cwd)) === '.instar') {
|
|
45
|
+
return path.dirname(path.dirname(cwd));
|
|
46
|
+
}
|
|
47
|
+
cwd = path.dirname(cwd);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveRealBinary(name) {
|
|
53
|
+
const sessionShim = process.env.INSTAR_SHIM_DIR;
|
|
54
|
+
// Strip shim dir from PATH and re-resolve
|
|
55
|
+
let pathParts = (process.env.PATH ?? '').split(':');
|
|
56
|
+
if (sessionShim) pathParts = pathParts.filter(p => p !== sessionShim);
|
|
57
|
+
const cleanPath = pathParts.join(':');
|
|
58
|
+
|
|
59
|
+
for (const dir of pathParts) {
|
|
60
|
+
const candidate = path.join(dir, name);
|
|
61
|
+
try {
|
|
62
|
+
const st = fs.statSync(candidate);
|
|
63
|
+
if (st.isFile() && (st.mode & 0o111)) return { path: candidate, env: { ...process.env, PATH: cleanPath } };
|
|
64
|
+
} catch { /* keep looking */ }
|
|
65
|
+
}
|
|
66
|
+
// Fallback: known absolute paths
|
|
67
|
+
const known = name === 'git'
|
|
68
|
+
? ['/usr/bin/git', '/opt/homebrew/bin/git', '/usr/local/bin/git']
|
|
69
|
+
: ['/bin/rm', '/usr/bin/rm'];
|
|
70
|
+
for (const k of known) {
|
|
71
|
+
if (fs.existsSync(k)) return { path: k, env: { ...process.env, PATH: cleanPath } };
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`could not resolve real ${name}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isDestructiveGit(args) {
|
|
77
|
+
if (args.length === 0) return false;
|
|
78
|
+
const a = args[0];
|
|
79
|
+
|
|
80
|
+
if (a === 'clean') {
|
|
81
|
+
// -f with -d (or -fd) is destructive
|
|
82
|
+
return args.some(x => /-.*[fd].*[fd]/.test(x) || x === '-fd' || x === '-fdx' || x === '-fx');
|
|
83
|
+
}
|
|
84
|
+
if (a === 'reset') {
|
|
85
|
+
return args.includes('--hard');
|
|
86
|
+
}
|
|
87
|
+
if (a === 'checkout') {
|
|
88
|
+
// git checkout -- . / git checkout HEAD -- .
|
|
89
|
+
return args.includes('--') || args.includes('.');
|
|
90
|
+
}
|
|
91
|
+
if (a === 'rm') {
|
|
92
|
+
return args.includes('-r') || args.includes('-rf') || args.includes('-fr');
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isDestructiveRm(args) {
|
|
98
|
+
return args.some(x => /^-.*r/.test(x)) && args.some(x => /^-.*f/.test(x));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function dryRunGitClean(cwd, args) {
|
|
102
|
+
// Use -n to count what would be removed
|
|
103
|
+
const dryArgs = ['-C', cwd, 'clean', '-n', ...args.filter(a => a !== '-f')];
|
|
104
|
+
try {
|
|
105
|
+
const out = execFileSync('git', dryArgs, { encoding: 'utf-8', timeout: 5000 });
|
|
106
|
+
return out.split('\n').filter(Boolean).length;
|
|
107
|
+
} catch { return Number.POSITIVE_INFINITY; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function snapshotWorktree(worktreeRoot, stateRoot) {
|
|
111
|
+
const snapshotsDir = path.join(stateRoot, 'worktrees', '.snapshots');
|
|
112
|
+
fs.mkdirSync(snapshotsDir, { recursive: true });
|
|
113
|
+
const ts = Date.now();
|
|
114
|
+
const wtName = path.basename(worktreeRoot);
|
|
115
|
+
const candidate = path.join(snapshotsDir, `${wtName}-${ts}.tar.zst`);
|
|
116
|
+
const fallback = path.join(snapshotsDir, `${wtName}-${ts}.tar.gz`);
|
|
117
|
+
|
|
118
|
+
const exclusions = ['node_modules', 'dist', '.next', 'build', 'target', '.cache'];
|
|
119
|
+
const excludeArgs = exclusions.flatMap(x => ['--exclude', x]);
|
|
120
|
+
|
|
121
|
+
// Try zstd first
|
|
122
|
+
try {
|
|
123
|
+
execFileSync('zstd', ['--version'], { stdio: 'pipe' });
|
|
124
|
+
execFileSync('sh', ['-c',
|
|
125
|
+
`tar -C "${path.dirname(worktreeRoot)}" ${excludeArgs.map(a => `'${a}'`).join(' ')} -cf - "${path.basename(worktreeRoot)}" | zstd -o "${candidate}"`,
|
|
126
|
+
], { stdio: 'inherit', timeout: 30_000 });
|
|
127
|
+
fs.chmodSync(candidate, SECURE_FILE_MODE);
|
|
128
|
+
return candidate;
|
|
129
|
+
} catch {
|
|
130
|
+
// Fallback: tar + gzip
|
|
131
|
+
execFileSync('tar', [
|
|
132
|
+
'-C', path.dirname(worktreeRoot),
|
|
133
|
+
...excludeArgs,
|
|
134
|
+
'-czf', fallback,
|
|
135
|
+
path.basename(worktreeRoot),
|
|
136
|
+
], { stdio: 'inherit', timeout: 30_000 });
|
|
137
|
+
fs.chmodSync(fallback, SECURE_FILE_MODE);
|
|
138
|
+
return fallback;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function appendHistoryEvent(worktreeRoot, event) {
|
|
143
|
+
const histFile = path.join(worktreeRoot, '.lock-history.jsonl');
|
|
144
|
+
const entry = JSON.stringify({ ts: Date.now(), ...event });
|
|
145
|
+
fs.appendFileSync(histFile, `${entry}\n`, { mode: SECURE_FILE_MODE });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function passthrough(realBin, args) {
|
|
149
|
+
const child = spawn(realBin.path, args, {
|
|
150
|
+
stdio: 'inherit',
|
|
151
|
+
env: realBin.env,
|
|
152
|
+
});
|
|
153
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
154
|
+
child.on('error', (err) => { console.error(err.message); process.exit(127); });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function main() {
|
|
158
|
+
const cmd = process.argv[2];
|
|
159
|
+
const args = process.argv.slice(3);
|
|
160
|
+
|
|
161
|
+
if (!['git', 'rm'].includes(cmd)) {
|
|
162
|
+
console.error(`destructive-command-shim: unknown command "${cmd}"`);
|
|
163
|
+
process.exit(2);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const realBin = resolveRealBinary(cmd);
|
|
167
|
+
const cwd = process.cwd();
|
|
168
|
+
const worktreeRoot = findWorktreeRoot(cwd);
|
|
169
|
+
|
|
170
|
+
// Not inside a managed worktree → passthrough silently
|
|
171
|
+
if (!worktreeRoot) return passthrough(realBin, args);
|
|
172
|
+
|
|
173
|
+
const isDestructive = cmd === 'git' ? isDestructiveGit(args) : isDestructiveRm(args);
|
|
174
|
+
if (!isDestructive) return passthrough(realBin, args);
|
|
175
|
+
|
|
176
|
+
// Determine if this would affect >5 files
|
|
177
|
+
let affectedCount = Number.POSITIVE_INFINITY;
|
|
178
|
+
if (cmd === 'git' && args[0] === 'clean') {
|
|
179
|
+
affectedCount = dryRunGitClean(worktreeRoot, args);
|
|
180
|
+
} else if (cmd === 'rm') {
|
|
181
|
+
// rm -rf <paths>: count entries
|
|
182
|
+
const targets = args.filter(a => !a.startsWith('-'));
|
|
183
|
+
affectedCount = targets.length;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// For all other destructive cmds (reset --hard, checkout -- .): always snapshot
|
|
187
|
+
const shouldSnapshot = affectedCount > DESTRUCTIVE_FILE_THRESHOLD || cmd === 'git' && (args[0] === 'reset' || args[0] === 'checkout');
|
|
188
|
+
|
|
189
|
+
if (shouldSnapshot) {
|
|
190
|
+
const stateRoot = findStateRoot(worktreeRoot);
|
|
191
|
+
if (!stateRoot) {
|
|
192
|
+
console.error('destructive-command-shim: BLOCK — could not locate .instar state root');
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
let snapshotPath;
|
|
196
|
+
try {
|
|
197
|
+
snapshotPath = snapshotWorktree(worktreeRoot, stateRoot);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
console.error(`destructive-command-shim: BLOCK — snapshot failed: ${err.message}`);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
appendHistoryEvent(worktreeRoot, {
|
|
203
|
+
kind: 'destructive-cmd-snapshotted',
|
|
204
|
+
cmd,
|
|
205
|
+
args,
|
|
206
|
+
snapshotPath,
|
|
207
|
+
affectedCount,
|
|
208
|
+
});
|
|
209
|
+
console.error(`destructive-command-shim: snapshotted to ${snapshotPath} before "${cmd} ${args.join(' ')}"`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return passthrough(realBin, args);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
main().catch((err) => {
|
|
216
|
+
console.error(`destructive-command-shim: BLOCK — ${err.message}`);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gh-ruleset-install.mjs — install the GitHub Repository Ruleset for parallel-dev.
|
|
4
|
+
*
|
|
5
|
+
* Per PARALLEL-DEV-ISOLATION-SPEC.md "Authoritative push gate (iter 4 — GH ruleset)".
|
|
6
|
+
*
|
|
7
|
+
* Creates a ruleset with:
|
|
8
|
+
* - Targets: main, topic/*, platform/*, tags v*
|
|
9
|
+
* - restrict_updates / restrict_creations / restrict_deletions / non_fast_forward
|
|
10
|
+
* - required_status_checks: worktree-trailer-sig-check
|
|
11
|
+
* - bypass_actors: [] (no admin bypass; github-actions[bot] allowed only for PR-merge commits)
|
|
12
|
+
* - second ruleset entry: 2-approval rule on `.github/workflows/worktree-trailer-sig-check.yml`
|
|
13
|
+
* and `.github/scripts/verify-trailers.js` paths
|
|
14
|
+
*
|
|
15
|
+
* Calls `gh api` with the user's authenticated PAT (must have `repo` scope).
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* gh-ruleset-install.mjs <owner> <repo> [--mode evaluate|active]
|
|
19
|
+
*
|
|
20
|
+
* Mode `evaluate` (default for first install): logs would-block, doesn't enforce.
|
|
21
|
+
* Mode `active`: enforces. Recommended after Day-7 cutover-gate is satisfied.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
|
|
26
|
+
const [, , owner, repo, ...rest] = process.argv;
|
|
27
|
+
if (!owner || !repo) {
|
|
28
|
+
console.error('Usage: gh-ruleset-install.mjs <owner> <repo> [--mode evaluate|active]');
|
|
29
|
+
process.exit(2);
|
|
30
|
+
}
|
|
31
|
+
const modeFlag = rest.find((a) => a.startsWith('--mode='));
|
|
32
|
+
const mode = (rest.includes('--mode') ? rest[rest.indexOf('--mode') + 1] : modeFlag?.split('=')[1]) ?? 'evaluate';
|
|
33
|
+
if (!['evaluate', 'active'].includes(mode)) {
|
|
34
|
+
console.error(`invalid --mode "${mode}" — must be evaluate|active`);
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ghApi(method, path, bodyObj = null) {
|
|
39
|
+
const args = ['api', '--method', method, path, '-H', 'Accept: application/vnd.github+json'];
|
|
40
|
+
if (bodyObj !== null) {
|
|
41
|
+
for (const [k, v] of Object.entries(bodyObj)) {
|
|
42
|
+
args.push('--field', `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return execFileSync('gh', args, { encoding: 'utf-8' });
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error(`gh-ruleset-install: gh api failed: ${err.message}`);
|
|
49
|
+
if (err.stderr) console.error(err.stderr.toString());
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const branchRuleset = {
|
|
55
|
+
name: 'instar-parallel-dev-isolation',
|
|
56
|
+
target: 'branch',
|
|
57
|
+
enforcement: mode,
|
|
58
|
+
bypass_actors: [],
|
|
59
|
+
conditions: {
|
|
60
|
+
ref_name: {
|
|
61
|
+
include: ['~DEFAULT_BRANCH', 'refs/heads/main', 'refs/heads/topic/*', 'refs/heads/platform/*'],
|
|
62
|
+
exclude: [],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
rules: [
|
|
66
|
+
{ type: 'deletion' },
|
|
67
|
+
{ type: 'non_fast_forward' },
|
|
68
|
+
{
|
|
69
|
+
type: 'required_status_checks',
|
|
70
|
+
parameters: {
|
|
71
|
+
strict_required_status_checks_policy: true,
|
|
72
|
+
required_status_checks: [
|
|
73
|
+
{ context: 'worktree-trailer-sig-check / verify' },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
type: 'pull_request',
|
|
79
|
+
parameters: {
|
|
80
|
+
required_approving_review_count: 0,
|
|
81
|
+
dismiss_stale_reviews_on_push: false,
|
|
82
|
+
require_code_owner_review: false,
|
|
83
|
+
require_last_push_approval: false,
|
|
84
|
+
required_review_thread_resolution: false,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const tagRuleset = {
|
|
91
|
+
name: 'instar-tag-protection',
|
|
92
|
+
target: 'tag',
|
|
93
|
+
enforcement: mode,
|
|
94
|
+
bypass_actors: [],
|
|
95
|
+
conditions: {
|
|
96
|
+
ref_name: { include: ['refs/tags/v*'], exclude: [] },
|
|
97
|
+
},
|
|
98
|
+
rules: [
|
|
99
|
+
{ type: 'deletion' },
|
|
100
|
+
{ type: 'non_fast_forward' },
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const trustRootRuleset = {
|
|
105
|
+
name: 'instar-trust-root-2-approval',
|
|
106
|
+
target: 'branch',
|
|
107
|
+
enforcement: 'active', // always enforced (per K4)
|
|
108
|
+
bypass_actors: [],
|
|
109
|
+
conditions: {
|
|
110
|
+
ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] },
|
|
111
|
+
},
|
|
112
|
+
rules: [
|
|
113
|
+
{
|
|
114
|
+
type: 'pull_request',
|
|
115
|
+
parameters: {
|
|
116
|
+
required_approving_review_count: 2,
|
|
117
|
+
require_code_owner_review: false,
|
|
118
|
+
dismiss_stale_reviews_on_push: true,
|
|
119
|
+
require_last_push_approval: true,
|
|
120
|
+
required_review_thread_resolution: true,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
type: 'file_path_restriction',
|
|
125
|
+
parameters: {
|
|
126
|
+
restricted_file_paths: [
|
|
127
|
+
'.github/workflows/worktree-trailer-sig-check.yml',
|
|
128
|
+
'.github/scripts/verify-trailers.js',
|
|
129
|
+
'scripts/gh-ruleset-install.mjs',
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
console.log(`gh-ruleset-install: installing ruleset on ${owner}/${repo} (mode=${mode})`);
|
|
137
|
+
ghApi('POST', `/repos/${owner}/${repo}/rulesets`, branchRuleset);
|
|
138
|
+
console.log(' ✓ branch ruleset installed');
|
|
139
|
+
ghApi('POST', `/repos/${owner}/${repo}/rulesets`, tagRuleset);
|
|
140
|
+
console.log(' ✓ tag ruleset installed');
|
|
141
|
+
ghApi('POST', `/repos/${owner}/${repo}/rulesets`, trustRootRuleset);
|
|
142
|
+
console.log(' ✓ trust-root 2-approval ruleset installed');
|
|
143
|
+
console.log('Done.');
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Day -2 prerequisite migration script for parallel-dev isolation.
|
|
4
|
+
*
|
|
5
|
+
* Per PARALLEL-DEV-ISOLATION-SPEC.md "Migration (iter 4 — Day -2 trust-boundary
|
|
6
|
+
* acknowledgment + GH ruleset auto-config)".
|
|
7
|
+
*
|
|
8
|
+
* Trust-on-first-use: this script CANNOT be verified by the system it installs
|
|
9
|
+
* (chicken-and-egg). The Day -2 PR ruleset entry requires 2 approvals on PRs
|
|
10
|
+
* touching .github/workflows/worktree-trailer-sig-check.yml so this lift
|
|
11
|
+
* happens with 4-eyes review.
|
|
12
|
+
*
|
|
13
|
+
* Steps:
|
|
14
|
+
* 1. Generate Ed25519 keypair (server-side; private to keychain).
|
|
15
|
+
* 2. Print public key for inspection (user pastes into workflow YAML).
|
|
16
|
+
* 3. Verify pre-existing stash@{0} matches expected SHA (refuse if changed).
|
|
17
|
+
* 4. After PR merges (and ruleset created via gh-ruleset-install.mjs),
|
|
18
|
+
* write signed sentinel .instar/local-state/migration-2026-04-17.completed.
|
|
19
|
+
*
|
|
20
|
+
* Exit codes:
|
|
21
|
+
* 0 — completed (or sentinel already exists / nothing to do)
|
|
22
|
+
* 1 — refused (stash SHA mismatch / pre-condition failure)
|
|
23
|
+
* 2 — uncaught error
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import crypto from 'node:crypto';
|
|
27
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
|
|
31
|
+
const ROOT = process.cwd();
|
|
32
|
+
const STATE_DIR = path.join(ROOT, '.instar', 'local-state');
|
|
33
|
+
const SENTINEL = path.join(STATE_DIR, 'migration-2026-04-17.completed');
|
|
34
|
+
// Captured at spec-approval time; refuses migration if stash has changed since.
|
|
35
|
+
const EXPECTED_STASH_REF = 'stash@{0}';
|
|
36
|
+
const EXPECTED_STASH_LABEL_PREFIX = 'parallel-session: InitiativeTracker';
|
|
37
|
+
|
|
38
|
+
function logStep(msg) {
|
|
39
|
+
console.log(`[migrate-incident-2026-04-17] ${msg}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function existsSentinel() {
|
|
43
|
+
return fs.existsSync(SENTINEL);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function generateKeypair() {
|
|
47
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', {
|
|
48
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
49
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
50
|
+
});
|
|
51
|
+
return { publicKey, privateKey };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function verifyStash() {
|
|
55
|
+
let stashList;
|
|
56
|
+
try {
|
|
57
|
+
stashList = execSync('git stash list', { encoding: 'utf-8' });
|
|
58
|
+
} catch (err) {
|
|
59
|
+
logStep(`No stash present (or not a git repo). Skipping stash verification.`);
|
|
60
|
+
return { ok: true, skipped: true };
|
|
61
|
+
}
|
|
62
|
+
const lines = stashList.split('\n').filter(Boolean);
|
|
63
|
+
if (lines.length === 0) {
|
|
64
|
+
logStep('No stash entries — nothing to verify. Continuing.');
|
|
65
|
+
return { ok: true, skipped: true };
|
|
66
|
+
}
|
|
67
|
+
const top = lines[0];
|
|
68
|
+
if (!top.startsWith(`${EXPECTED_STASH_REF}:`)) {
|
|
69
|
+
return { ok: false, reason: `expected stash ref ${EXPECTED_STASH_REF}, got "${top}"` };
|
|
70
|
+
}
|
|
71
|
+
if (!top.includes(EXPECTED_STASH_LABEL_PREFIX)) {
|
|
72
|
+
return { ok: false, reason: `stash label changed; expected prefix "${EXPECTED_STASH_LABEL_PREFIX}", got "${top}"` };
|
|
73
|
+
}
|
|
74
|
+
return { ok: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeSentinel(publicKey, privateKey) {
|
|
78
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
79
|
+
// Sign the sentinel content with the new Ed25519 private key, anchoring future trust.
|
|
80
|
+
const sentinelContent = JSON.stringify({
|
|
81
|
+
completedAt: new Date().toISOString(),
|
|
82
|
+
publicKeyFingerprint: crypto.createHash('sha256').update(publicKey).digest('hex'),
|
|
83
|
+
schemaVersion: 1,
|
|
84
|
+
});
|
|
85
|
+
const signature = crypto.sign(null, Buffer.from(sentinelContent), { key: privateKey }).toString('base64');
|
|
86
|
+
fs.writeFileSync(SENTINEL, JSON.stringify({
|
|
87
|
+
content: sentinelContent,
|
|
88
|
+
signature,
|
|
89
|
+
}, null, 2), { mode: 0o600 });
|
|
90
|
+
logStep(`Wrote sentinel ${SENTINEL}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function main() {
|
|
94
|
+
if (existsSentinel()) {
|
|
95
|
+
logStep(`Sentinel exists at ${SENTINEL} — refusing to re-run.`);
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
logStep('Step 1: Generate Ed25519 keypair (server-side; private will go to keychain)');
|
|
100
|
+
const { publicKey, privateKey } = generateKeypair();
|
|
101
|
+
|
|
102
|
+
logStep('Step 2: Public key (paste into .github/workflows/worktree-trailer-sig-check.yml as `INSTAR_TRAILER_PUBLIC_KEY` repo variable):');
|
|
103
|
+
console.log('');
|
|
104
|
+
console.log(publicKey);
|
|
105
|
+
console.log('');
|
|
106
|
+
|
|
107
|
+
logStep('Step 3: Verify staged stash@{0} matches expected (refuse if changed)');
|
|
108
|
+
const stashCheck = verifyStash();
|
|
109
|
+
if (!stashCheck.ok) {
|
|
110
|
+
console.error(`[migrate-incident-2026-04-17] REFUSED: ${stashCheck.reason}`);
|
|
111
|
+
console.error(' → If you have intentionally moved the InitiativeTracker work, update EXPECTED_STASH_LABEL_PREFIX in this script.');
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
if (stashCheck.skipped) {
|
|
115
|
+
logStep(' (no stash to verify; this is expected after the InitiativeTracker work has been popped/dropped on this machine)');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
logStep('Step 4: Write signed sentinel (anchors future trust to this acknowledged-TOFU root)');
|
|
119
|
+
writeSentinel(publicKey, privateKey);
|
|
120
|
+
|
|
121
|
+
// Save private key to a known transitional location for the user to register
|
|
122
|
+
// via `instar worktree register-keypair --private <path>` (which prompts for keychain).
|
|
123
|
+
const privPath = path.join(STATE_DIR, 'trailer-private-key.pem.NEW');
|
|
124
|
+
fs.writeFileSync(privPath, privateKey, { mode: 0o600 });
|
|
125
|
+
logStep(`Private key written to ${privPath} (chmod 0600)`);
|
|
126
|
+
logStep(' → Next: run `instar worktree register-keypair --private ' + privPath + '` to move into keychain.');
|
|
127
|
+
logStep(' → After successful registration, this file is securely deleted.');
|
|
128
|
+
|
|
129
|
+
logStep('DONE.');
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try { main(); }
|
|
134
|
+
catch (err) {
|
|
135
|
+
console.error(`[migrate-incident-2026-04-17] UNCAUGHT ${err.message}`);
|
|
136
|
+
console.error(err.stack);
|
|
137
|
+
process.exit(2);
|
|
138
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* worktree-commit-msg-hook.js — commit-msg hook for trailer signing.
|
|
4
|
+
*
|
|
5
|
+
* Invoked by git as: `worktree-commit-msg-hook.js <commit-msg-file>`.
|
|
6
|
+
*
|
|
7
|
+
* 1. Reads .instar/session-context.json (locates by walking up from cwd).
|
|
8
|
+
* 2. Computes treeHash via `git write-tree` (HONORS $GIT_INDEX_FILE per K-fix).
|
|
9
|
+
* 3. Reads parent SHA(s) via `git rev-parse HEAD` + merge-mode parents.
|
|
10
|
+
* 4. POSTs to instar server `/commits/sign-trailer`.
|
|
11
|
+
* 5. Appends 9 trailer lines via `git interpret-trailers --in-place`.
|
|
12
|
+
*
|
|
13
|
+
* Per spec section: "Commit trailer (iter 4 — Ed25519 + offline-friendly expiry + index-aware)".
|
|
14
|
+
*
|
|
15
|
+
* Exit codes:
|
|
16
|
+
* 0 — trailer injected (commit proceeds)
|
|
17
|
+
* 1 — failed; commit aborted with retry message
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
24
|
+
|
|
25
|
+
const SIGN_TIMEOUT_MS = 5000;
|
|
26
|
+
|
|
27
|
+
function findSessionContext(startCwd) {
|
|
28
|
+
let cwd = startCwd;
|
|
29
|
+
for (let i = 0; i < 10; i++) {
|
|
30
|
+
const ctx = path.join(cwd, '.instar', 'session-context.json');
|
|
31
|
+
if (fs.existsSync(ctx)) return ctx;
|
|
32
|
+
const parent = path.dirname(cwd);
|
|
33
|
+
if (parent === cwd) break;
|
|
34
|
+
cwd = parent;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function gitParents(cwd) {
|
|
40
|
+
// Determine if this is a merge: presence of $GIT_REFLOG_ACTION=merge OR MERGE_HEAD file
|
|
41
|
+
const gitDir = execFileSync('git', ['-C', cwd, 'rev-parse', '--git-dir'], { encoding: 'utf-8' }).trim();
|
|
42
|
+
const mergeHeadPath = path.join(cwd, gitDir, 'MERGE_HEAD');
|
|
43
|
+
let parents = [];
|
|
44
|
+
// Primary parent = current HEAD (or 0...0 for initial commit)
|
|
45
|
+
try {
|
|
46
|
+
const head = execFileSync('git', ['-C', cwd, 'rev-parse', 'HEAD'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
47
|
+
parents.push(head);
|
|
48
|
+
} catch {
|
|
49
|
+
parents.push('0'.repeat(40));
|
|
50
|
+
}
|
|
51
|
+
if (fs.existsSync(mergeHeadPath)) {
|
|
52
|
+
const extra = fs.readFileSync(mergeHeadPath, 'utf-8').trim().split('\n').filter(Boolean);
|
|
53
|
+
parents.push(...extra);
|
|
54
|
+
}
|
|
55
|
+
return parents;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function gitWriteTree(cwd) {
|
|
59
|
+
// K-fix: honor $GIT_INDEX_FILE (set by `git commit -a` and `git commit <file>`)
|
|
60
|
+
const env = { ...process.env };
|
|
61
|
+
return execFileSync('git', ['-C', cwd, 'write-tree', '--missing-ok'], {
|
|
62
|
+
encoding: 'utf-8', env,
|
|
63
|
+
}).trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function postSign({ serverUrl, authToken, sessionId, fencingToken, treeHash, parents }) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const url = new URL(serverUrl);
|
|
69
|
+
const body = JSON.stringify({ sessionId, fencingToken, treeHash, parents });
|
|
70
|
+
const req = http.request({
|
|
71
|
+
method: 'POST',
|
|
72
|
+
hostname: url.hostname,
|
|
73
|
+
port: url.port,
|
|
74
|
+
path: '/commits/sign-trailer',
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
'Content-Length': Buffer.byteLength(body),
|
|
78
|
+
'Authorization': `Bearer ${authToken}`,
|
|
79
|
+
},
|
|
80
|
+
timeout: SIGN_TIMEOUT_MS,
|
|
81
|
+
}, (res) => {
|
|
82
|
+
let data = '';
|
|
83
|
+
res.on('data', (c) => data += c);
|
|
84
|
+
res.on('end', () => {
|
|
85
|
+
if (res.statusCode !== 200) return reject(new Error(`sign-trailer ${res.statusCode}: ${data}`));
|
|
86
|
+
try { resolve(JSON.parse(data)); }
|
|
87
|
+
catch (e) { reject(e); }
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('sign-trailer timeout')); });
|
|
91
|
+
req.on('error', reject);
|
|
92
|
+
req.write(body);
|
|
93
|
+
req.end();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function main() {
|
|
98
|
+
const commitMsgFile = process.argv[2];
|
|
99
|
+
if (!commitMsgFile) {
|
|
100
|
+
console.error('worktree-commit-msg-hook: usage: <commit-msg-file>');
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Skip merge commits authored by GitHub (they're verified at PR-merge time per spec)
|
|
105
|
+
// and skip rebase/cherry-pick (commit-msg re-fires per spec — handled the same way)
|
|
106
|
+
const cwd = process.cwd();
|
|
107
|
+
const ctxPath = findSessionContext(cwd);
|
|
108
|
+
if (!ctxPath) process.exit(0); // human commit, no session context
|
|
109
|
+
|
|
110
|
+
const ctx = JSON.parse(fs.readFileSync(ctxPath, 'utf-8'));
|
|
111
|
+
if (!ctx.sessionId || !ctx.fencingToken) process.exit(0);
|
|
112
|
+
if (ctx.mode === 'read-only') {
|
|
113
|
+
console.error('worktree-commit-msg-hook: BLOCK — read-only mode');
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const serverUrl = process.env.INSTAR_SERVER_URL;
|
|
118
|
+
const authToken = process.env.INSTAR_AUTH_TOKEN;
|
|
119
|
+
if (!serverUrl || !authToken) {
|
|
120
|
+
console.error('worktree-commit-msg-hook: WARN — missing INSTAR_SERVER_URL; cannot sign trailer');
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let treeHash;
|
|
125
|
+
try { treeHash = gitWriteTree(cwd); }
|
|
126
|
+
catch (err) {
|
|
127
|
+
console.error(`worktree-commit-msg-hook: BLOCK — write-tree failed: ${err.message}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const parents = gitParents(cwd);
|
|
132
|
+
|
|
133
|
+
let signed;
|
|
134
|
+
try {
|
|
135
|
+
signed = await postSign({
|
|
136
|
+
serverUrl, authToken,
|
|
137
|
+
sessionId: ctx.sessionId,
|
|
138
|
+
fencingToken: ctx.fencingToken,
|
|
139
|
+
treeHash,
|
|
140
|
+
parents,
|
|
141
|
+
});
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.error(`worktree-commit-msg-hook: BLOCK — sign-trailer failed: ${err.message}`);
|
|
144
|
+
console.error(' Retry the commit; or run `instar worktree status` to diagnose.');
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Append trailers via git interpret-trailers --in-place
|
|
149
|
+
const trailerArgs = [];
|
|
150
|
+
for (const t of signed.trailers) {
|
|
151
|
+
trailerArgs.push('--trailer', t);
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
execFileSync('git', ['interpret-trailers', '--in-place', ...trailerArgs, commitMsgFile], {
|
|
155
|
+
stdio: 'inherit',
|
|
156
|
+
});
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error(`worktree-commit-msg-hook: BLOCK — interpret-trailers failed: ${err.message}`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
process.exit(0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
main().catch((err) => {
|
|
166
|
+
console.error(`worktree-commit-msg-hook: BLOCK — ${err.message}`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
});
|