@namewta/speculo 0.8.11 → 0.8.13

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 (51) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/template/canonical/canonical-specdev-goal-plan.md +13 -7
  4. package/template/canonical/canonical-specdev-grill-with-docs.md +1 -1
  5. package/template/canonical/canonical-specdev-orchestrate-implementation.md +29 -11
  6. package/template/canonical/canonical-specdev-spec.md +1 -1
  7. package/template/canonical/canonical-specdev-tickets.md +38 -4
  8. package/template/skills/engineering-standards-builder/README.md +6 -28
  9. package/template/skills/engineering-standards-builder/SKILL.md +88 -163
  10. package/template/skills/engineering-standards-builder/examples/README.md +2 -0
  11. package/template/skills/engineering-standards-builder/manifest.txt +4 -0
  12. package/template/skills/engineering-standards-builder/references/rules/00-governance-and-precedence.md +22 -29
  13. package/template/skills/engineering-standards-builder/references/rules/01-project-discovery.md +26 -59
  14. package/template/skills/engineering-standards-builder/references/rules/02-evidence-topology-and-scope.md +28 -55
  15. package/template/skills/engineering-standards-builder/references/rules/03-interview-and-decisions.md +4 -3
  16. package/template/skills/engineering-standards-builder/references/rules/14-generation-contract.md +64 -80
  17. package/template/skills/engineering-standards-builder/references/rules/15-validation-contract.md +24 -47
  18. package/template/skills/engineering-standards-builder/references/rules/16-language-adapter-contract.md +11 -48
  19. package/template/skills/engineering-standards-builder/references/rules/README.md +3 -3
  20. package/template/skills/engineering-standards-builder/scripts/self-test.mjs +51 -5
  21. package/template/skills/engineering-standards-builder/scripts/validate-builder.mjs +16 -4
  22. package/template/skills/engineering-standards-builder/scripts/validate-generated-skill.mjs +166 -64
  23. package/template/skills/engineering-standards-builder/templates/README.md +12 -3
  24. package/template/skills/engineering-standards-builder/templates/domain-skill/SKILL.md.template +32 -0
  25. package/template/skills/engineering-standards-builder/templates/project-skill/SKILL.md.template +16 -11
  26. package/template/skills/engineering-standards-builder/templates/project-skill/generated-skill-set.json.template +7 -0
  27. package/template/skills/engineering-standards-builder/templates/project-skill/references/project/00-project-profile.md.template +3 -1
  28. package/template/skills/engineering-standards-builder/templates/project-skill/references/project/01-module-map.md.template +4 -0
  29. package/template/skills/engineering-standards-builder/templates/project-skill/references/project/02-decisions-and-exceptions.md.template +1 -1
  30. package/template/skills/engineering-standards-builder/templates/project-skill/references/project/03-skill-map.md.template +19 -0
  31. package/template/skills/engineering-standards-builder/templates/project-skill/references/project/04-source-and-template-map.md.template +22 -0
  32. package/template/skills/engineering-standards-builder/templates/project-skill/references/project/review-checklist.md.template +2 -0
  33. package/template/skills/git-history-squash/SKILL.md +100 -0
  34. package/template/skills/git-history-squash/assets/request-template.json +18 -0
  35. package/template/skills/git-history-squash/references/recovery-contract.md +50 -0
  36. package/template/skills/git-history-squash/references/rewrite-contract.md +123 -0
  37. package/template/skills/git-history-squash/references/submodule-contract.md +54 -0
  38. package/template/skills/git-history-squash/scripts/git-history-squash.mjs +1171 -0
  39. package/template/workflows/specdev/I-implement/I-implement.md +12 -4
  40. package/template/workflows/specdev/I-implement/execution-preflight.md +4 -0
  41. package/template/workflows/specdev/README.md +1 -1
  42. package/template/workflows/specdev/T-tickets/T-tickets.md +16 -3
  43. package/template/workflows/specdev/T-tickets/ticket-readiness.md +3 -0
  44. package/template/workflows/specdev/T-tickets/ticket-template.md +3 -0
  45. package/template/workflows/specdev/T-tickets/tickets-map-template.md +15 -0
  46. package/template/workflows/specdev/common/rules/artifact-contract.md +1 -1
  47. package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +4 -2
  48. package/template/workflows/specdev/common/skills/subagent-delivery/references/external-web-subagent.md +2 -1
  49. package/template/workflows/specdev/common/skills/subagent-delivery/references/native-subagent.md +2 -1
  50. package/template/workflows/specdev/common/skills/subagent-delivery/references/source-package.md +4 -2
  51. package/template/workflows/specdev/common/tools/validate-specdev.mjs +143 -3
@@ -0,0 +1,1171 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ import { spawnSync } from 'node:child_process';
5
+ import {
6
+ closeSync,
7
+ existsSync,
8
+ fsyncSync,
9
+ lstatSync,
10
+ mkdirSync,
11
+ mkdtempSync,
12
+ openSync,
13
+ readFileSync,
14
+ realpathSync,
15
+ readdirSync,
16
+ renameSync,
17
+ rmSync,
18
+ statSync,
19
+ writeFileSync,
20
+ } from 'node:fs';
21
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
22
+ import { tmpdir } from 'node:os';
23
+ import { pathToFileURL } from 'node:url';
24
+
25
+ export const SCHEMA_VERSION = 1;
26
+ const TERMINAL = new Set(['local-only', 'published']);
27
+ const REPOSITORY_STATUSES = new Set(['planned', 'object-created', 'local-updated', 'local-verified', 'local-only', 'publishing', 'published', 'blocked']);
28
+ const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
29
+ const ZERO_OID = '0'.repeat(40);
30
+ const OID = /^[0-9a-f]{40}$/;
31
+
32
+ export class SquashError extends Error {}
33
+
34
+ function usage(code = 0) {
35
+ const out = code === 0 ? console.log : console.error;
36
+ out(`Usage:
37
+ git-history-squash.mjs plan --root <path> --state-root <path> --evidence-root <path> --request <json> [--date YYYY-MM-DD]
38
+ git-history-squash.mjs apply --root <path> --state-root <path> --change <name> --confirm-plan <sha256>
39
+ git-history-squash.mjs publish --root <path> --state-root <path> --change <name> --confirm-publish <sha256>
40
+ git-history-squash.mjs status --root <path> --state-root <path> --change <name>
41
+
42
+ Examples:
43
+ node git-history-squash.mjs plan --root . --state-root speculo/.speculo/skills/git-history-squash --evidence-root speculo/.speculo --request request.json
44
+ node git-history-squash.mjs apply --root . --state-root speculo/.speculo/skills/git-history-squash --change 2026-09-02-account-profile --confirm-plan <digest>`);
45
+ process.exit(code);
46
+ }
47
+
48
+ function parseArgs(argv) {
49
+ const operation = argv[0];
50
+ if (!operation || operation === '--help' || operation === '-h') usage(0);
51
+ const values = {};
52
+ for (let index = 1; index < argv.length; index += 1) {
53
+ const arg = argv[index];
54
+ if (arg === '--help' || arg === '-h') usage(0);
55
+ if (!arg.startsWith('--')) throw new SquashError(`unexpected argument: ${arg}`);
56
+ const value = argv[++index];
57
+ if (value === undefined || value.startsWith('--')) throw new SquashError(`${arg} requires a value`);
58
+ const key = arg.slice(2).replaceAll('-', '_');
59
+ if (key in values) throw new SquashError(`duplicate option: ${arg}`);
60
+ values[key] = value;
61
+ }
62
+ return { operation, values };
63
+ }
64
+
65
+ function required(values, key) {
66
+ if (!values[key]) throw new SquashError(`--${key.replaceAll('_', '-')} is required`);
67
+ return values[key];
68
+ }
69
+
70
+ function now() {
71
+ return new Date().toISOString();
72
+ }
73
+
74
+ function today() {
75
+ return now().slice(0, 10);
76
+ }
77
+
78
+ function sha256(value) {
79
+ return createHash('sha256').update(typeof value === 'string' ? value : stableJson(value)).digest('hex');
80
+ }
81
+
82
+ function stable(value) {
83
+ if (Array.isArray(value)) return value.map(stable);
84
+ if (value && typeof value === 'object') {
85
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
86
+ }
87
+ return value;
88
+ }
89
+
90
+ function stableJson(value) {
91
+ return JSON.stringify(stable(value));
92
+ }
93
+
94
+ function sanitize(value, root = null) {
95
+ let text = String(value ?? '');
96
+ if (root) text = text.replaceAll(resolve(root), '.');
97
+ return text
98
+ .replace(/\b(?:https?|ssh):\/\/[^\s]+/gi, '<redacted-url>')
99
+ .replace(/\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s]+/g, '<redacted-url>')
100
+ .replace(/([?&](?:token|access_token|auth|key)=)[^&\s]+/gi, '$1<redacted>')
101
+ .replace(/\b(?:ghp|github_pat|glpat)-?[A-Za-z0-9_]{12,}\b/g, '<redacted-token>')
102
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '<redacted-email>');
103
+ }
104
+
105
+ function shellQuote(value) {
106
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
107
+ }
108
+
109
+ function run(cwd, command, args, options = {}) {
110
+ const allowed = options.allowed ?? [0];
111
+ const result = spawnSync(command, args, {
112
+ cwd,
113
+ encoding: 'utf8',
114
+ input: options.input,
115
+ env: { ...process.env, ...(options.env ?? {}) },
116
+ maxBuffer: 64 * 1024 * 1024,
117
+ timeout: options.timeout ?? 120_000,
118
+ });
119
+ if (result.error) throw new SquashError(`${command} failed: ${result.error.message}`);
120
+ const status = result.status ?? 1;
121
+ if (!allowed.includes(status)) {
122
+ const detail = sanitize(result.stderr || result.stdout || `exit ${status}`, cwd).trim();
123
+ throw new SquashError(`${command} ${args[0] ?? ''} failed (${status}): ${detail}`);
124
+ }
125
+ return { status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
126
+ }
127
+
128
+ function git(repo, args, options = {}) {
129
+ return run(repo, 'git', args, options);
130
+ }
131
+
132
+ function inside(root, value, label, { allowRoot = false, mustExist = true } = {}) {
133
+ const target = resolve(root, value);
134
+ const relation = relative(resolve(root), target);
135
+ if (isAbsolute(relation) || relation === '..' || relation.startsWith(`..${sep}`)) {
136
+ throw new SquashError(`${label} must stay under project root`);
137
+ }
138
+ if (!allowRoot && !relation) throw new SquashError(`${label} must not equal project root`);
139
+ let current = resolve(root);
140
+ for (const segment of relation.split(sep).filter(Boolean)) {
141
+ current = join(current, segment);
142
+ if (!existsSync(current)) {
143
+ if (mustExist) throw new SquashError(`${label} does not exist`);
144
+ break;
145
+ }
146
+ if (lstatSync(current).isSymbolicLink()) throw new SquashError(`${label} must not traverse a symlink`);
147
+ }
148
+ return target;
149
+ }
150
+
151
+ function ensureDirectory(path, label) {
152
+ if (!existsSync(path) || !statSync(path).isDirectory()) throw new SquashError(`${label} is not a directory`);
153
+ if (lstatSync(path).isSymbolicLink()) throw new SquashError(`${label} must not be a symlink`);
154
+ }
155
+
156
+ function readJson(path, label) {
157
+ if (!existsSync(path)) throw new SquashError(`${label} does not exist`);
158
+ if (lstatSync(path).isSymbolicLink()) throw new SquashError(`${label} must not be a symlink`);
159
+ try {
160
+ return JSON.parse(readFileSync(path, 'utf8'));
161
+ } catch (error) {
162
+ throw new SquashError(`${label} is not valid JSON: ${error.message}`);
163
+ }
164
+ }
165
+
166
+ function atomicWrite(path, content) {
167
+ mkdirSync(dirname(path), { recursive: true });
168
+ if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new SquashError(`refusing to replace symlink: ${basename(path)}`);
169
+ const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
170
+ const fd = openSync(temporary, 'wx', 0o600);
171
+ try {
172
+ writeFileSync(fd, content, 'utf8');
173
+ fsyncSync(fd);
174
+ } finally {
175
+ closeSync(fd);
176
+ }
177
+ renameSync(temporary, path);
178
+ }
179
+
180
+ function atomicJson(path, value) {
181
+ atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`);
182
+ }
183
+
184
+ function validateRootCursor(stateRoot) {
185
+ const path = join(stateRoot, 'state.json');
186
+ if (!existsSync(path)) return;
187
+ const value = readJson(path, 'root state');
188
+ exactKeys(value, ['schema_version', 'current_change'], 'root state');
189
+ if (value.schema_version !== SCHEMA_VERSION) throw new SquashError(`unsupported root state schema: ${value.schema_version}`);
190
+ if (value.current_change !== null && typeof value.current_change !== 'string') throw new SquashError('root state current_change must be a string or null');
191
+ }
192
+
193
+ function resolveCommit(repo, ref, requiredValue = true) {
194
+ const result = git(repo, ['rev-parse', '--verify', `${ref}^{commit}`], { allowed: [0, 128] });
195
+ if (result.status === 0) return result.stdout.trim();
196
+ if (requiredValue) throw new SquashError(`missing commit: ${ref}`);
197
+ return null;
198
+ }
199
+
200
+ function resolveTree(repo, ref) {
201
+ return git(repo, ['rev-parse', '--verify', `${ref}^{tree}`]).stdout.trim();
202
+ }
203
+
204
+ function revList(repo, args) {
205
+ return git(repo, ['rev-list', ...args]).stdout.split('\n').filter(Boolean);
206
+ }
207
+
208
+ function isAncestor(repo, ancestor, descendant) {
209
+ return git(repo, ['merge-base', '--is-ancestor', ancestor, descendant], { allowed: [0, 1] }).status === 0;
210
+ }
211
+
212
+ function validateRef(repo, ref, label) {
213
+ if (typeof ref !== 'string' || !ref.startsWith('refs/heads/')) throw new SquashError(`${label} must be a full refs/heads ref`);
214
+ if (git(repo, ['check-ref-format', ref], { allowed: [0, 1] }).status !== 0) throw new SquashError(`${label} is invalid`);
215
+ }
216
+
217
+ function validateOid(value, label, { nullable = false } = {}) {
218
+ if (nullable && value === null) return;
219
+ if (typeof value !== 'string' || !OID.test(value)) throw new SquashError(`${label} must be a full object id`);
220
+ }
221
+
222
+ function safePath(value, label) {
223
+ if (typeof value !== 'string' || !value || value.includes('\\') || isAbsolute(value) || value.split('/').includes('..')) {
224
+ throw new SquashError(`${label} must be a POSIX project-relative path`);
225
+ }
226
+ }
227
+
228
+ function exactKeys(value, keys, label) {
229
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new SquashError(`${label} must be an object`);
230
+ const actual = Object.keys(value).sort().join(',');
231
+ const expected = [...keys].sort().join(',');
232
+ if (actual !== expected) throw new SquashError(`${label} must contain exactly: ${[...keys].sort().join(', ')}`);
233
+ }
234
+
235
+ function loadRequest(path) {
236
+ const request = readJson(resolve(path), 'request');
237
+ exactKeys(request, ['schema_version', 'topic', 'repositories'], 'request');
238
+ if (request.schema_version !== SCHEMA_VERSION) throw new SquashError(`unsupported request schema: ${request.schema_version}`);
239
+ if (!KEBAB.test(request.topic)) throw new SquashError('request.topic must be lowercase ASCII kebab-case');
240
+ if (!Array.isArray(request.repositories) || request.repositories.length === 0) throw new SquashError('request.repositories must be non-empty');
241
+ const ids = new Set();
242
+ for (const [index, item] of request.repositories.entries()) {
243
+ exactKeys(item, ['id', 'path', 'branch', 'start', 'end', 'boundary', 'message', 'sign', 'remote', 'submodule_of'], `repositories[${index}]`);
244
+ if (!KEBAB.test(item.id) || ids.has(item.id)) throw new SquashError(`repository id must be unique kebab-case: ${item.id}`);
245
+ ids.add(item.id);
246
+ safePath(item.path, `${item.id}.path`);
247
+ if (typeof item.branch !== 'string' || !item.branch) throw new SquashError(`${item.id}.branch is required`);
248
+ if (typeof item.start !== 'string' || !item.start || typeof item.end !== 'string' || !item.end) throw new SquashError(`${item.id}.start and end are required`);
249
+ if (!new Set(['inclusive', 'exclusive']).has(item.boundary)) throw new SquashError(`${item.id}.boundary must be inclusive or exclusive`);
250
+ if (typeof item.message !== 'string' || !item.message.trim() || item.message.includes('\0')) throw new SquashError(`${item.id}.message must be non-empty and contain no NUL`);
251
+ if (typeof item.sign !== 'boolean') throw new SquashError(`${item.id}.sign must be boolean`);
252
+ if (item.remote !== null) {
253
+ exactKeys(item.remote, ['name', 'branch', 'publish'], `${item.id}.remote`);
254
+ if (typeof item.remote.name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(item.remote.name)) throw new SquashError(`${item.id}.remote.name is invalid`);
255
+ if (typeof item.remote.branch !== 'string' || !item.remote.branch.startsWith('refs/heads/')) throw new SquashError(`${item.id}.remote.branch must be a full branch ref`);
256
+ if (typeof item.remote.publish !== 'boolean') throw new SquashError(`${item.id}.remote.publish must be boolean`);
257
+ }
258
+ if (item.submodule_of !== null) {
259
+ exactKeys(item.submodule_of, ['repository', 'gitlink_path'], `${item.id}.submodule_of`);
260
+ if (!KEBAB.test(item.submodule_of.repository)) throw new SquashError(`${item.id}.submodule_of.repository is invalid`);
261
+ safePath(item.submodule_of.gitlink_path, `${item.id}.submodule_of.gitlink_path`);
262
+ }
263
+ }
264
+ for (const item of request.repositories) {
265
+ if (item.submodule_of && !ids.has(item.submodule_of.repository)) throw new SquashError(`${item.id} references unknown parent ${item.submodule_of.repository}`);
266
+ if (item.submodule_of && (!item.remote || !item.remote.publish)) throw new SquashError(`${item.id} must publish before parent gitlink can advance`);
267
+ }
268
+ validateAcyclic(request.repositories);
269
+ return request;
270
+ }
271
+
272
+ function validateAcyclic(repositories) {
273
+ const parent = new Map(repositories.map((item) => [item.id, item.submodule_of?.repository ?? null]));
274
+ for (const id of parent.keys()) {
275
+ const seen = new Set();
276
+ let current = id;
277
+ while (current) {
278
+ if (seen.has(current)) throw new SquashError(`submodule dependency cycle includes ${current}`);
279
+ seen.add(current);
280
+ current = parent.get(current) ?? null;
281
+ }
282
+ }
283
+ }
284
+
285
+ function repositoryRoot(path) {
286
+ if (git(path, ['rev-parse', '--is-inside-work-tree'], { allowed: [0, 128] }).stdout.trim() !== 'true') {
287
+ throw new SquashError('repository path is not a Git worktree');
288
+ }
289
+ return realpathSync(git(path, ['rev-parse', '--show-toplevel']).stdout.trim());
290
+ }
291
+
292
+ function portable(root, path, externalIndex) {
293
+ const rel = relative(realpathSync(root), realpathSync(path));
294
+ if (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`)) return rel ? rel.split(sep).join('/') : '.';
295
+ return `<external-worktree:${externalIndex}>`;
296
+ }
297
+
298
+ function worktrees(repo, projectRoot) {
299
+ const tokens = git(repo, ['worktree', 'list', '--porcelain', '-z'], { env: { GIT_OPTIONAL_LOCKS: '0' } }).stdout.split('\0');
300
+ const entries = [];
301
+ let current = null;
302
+ for (const token of tokens) {
303
+ if (!token) continue;
304
+ const space = token.indexOf(' ');
305
+ const key = space === -1 ? token : token.slice(0, space);
306
+ const value = space === -1 ? true : token.slice(space + 1);
307
+ if (key === 'worktree') {
308
+ if (current) entries.push(current);
309
+ current = { absolute: resolve(String(value)), branch: null, head: null, detached: false, locked: false, prunable: false };
310
+ } else if (current) {
311
+ if (key === 'branch') current.branch = value;
312
+ else if (key === 'HEAD') current.head = value;
313
+ else if (key === 'detached') current.detached = true;
314
+ else if (key === 'locked') current.locked = true;
315
+ else if (key === 'prunable') current.prunable = true;
316
+ }
317
+ }
318
+ if (current) entries.push(current);
319
+ return entries.map((entry, index) => {
320
+ let dirty = true;
321
+ let operations = ['unreadable'];
322
+ if (!entry.prunable && existsSync(entry.absolute)) {
323
+ const status = git(entry.absolute, ['status', '--porcelain=v2', '-z', '--untracked-files=all'], { env: { GIT_OPTIONAL_LOCKS: '0' } }).stdout;
324
+ dirty = status.length > 0;
325
+ operations = operationMarkers(entry.absolute);
326
+ }
327
+ return {
328
+ locator: portable(projectRoot, entry.absolute, index + 1),
329
+ branch: entry.branch,
330
+ head: entry.head,
331
+ detached: entry.detached,
332
+ locked: entry.locked,
333
+ prunable: entry.prunable,
334
+ dirty,
335
+ operations,
336
+ };
337
+ });
338
+ }
339
+
340
+ function operationMarkers(worktree) {
341
+ const names = ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'BISECT_LOG', 'rebase-merge', 'rebase-apply', 'sequencer', 'index.lock', 'HEAD.lock'];
342
+ const present = [];
343
+ for (const name of names) {
344
+ const path = git(worktree, ['rev-parse', '--path-format=absolute', '--git-path', name], { env: { GIT_OPTIONAL_LOCKS: '0' } }).stdout.trim();
345
+ if (path && existsSync(path)) present.push(name);
346
+ }
347
+ return present;
348
+ }
349
+
350
+ function affectedRefs(repo, replaced) {
351
+ const refs = [];
352
+ const lines = git(repo, ['for-each-ref', '--format=%(refname)\t%(objectname)', 'refs/heads', 'refs/remotes', 'refs/tags'], { env: { GIT_OPTIONAL_LOCKS: '0' } }).stdout.split('\n').filter(Boolean);
353
+ for (const line of lines) {
354
+ const [ref] = line.split('\t');
355
+ const commit = resolveCommit(repo, ref, false);
356
+ if (commit && replaced.has(commit)) refs.push({ ref, commit });
357
+ }
358
+ return refs;
359
+ }
360
+
361
+ function gitlinkAt(repo, tree, path) {
362
+ const output = git(repo, ['ls-tree', tree, '--', path]).stdout.trim();
363
+ if (!output) return null;
364
+ const match = output.match(/^([0-9]+)\s+(\S+)\s+([0-9a-f]+)\t/);
365
+ return match ? { mode: match[1], type: match[2], oid: match[3] } : null;
366
+ }
367
+
368
+ function pushUrls(repo, remote) {
369
+ return git(repo, ['remote', 'get-url', '--push', '--all', remote]).stdout.split('\n').filter(Boolean);
370
+ }
371
+
372
+ function githubCoordinates(url) {
373
+ const patterns = [
374
+ /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/,
375
+ /^(?:https?|ssh):\/\/(?:git@)?github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/,
376
+ ];
377
+ for (const pattern of patterns) {
378
+ const match = url.match(pattern);
379
+ if (match) return { owner: match[1], repo: match[2] };
380
+ }
381
+ return null;
382
+ }
383
+
384
+ function localRemote(url) {
385
+ return url.startsWith('file://') || isAbsolute(url) || url.startsWith('./') || url.startsWith('../');
386
+ }
387
+
388
+ function remoteTip(repo, remote, remoteRef) {
389
+ const result = git(repo, ['ls-remote', '--exit-code', '--refs', remote, remoteRef], { allowed: [0, 2], timeout: 180_000 });
390
+ if (result.status === 2) throw new SquashError(`remote branch does not exist: ${remoteRef}`);
391
+ const lines = result.stdout.split('\n').filter(Boolean);
392
+ if (lines.length !== 1) throw new SquashError(`expected one remote ref for ${remoteRef}, found ${lines.length}`);
393
+ return lines[0].split(/\s+/)[0];
394
+ }
395
+
396
+ function githubPolicy(repo, url, remoteRef, sign) {
397
+ const coordinates = githubCoordinates(url);
398
+ if (!coordinates) return { provider: 'unknown', status: 'unknown', rules: [] };
399
+ const branch = remoteRef.slice('refs/heads/'.length);
400
+ const encodedBranch = encodeURIComponent(branch);
401
+ const base = `repos/${encodeURIComponent(coordinates.owner)}/${encodeURIComponent(coordinates.repo)}`;
402
+ const branchResult = run(repo, 'gh', ['api', `${base}/branches/${encodedBranch}`], { allowed: [0, 1, 2, 4, 127], timeout: 60_000 });
403
+ if (branchResult.status !== 0) return { provider: 'github', status: 'unknown', rules: [] };
404
+ let branchInfo;
405
+ try { branchInfo = JSON.parse(branchResult.stdout); } catch { return { provider: 'github', status: 'unknown', rules: [] }; }
406
+ const rulesResult = run(repo, 'gh', ['api', `${base}/rules/branches/${encodedBranch}`, '--paginate', '--slurp'], { allowed: [0, 1, 2, 4, 127], timeout: 60_000 });
407
+ if (rulesResult.status !== 0) return { provider: 'github', status: 'unknown', rules: [] };
408
+ let rules;
409
+ try {
410
+ rules = JSON.parse(rulesResult.stdout);
411
+ if (Array.isArray(rules) && rules.every(Array.isArray)) rules = rules.flat();
412
+ } catch { return { provider: 'github', status: 'unknown', rules: [] }; }
413
+ if (!Array.isArray(rules)) return { provider: 'github', status: 'unknown', rules: [] };
414
+ const types = [...new Set(rules.map((item) => item?.type).filter((value) => typeof value === 'string'))].sort();
415
+ const blockers = types.filter((type) => type !== 'required_linear_history' && !(type === 'required_signatures' && sign));
416
+ if (types.includes('non_fast_forward')) blockers.push('non_fast_forward');
417
+ if (branchInfo.protected) {
418
+ const protection = run(repo, 'gh', ['api', `${base}/branches/${encodedBranch}/protection`], { allowed: [0, 1, 2, 4, 127], timeout: 60_000 });
419
+ if (protection.status !== 0) return { provider: 'github', status: 'unknown', rules: types };
420
+ let value;
421
+ try { value = JSON.parse(protection.stdout); } catch { return { provider: 'github', status: 'unknown', rules: types }; }
422
+ if (value.lock_branch?.enabled || value.allow_force_pushes?.enabled !== true) blockers.push('classic-protection');
423
+ if (value.required_pull_request_reviews || value.required_status_checks) blockers.push('classic-required-gate');
424
+ if (value.required_signatures?.enabled && !sign) blockers.push('required-signatures');
425
+ }
426
+ return {
427
+ provider: 'github',
428
+ status: blockers.length === 0 ? 'verified-allowed' : 'blocked',
429
+ rules: [...new Set([...types, ...blockers])].sort(),
430
+ };
431
+ }
432
+
433
+ function inspectRemote(repo, request, oldHead) {
434
+ validateRef(repo, request.remote.branch, `${request.id}.remote.branch`);
435
+ const urls = pushUrls(repo, request.remote.name);
436
+ if (urls.length !== 1) throw new SquashError(`${request.id} must have exactly one push URL`);
437
+ const old = remoteTip(repo, urls[0], request.remote.branch);
438
+ if (!resolveCommit(repo, old, false)) throw new SquashError(`${request.id} remote tip is not present locally; fetch explicitly and re-plan`);
439
+ if (!isAncestor(repo, old, oldHead)) throw new SquashError(`${request.id} remote branch is not an ancestor of local old head`);
440
+ const protection = localRemote(urls[0])
441
+ ? { provider: 'local', status: 'not-applicable', rules: [] }
442
+ : githubPolicy(repo, urls[0], request.remote.branch, request.sign);
443
+ if (!new Set(['not-applicable', 'verified-allowed']).has(protection.status)) {
444
+ throw new SquashError(`${request.id} remote protection policy is ${protection.status}`);
445
+ }
446
+ return { name: request.remote.name, branch: request.remote.branch, old_sha: old, protection };
447
+ }
448
+
449
+ function inspectRepository(projectRoot, request) {
450
+ const repo = inside(projectRoot, request.path, `${request.id}.path`, { allowRoot: true });
451
+ ensureDirectory(repo, `${request.id}.path`);
452
+ if (repositoryRoot(repo) !== realpathSync(repo)) throw new SquashError(`${request.id}.path must be the repository toplevel`);
453
+ validateRef(repo, request.branch, `${request.id}.branch`);
454
+ git(repo, ['update-ref', '--stdin'], { input: 'start\nabort\n', env: { GIT_OPTIONAL_LOCKS: '0' } });
455
+ git(repo, ['var', 'GIT_AUTHOR_IDENT'], { env: { GIT_OPTIONAL_LOCKS: '0' } });
456
+ git(repo, ['var', 'GIT_COMMITTER_IDENT'], { env: { GIT_OPTIONAL_LOCKS: '0' } });
457
+ if (git(repo, ['rev-parse', '--is-shallow-repository'], { env: { GIT_OPTIONAL_LOCKS: '0' } }).stdout.trim() !== 'false') throw new SquashError(`${request.id} shallow repository is unsupported`);
458
+ if (git(repo, ['replace', '-l'], { env: { GIT_OPTIONAL_LOCKS: '0' } }).stdout.trim()) throw new SquashError(`${request.id} replace refs are unsupported`);
459
+ const grafts = git(repo, ['rev-parse', '--path-format=absolute', '--git-path', 'info/grafts'], { env: { GIT_OPTIONAL_LOCKS: '0' } }).stdout.trim();
460
+ if (grafts && existsSync(grafts) && statSync(grafts).size > 0) throw new SquashError(`${request.id} grafts are unsupported`);
461
+
462
+ const branchHead = resolveCommit(repo, request.branch);
463
+ const start = resolveCommit(repo, request.start);
464
+ const end = resolveCommit(repo, request.end);
465
+ if (branchHead !== end) throw new SquashError(`${request.id}.end must equal the selected branch tip`);
466
+ const firstParentHistory = new Set(revList(repo, ['--first-parent', end]));
467
+ if (!firstParentHistory.has(start)) throw new SquashError(`${request.id}.start is not on end's first-parent chain`);
468
+ const parents = git(repo, ['show', '-s', '--format=%P', start], { env: { GIT_OPTIONAL_LOCKS: '0' } }).stdout.trim().split(/\s+/).filter(Boolean);
469
+ const baseline = request.boundary === 'exclusive' ? start : (parents[0] ?? null);
470
+ const revision = baseline ? `${baseline}..${end}` : end;
471
+ const firstParent = revList(repo, ['--first-parent', revision]);
472
+ const reachable = revList(repo, [revision]);
473
+ const merges = revList(repo, ['--merges', revision]);
474
+ if (firstParent.length === 0) throw new SquashError(`${request.id} range is empty`);
475
+ if (reachable.length <= 1 && merges.length === 0) throw new SquashError(`${request.id} range does not reduce history`);
476
+ const tree = resolveTree(repo, end);
477
+ const allWorktrees = worktrees(repo, projectRoot);
478
+ for (const item of allWorktrees) {
479
+ if (item.prunable) throw new SquashError(`${request.id} has a prunable worktree: ${item.locator}`);
480
+ if (item.dirty) throw new SquashError(`${request.id} has a dirty worktree: ${item.locator}`);
481
+ if (item.operations.length) throw new SquashError(`${request.id} has an in-progress Git operation in ${item.locator}: ${item.operations.join(', ')}`);
482
+ }
483
+ const stash = resolveCommit(repo, 'refs/stash', false);
484
+ const refs = affectedRefs(repo, new Set(reachable));
485
+ const remote = request.remote?.publish ? inspectRemote(repo, request, end) : null;
486
+ const runIdPart = randomBytes(4).toString('hex');
487
+ return {
488
+ id: request.id,
489
+ path: request.path,
490
+ branch: request.branch,
491
+ boundary: request.boundary,
492
+ start_sha: start,
493
+ end_sha: end,
494
+ baseline_sha: baseline,
495
+ old_tree: tree,
496
+ counts: { first_parent: firstParent.length, reachable: reachable.length, merges: merges.length },
497
+ message: request.message,
498
+ message_subject: request.message.split('\n')[0],
499
+ message_sha256: sha256(request.message),
500
+ sign: request.sign,
501
+ remote,
502
+ publish: request.remote?.publish === true,
503
+ submodule_of: request.submodule_of,
504
+ backup_ref_suffix: runIdPart,
505
+ worktrees: allWorktrees,
506
+ stash_sha: stash,
507
+ affected_refs: refs,
508
+ evidence: [],
509
+ status: 'planned',
510
+ new_tree: null,
511
+ new_head: null,
512
+ error: null,
513
+ };
514
+ }
515
+
516
+ function scanEvidence(evidenceRoot, stateRoot, repositories) {
517
+ const shaToRepo = new Map();
518
+ for (const repo of repositories) {
519
+ const repoPath = resolve(repo.__absolute_path);
520
+ const revision = repo.baseline_sha ? `${repo.baseline_sha}..${repo.end_sha}` : repo.end_sha;
521
+ for (const oid of revList(repoPath, [revision])) {
522
+ if (!shaToRepo.has(oid)) shaToRepo.set(oid, new Set());
523
+ shaToRepo.get(oid).add(repo.id);
524
+ }
525
+ }
526
+ const hits = new Map(repositories.map((repo) => [repo.id, []]));
527
+ const documents = [];
528
+ function visit(path) {
529
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
530
+ const full = join(path, entry.name);
531
+ if (full === stateRoot || full.startsWith(`${stateRoot}${sep}`)) continue;
532
+ if (entry.isSymbolicLink()) continue;
533
+ if (entry.isDirectory()) {
534
+ if (new Set(['back', 'baselines']).has(entry.name)) continue;
535
+ visit(full);
536
+ } else if (entry.isFile() && /\.(?:json|md)$/i.test(entry.name) && statSync(full).size <= 5 * 1024 * 1024) {
537
+ const text = readFileSync(full, 'utf8');
538
+ documents.push({ full, name: entry.name, text });
539
+ }
540
+ }
541
+ }
542
+ visit(evidenceRoot);
543
+ const changeRoots = documents
544
+ .filter((document) => document.name === '.status.json')
545
+ .map((document) => {
546
+ let active = true;
547
+ try {
548
+ const value = JSON.parse(document.text);
549
+ const status = value.change_status ?? value.status;
550
+ active = typeof status !== 'string' || !new Set(['completed', 'archived']).has(status);
551
+ } catch {}
552
+ return { root: dirname(document.full), active };
553
+ })
554
+ .sort((left, right) => right.root.length - left.root.length);
555
+ for (const document of documents) {
556
+ const owner = changeRoots.find((candidate) => document.full === candidate.root || document.full.startsWith(`${candidate.root}${sep}`));
557
+ const active = owner?.active ?? false;
558
+ const locator = relative(evidenceRoot, document.full).split(sep).join('/');
559
+ for (const match of document.text.matchAll(/\b[0-9a-f]{40}\b/g)) {
560
+ const ids = shaToRepo.get(match[0]);
561
+ if (!ids) continue;
562
+ for (const id of ids) hits.get(id).push({ path: locator, active });
563
+ }
564
+ }
565
+ for (const repo of repositories) {
566
+ const unique = new Map(hits.get(repo.id).map((hit) => [`${hit.path}:${hit.active}`, hit]));
567
+ repo.evidence = [...unique.values()].sort((a, b) => a.path.localeCompare(b.path));
568
+ if (repo.evidence.some((hit) => hit.active)) throw new SquashError(`${repo.id} range is referenced by active workflow evidence`);
569
+ }
570
+ }
571
+
572
+ function validateSubmodules(projectRoot, repositories) {
573
+ const byId = new Map(repositories.map((repo) => [repo.id, repo]));
574
+ for (const child of repositories.filter((repo) => repo.submodule_of)) {
575
+ const parent = byId.get(child.submodule_of.repository);
576
+ const expectedChild = resolve(projectRoot, parent.path, child.submodule_of.gitlink_path);
577
+ if (resolve(projectRoot, child.path) !== expectedChild) throw new SquashError(`${child.id}.path must equal its parent gitlink path`);
578
+ const entry = gitlinkAt(resolve(projectRoot, parent.path), parent.end_sha, child.submodule_of.gitlink_path);
579
+ if (!entry || entry.mode !== '160000' || entry.type !== 'commit') throw new SquashError(`${child.id} parent path is not a gitlink at parent end`);
580
+ if (entry.oid !== child.end_sha) throw new SquashError(`${child.id} parent old gitlink does not equal child old end`);
581
+ const modules = git(resolve(projectRoot, parent.path), ['show', `${parent.end_sha}:.gitmodules`], { allowed: [0, 128], env: { GIT_OPTIONAL_LOCKS: '0' } });
582
+ if (modules.status !== 0) throw new SquashError(`${child.id} parent end has no .gitmodules`);
583
+ const escaped = child.submodule_of.gitlink_path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
584
+ const matches = [...modules.stdout.matchAll(new RegExp(`^\\s*path\\s*=\\s*${escaped}\\s*$`, 'gm'))];
585
+ if (matches.length !== 1) throw new SquashError(`${child.id} gitlink path must appear once in .gitmodules`);
586
+ }
587
+ }
588
+
589
+ function manifestForApply(state) {
590
+ const children = new Map(state.repositories.map((repo) => [repo.id, []]));
591
+ for (const repo of state.repositories) if (repo.submodule_of) children.get(repo.submodule_of.repository).push(repo);
592
+ return state.repositories
593
+ .filter((repo) => repo.status === 'planned' && children.get(repo.id).every((child) => child.status === 'published'))
594
+ .map((repo) => ({
595
+ repository: repo.id,
596
+ path: repo.path,
597
+ branch: repo.branch,
598
+ start_sha: repo.start_sha,
599
+ end_sha: repo.end_sha,
600
+ baseline_sha: repo.baseline_sha,
601
+ old_tree: repo.old_tree,
602
+ counts: repo.counts,
603
+ message: repo.message,
604
+ message_sha256: repo.message_sha256,
605
+ sign: repo.sign,
606
+ backup_ref: repo.backup_ref,
607
+ child_gitlinks: children.get(repo.id).map((child) => ({ path: child.submodule_of.gitlink_path, sha: child.new_head })),
608
+ }));
609
+ }
610
+
611
+ function manifestForPublish(state) {
612
+ return state.repositories
613
+ .filter((repo) => repo.status === 'local-verified' && repo.publish)
614
+ .map((repo) => ({
615
+ repository: repo.id,
616
+ path: repo.path,
617
+ local_ref: repo.branch,
618
+ new_sha: repo.new_head,
619
+ remote: repo.remote.name,
620
+ remote_ref: repo.remote.branch,
621
+ remote_old_sha: repo.remote.old_sha,
622
+ protection: repo.remote.protection,
623
+ }));
624
+ }
625
+
626
+ function refreshNext(state) {
627
+ state.local_manifest = manifestForApply(state);
628
+ state.publish_manifest = manifestForPublish(state);
629
+ state.local_digest = state.local_manifest.length ? sha256(state.local_manifest) : null;
630
+ state.publish_digest = state.publish_manifest.length ? sha256(state.publish_manifest) : null;
631
+ if (state.publish_manifest.length) {
632
+ state.next_action = 'confirm-publish';
633
+ state.phase = 'local-verified';
634
+ } else if (state.local_manifest.length) {
635
+ state.next_action = 'confirm-local';
636
+ state.phase = 'planned';
637
+ } else if (state.repositories.every((repo) => TERMINAL.has(repo.status))) {
638
+ state.next_action = 'complete';
639
+ state.phase = state.repositories.some((repo) => repo.status === 'published') ? 'completed-published' : 'completed-local';
640
+ } else {
641
+ state.next_action = 'blocked';
642
+ state.phase = 'blocked-partial';
643
+ }
644
+ state.updated_at = now();
645
+ }
646
+
647
+ function reportText(state) {
648
+ const lines = [
649
+ '---',
650
+ 'skill: git-history-squash',
651
+ `schema_version: ${SCHEMA_VERSION}`,
652
+ `run_id: ${state.run_id}`,
653
+ `status: ${state.phase}`,
654
+ `topic: ${state.topic}`,
655
+ `generated_at: "${state.updated_at}"`,
656
+ '---',
657
+ '',
658
+ '# Git History Squash Report',
659
+ '',
660
+ '## Scope',
661
+ '',
662
+ `- Project: \`.\``,
663
+ `- Mode: \`${state.next_action}\``,
664
+ `- Run: \`${state.change}\``,
665
+ '',
666
+ '## Repository Plan',
667
+ '',
668
+ '| Repository | Path | Branch | Boundary | Baseline | Old head | First-parent | Reachable | Merges | Message | Status |',
669
+ '|---|---|---|---|---|---|---:|---:|---:|---|---|',
670
+ ];
671
+ for (const repo of state.repositories) {
672
+ lines.push(`| ${repo.id} | \`${repo.path}\` | \`${repo.branch}\` | ${repo.boundary} | \`${repo.baseline_sha ?? '<root>'}\` | \`${repo.end_sha}\` | ${repo.counts.first_parent} | ${repo.counts.reachable} | ${repo.counts.merges} | ${repo.message_subject.replaceAll('|', '\\|')} (\`${repo.message_sha256}\`) | ${repo.status} |`);
673
+ }
674
+ lines.push('', '## Local Effects', '');
675
+ for (const repo of state.repositories) {
676
+ lines.push(`### ${repo.id}`, '', `- Backup ref: \`${repo.backup_ref}\``);
677
+ lines.push(`- New head: \`${repo.new_head ?? '<not-created>'}\``);
678
+ lines.push(`- Tree contract: \`${repo.new_tree ?? repo.old_tree}\``);
679
+ lines.push(`- Stash observed: ${repo.stash_sha ? `\`${repo.stash_sha}\` (preserved)` : 'none'}`);
680
+ lines.push(`- Affected refs preserved: ${repo.affected_refs.length ? repo.affected_refs.map((item) => `\`${item.ref}\``).join(', ') : 'none'}`);
681
+ lines.push(`- Worktrees: ${repo.worktrees.map((item) => `\`${item.locator}\` (${item.branch ?? 'detached'} @ ${item.head})`).join(', ')}`);
682
+ lines.push(`- Workflow evidence: ${repo.evidence.length ? repo.evidence.map((item) => `\`${item.path}\`${item.active ? ' [active]' : ''}`).join(', ') : 'none'}`);
683
+ if (repo.error) lines.push(`- Error: ${sanitize(repo.error)}`);
684
+ lines.push('');
685
+ }
686
+ lines.push('## Confirmation Digests', '');
687
+ lines.push(`- Local: \`${state.local_digest ?? '<none>'}\``);
688
+ lines.push(`- Publish: \`${state.publish_digest ?? '<none>'}\``);
689
+ lines.push('- A digest is valid only after the exact manifest is shown and confirmed in the current conversation.', '');
690
+ lines.push('## Remote Results', '');
691
+ for (const repo of state.repositories) {
692
+ if (!repo.publish) lines.push(`- ${repo.id}: local-only`);
693
+ else lines.push(`- ${repo.id}: ${repo.status}; \`${repo.remote.name}\` \`${repo.remote.branch}\` old=\`${repo.remote.old_sha}\` new=\`${repo.new_head ?? '<not-created>'}\`; policy=${repo.remote.protection.status}`);
694
+ }
695
+ lines.push('', '## Recovery', '');
696
+ for (const repo of state.repositories.filter((item) => item.new_head)) {
697
+ lines.push(`- Local ${repo.id}: \`git -C ${shellQuote(repo.path)} update-ref ${repo.branch} ${repo.end_sha} ${repo.new_head}\``);
698
+ if (repo.status === 'published') lines.push(`- Remote ${repo.id}: \`git -C ${shellQuote(repo.path)} push ${repo.remote.name} --force-with-lease=${repo.remote.branch}:${repo.new_head} ${repo.backup_ref}:${repo.remote.branch}\``);
699
+ }
700
+ if (state.error) lines.push('', '## Blocking Error', '', sanitize(state.error), '');
701
+ lines.push('', 'Recovery and cleanup require a new exact plan and explicit authorization. Backup refs, worktrees, branches, stash, reflog, and workflow evidence remain preserved.', '');
702
+ return sanitize(lines.join('\n'));
703
+ }
704
+
705
+ function persist(stateRoot, state) {
706
+ const changeDir = join(stateRoot, state.change);
707
+ atomicJson(join(changeDir, 'state.json'), state);
708
+ atomicWrite(join(changeDir, 'report.md'), reportText(state));
709
+ atomicJson(join(stateRoot, 'state.json'), { schema_version: SCHEMA_VERSION, current_change: state.change });
710
+ }
711
+
712
+ function loadRun(projectRoot, stateRootArg, change) {
713
+ const stateRoot = inside(projectRoot, stateRootArg, 'state root', { mustExist: true });
714
+ ensureDirectory(stateRoot, 'state root');
715
+ validateRootCursor(stateRoot);
716
+ if (!/^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*(?:-\d{2})?$/.test(change)) throw new SquashError('invalid change name');
717
+ const changeDir = inside(stateRoot, change, 'change', { mustExist: true });
718
+ const state = readJson(join(changeDir, 'state.json'), 'change state');
719
+ if (state.schema_version !== SCHEMA_VERSION || state.skill !== 'git-history-squash' || state.change !== change) throw new SquashError('unsupported or mismatched change state');
720
+ validateRunState(projectRoot, state);
721
+ return { stateRoot, state, changeDir };
722
+ }
723
+
724
+ function validateRunState(projectRoot, state) {
725
+ if (!Array.isArray(state.repositories) || !Array.isArray(state.local_manifest) || !Array.isArray(state.publish_manifest)) {
726
+ throw new SquashError('change state manifest fields must be arrays');
727
+ }
728
+ if (!Array.isArray(state.confirmations) || typeof state.run_id !== 'string' || !state.run_id || !KEBAB.test(state.topic)) {
729
+ throw new SquashError('change state metadata is invalid');
730
+ }
731
+ const ids = new Set();
732
+ for (const repo of state.repositories) {
733
+ if (!repo || typeof repo !== 'object' || Array.isArray(repo) || !KEBAB.test(repo.id) || ids.has(repo.id)) {
734
+ throw new SquashError('change state repository ids must be unique kebab-case');
735
+ }
736
+ ids.add(repo.id);
737
+ safePath(repo.path, `${repo.id}.path`);
738
+ const path = inside(projectRoot, repo.path, `${repo.id}.path`, { allowRoot: true, mustExist: true });
739
+ ensureDirectory(path, `${repo.id}.path`);
740
+ if (repositoryRoot(path) !== realpathSync(path)) throw new SquashError(`${repo.id}.path must remain a repository toplevel`);
741
+ validateRef(path, repo.branch, `${repo.id}.branch`);
742
+ validateOid(repo.start_sha, `${repo.id}.start_sha`);
743
+ validateOid(repo.end_sha, `${repo.id}.end_sha`);
744
+ validateOid(repo.baseline_sha, `${repo.id}.baseline_sha`, { nullable: true });
745
+ validateOid(repo.old_tree, `${repo.id}.old_tree`);
746
+ validateOid(repo.new_tree, `${repo.id}.new_tree`, { nullable: true });
747
+ validateOid(repo.new_head, `${repo.id}.new_head`, { nullable: true });
748
+ if (typeof repo.message !== 'string' || sha256(repo.message) !== repo.message_sha256) throw new SquashError(`${repo.id} message digest is invalid`);
749
+ if (typeof repo.sign !== 'boolean' || typeof repo.publish !== 'boolean' || !REPOSITORY_STATUSES.has(repo.status)) {
750
+ throw new SquashError(`${repo.id} execution fields are invalid`);
751
+ }
752
+ const expectedBackup = `refs/speculo/backups/git-history-squash/${state.run_id}/${repo.id}`;
753
+ if (repo.backup_ref !== expectedBackup || git(path, ['check-ref-format', repo.backup_ref], { allowed: [0, 1] }).status !== 0) {
754
+ throw new SquashError(`${repo.id} backup ref is invalid`);
755
+ }
756
+ if (repo.publish) {
757
+ if (!repo.remote || typeof repo.remote.name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(repo.remote.name)) throw new SquashError(`${repo.id} remote state is invalid`);
758
+ validateRef(path, repo.remote.branch, `${repo.id}.remote.branch`);
759
+ validateOid(repo.remote.old_sha, `${repo.id}.remote.old_sha`);
760
+ } else if (repo.remote !== null) {
761
+ throw new SquashError(`${repo.id} local-only state must not contain a remote manifest`);
762
+ }
763
+ if (repo.submodule_of !== null) {
764
+ if (!repo.submodule_of || !KEBAB.test(repo.submodule_of.repository)) throw new SquashError(`${repo.id} submodule state is invalid`);
765
+ safePath(repo.submodule_of.gitlink_path, `${repo.id}.submodule_of.gitlink_path`);
766
+ }
767
+ }
768
+ for (const repo of state.repositories) {
769
+ if (repo.submodule_of && !ids.has(repo.submodule_of.repository)) throw new SquashError(`${repo.id} state references an unknown parent`);
770
+ }
771
+ }
772
+
773
+ function validateConfirmationManifest(state, kind) {
774
+ const manifest = kind === 'local' ? manifestForApply(state) : manifestForPublish(state);
775
+ const saved = kind === 'local' ? state.local_manifest : state.publish_manifest;
776
+ const digest = kind === 'local' ? state.local_digest : state.publish_digest;
777
+ if (stableJson(saved) !== stableJson(manifest) || digest !== sha256(manifest)) {
778
+ throw new SquashError(`${kind} confirmation manifest no longer matches change state`);
779
+ }
780
+ }
781
+
782
+ function allocateChange(stateRoot, date, topic) {
783
+ const base = `${date}-${topic}`;
784
+ for (let index = 0; index <= 99; index += 1) {
785
+ const name = index === 0 ? base : `${base}-${String(index).padStart(2, '0')}`;
786
+ const path = join(stateRoot, name);
787
+ if (!existsSync(path)) {
788
+ mkdirSync(path, { recursive: false });
789
+ return name;
790
+ }
791
+ }
792
+ throw new SquashError(`no free change name for ${base}`);
793
+ }
794
+
795
+ function plan(values) {
796
+ const projectRoot = resolve(required(values, 'root'));
797
+ ensureDirectory(projectRoot, 'project root');
798
+ const stateRoot = inside(projectRoot, required(values, 'state_root'), 'state root', { allowRoot: false, mustExist: false });
799
+ mkdirSync(stateRoot, { recursive: true });
800
+ ensureDirectory(stateRoot, 'state root');
801
+ validateRootCursor(stateRoot);
802
+ const evidenceRoot = inside(projectRoot, required(values, 'evidence_root'), 'evidence root', { allowRoot: false, mustExist: true });
803
+ const date = values.date ?? today();
804
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new SquashError('--date must be YYYY-MM-DD');
805
+ const request = loadRequest(required(values, 'request'));
806
+ const change = allocateChange(stateRoot, date, request.topic);
807
+ const runId = `${date.replaceAll('-', '')}T${now().slice(11, 19).replaceAll(':', '')}Z-${randomBytes(4).toString('hex')}`;
808
+ try {
809
+ const repositories = request.repositories.map((item) => {
810
+ const inspected = inspectRepository(projectRoot, item);
811
+ inspected.__absolute_path = resolve(projectRoot, item.path);
812
+ inspected.backup_ref = `refs/speculo/backups/git-history-squash/${runId}/${item.id}`;
813
+ return inspected;
814
+ });
815
+ validateSubmodules(projectRoot, repositories);
816
+ scanEvidence(evidenceRoot, stateRoot, repositories);
817
+ for (const repo of repositories) delete repo.__absolute_path;
818
+ const state = {
819
+ schema_version: SCHEMA_VERSION,
820
+ skill: 'git-history-squash',
821
+ change,
822
+ run_id: runId,
823
+ topic: request.topic,
824
+ phase: 'planned',
825
+ next_action: null,
826
+ created_at: now(),
827
+ updated_at: now(),
828
+ confirmations: [],
829
+ repositories,
830
+ local_manifest: [],
831
+ publish_manifest: [],
832
+ local_digest: null,
833
+ publish_digest: null,
834
+ error: null,
835
+ };
836
+ refreshNext(state);
837
+ persist(stateRoot, state);
838
+ return { change, report: `${relative(projectRoot, join(stateRoot, change, 'report.md')).split(sep).join('/')}`, phase: state.phase, next_action: state.next_action, plan_digest: state.local_digest, publish_digest: state.publish_digest };
839
+ } catch (error) {
840
+ const state = {
841
+ schema_version: SCHEMA_VERSION,
842
+ skill: 'git-history-squash',
843
+ change,
844
+ run_id: runId,
845
+ topic: request.topic,
846
+ phase: 'blocked-partial',
847
+ next_action: 'blocked',
848
+ created_at: now(),
849
+ updated_at: now(),
850
+ confirmations: [],
851
+ repositories: [],
852
+ local_manifest: [],
853
+ publish_manifest: [],
854
+ local_digest: null,
855
+ publish_digest: null,
856
+ error: sanitize(error.message, projectRoot),
857
+ };
858
+ persist(stateRoot, state);
859
+ throw new SquashError(`${change} blocked; report=${relative(projectRoot, join(stateRoot, change, 'report.md')).split(sep).join('/')}: ${error.message}`);
860
+ }
861
+ }
862
+
863
+ function expectedGitlinkDrift(repoPath, allowedPaths) {
864
+ const cached = git(repoPath, ['diff', '--cached', '--name-only', '-z']).stdout.split('\0').filter(Boolean);
865
+ const unstaged = git(repoPath, ['diff', '--name-only', '-z']).stdout.split('\0').filter(Boolean);
866
+ const untracked = git(repoPath, ['ls-files', '--others', '--exclude-standard', '-z']).stdout.split('\0').filter(Boolean);
867
+ return cached.length === 0
868
+ && untracked.length === 0
869
+ && unstaged.length > 0
870
+ && unstaged.every((item) => allowedPaths.has(item));
871
+ }
872
+
873
+ function revalidateLocal(projectRoot, state, repo) {
874
+ const path = resolve(projectRoot, repo.path);
875
+ if (resolveCommit(path, repo.branch) !== repo.end_sha && resolveCommit(path, repo.branch) !== repo.new_head) throw new SquashError(`${repo.id} target branch drifted`);
876
+ const publishedChildren = state.repositories.filter((item) => item.submodule_of?.repository === repo.id && item.status === 'published');
877
+ const allowedGitlinks = new Set(publishedChildren.map((item) => item.submodule_of.gitlink_path));
878
+ for (const item of worktrees(path, projectRoot)) {
879
+ const allowedTransition = item.dirty
880
+ && item.branch === repo.branch
881
+ && item.locator === repo.path
882
+ && allowedGitlinks.size > 0
883
+ && expectedGitlinkDrift(path, allowedGitlinks);
884
+ if (item.prunable || (item.dirty && !allowedTransition) || item.operations.length) throw new SquashError(`${repo.id} worktree precondition drifted: ${item.locator}`);
885
+ }
886
+ }
887
+
888
+ function buildTree(projectRoot, state, repo) {
889
+ const children = state.repositories.filter((item) => item.submodule_of?.repository === repo.id);
890
+ if (children.length === 0) return repo.old_tree;
891
+ for (const child of children) if (child.status !== 'published' || !child.new_head) throw new SquashError(`${repo.id} child ${child.id} is not remotely published`);
892
+ const path = resolve(projectRoot, repo.path);
893
+ const temporary = mkdtempSync(join(tmpdir(), 'speculo-git-squash-'));
894
+ const indexPath = join(temporary, 'index');
895
+ const env = { GIT_INDEX_FILE: indexPath, GIT_OPTIONAL_LOCKS: '0' };
896
+ try {
897
+ git(path, ['read-tree', repo.old_tree], { env });
898
+ for (const child of children) {
899
+ git(path, ['update-index', '--add', '--cacheinfo', `160000,${child.new_head},${child.submodule_of.gitlink_path}`], { env });
900
+ }
901
+ const tree = git(path, ['write-tree'], { env }).stdout.trim();
902
+ const changed = git(path, ['diff-tree', '--no-commit-id', '--name-only', '-r', repo.old_tree, tree]).stdout.split('\n').filter(Boolean).sort();
903
+ const expected = children.map((child) => child.submodule_of.gitlink_path).sort();
904
+ if (JSON.stringify(changed) !== JSON.stringify(expected)) throw new SquashError(`${repo.id} planned tree changed paths outside declared gitlinks`);
905
+ for (const child of children) {
906
+ const entry = gitlinkAt(path, tree, child.submodule_of.gitlink_path);
907
+ if (!entry || entry.mode !== '160000' || entry.oid !== child.new_head) throw new SquashError(`${repo.id} planned gitlink verification failed for ${child.id}`);
908
+ }
909
+ return tree;
910
+ } finally {
911
+ rmSync(temporary, { recursive: true, force: true });
912
+ }
913
+ }
914
+
915
+ function createCommit(projectRoot, repo, tree) {
916
+ const path = resolve(projectRoot, repo.path);
917
+ const args = ['commit-tree', tree];
918
+ if (repo.baseline_sha) args.push('-p', repo.baseline_sha);
919
+ if (repo.sign) args.push('-S');
920
+ args.push('-F', '-');
921
+ const newHead = git(path, args, { input: repo.message.endsWith('\n') ? repo.message : `${repo.message}\n`, timeout: 180_000 }).stdout.trim();
922
+ if (resolveTree(path, newHead) !== tree) throw new SquashError(`${repo.id} new commit tree verification failed`);
923
+ const parents = git(path, ['show', '-s', '--format=%P', newHead]).stdout.trim().split(/\s+/).filter(Boolean);
924
+ const expectedParents = repo.baseline_sha ? [repo.baseline_sha] : [];
925
+ if (JSON.stringify(parents) !== JSON.stringify(expectedParents)) throw new SquashError(`${repo.id} new commit parent verification failed`);
926
+ if (sha256(git(path, ['show', '-s', '--format=%B', newHead]).stdout.trimEnd()) !== sha256(repo.message.trimEnd())) throw new SquashError(`${repo.id} new commit message verification failed`);
927
+ if (repo.sign && git(path, ['verify-commit', newHead], { allowed: [0, 1] }).status !== 0) throw new SquashError(`${repo.id} new commit signature verification failed`);
928
+ return newHead;
929
+ }
930
+
931
+ function updateRefs(projectRoot, repo) {
932
+ const path = resolve(projectRoot, repo.path);
933
+ if (resolveCommit(path, repo.backup_ref, false)) throw new SquashError(`${repo.id} backup ref already exists`);
934
+ const input = [
935
+ 'start',
936
+ `create ${repo.backup_ref} ${repo.end_sha}`,
937
+ `update ${repo.branch} ${repo.new_head} ${repo.end_sha}`,
938
+ 'prepare',
939
+ 'commit',
940
+ '',
941
+ ].join('\n');
942
+ git(path, ['update-ref', '--create-reflog', '-m', `speculo git-history-squash ${repo.id}`, '--stdin'], { input });
943
+ }
944
+
945
+ function alignAggregateWorktree(projectRoot, state, repo) {
946
+ const children = state.repositories.filter((item) => item.submodule_of?.repository === repo.id);
947
+ if (!children.length) return;
948
+ const repoPath = resolve(projectRoot, repo.path);
949
+ const checkedOut = worktrees(repoPath, projectRoot).filter((item) => item.branch === repo.branch);
950
+ if (checkedOut.length > 1) throw new SquashError(`${repo.id} target branch is checked out more than once`);
951
+ if (!checkedOut.length) return;
952
+ if (checkedOut[0].locator !== repo.path && !(repo.path === '.' && checkedOut[0].locator === '.')) {
953
+ throw new SquashError(`${repo.id} aggregate branch must be checked out at its declared repository path`);
954
+ }
955
+ for (const child of children) {
956
+ const childPath = resolve(projectRoot, child.path);
957
+ const symbolic = git(childPath, ['symbolic-ref', '-q', 'HEAD'], { allowed: [0, 1] });
958
+ if (symbolic.status === 0) {
959
+ if (symbolic.stdout.trim() !== child.branch || resolveCommit(childPath, 'HEAD') !== child.new_head) throw new SquashError(`${child.id} checked-out branch is not at its new head`);
960
+ } else {
961
+ const head = resolveCommit(childPath, 'HEAD');
962
+ if (head === child.end_sha) git(childPath, ['update-ref', '--no-deref', 'HEAD', child.new_head, child.end_sha]);
963
+ else if (head !== child.new_head) throw new SquashError(`${child.id} detached HEAD drifted`);
964
+ }
965
+ git(repoPath, ['update-index', '--add', '--cacheinfo', `160000,${child.new_head},${child.submodule_of.gitlink_path}`]);
966
+ }
967
+ if (git(repoPath, ['write-tree']).stdout.trim() !== repo.new_tree) throw new SquashError(`${repo.id} real index does not match planned tree`);
968
+ }
969
+
970
+ function verifyApplied(projectRoot, state, repo) {
971
+ const path = resolve(projectRoot, repo.path);
972
+ if (resolveCommit(path, repo.branch) !== repo.new_head) throw new SquashError(`${repo.id} target branch did not move to new head`);
973
+ if (resolveCommit(path, repo.backup_ref) !== repo.end_sha) throw new SquashError(`${repo.id} backup ref does not point to old head`);
974
+ if (resolveTree(path, repo.new_head) !== repo.new_tree) throw new SquashError(`${repo.id} final tree verification failed`);
975
+ const parents = git(path, ['show', '-s', '--format=%P', repo.new_head]).stdout.trim().split(/\s+/).filter(Boolean);
976
+ const expectedParents = repo.baseline_sha ? [repo.baseline_sha] : [];
977
+ if (JSON.stringify(parents) !== JSON.stringify(expectedParents)) throw new SquashError(`${repo.id} final parent verification failed`);
978
+ if (sha256(git(path, ['show', '-s', '--format=%B', repo.new_head]).stdout.trimEnd()) !== sha256(repo.message.trimEnd())) throw new SquashError(`${repo.id} final message verification failed`);
979
+ if (repo.sign && git(path, ['verify-commit', repo.new_head], { allowed: [0, 1] }).status !== 0) throw new SquashError(`${repo.id} final signature verification failed`);
980
+ const children = state.repositories.filter((item) => item.submodule_of?.repository === repo.id);
981
+ if (children.length === 0 && repo.new_tree !== repo.old_tree) throw new SquashError(`${repo.id} final tree differs from old end tree`);
982
+ if (children.length) {
983
+ const changed = git(path, ['diff-tree', '--no-commit-id', '--name-only', '-r', repo.old_tree, repo.new_tree]).stdout.split('\n').filter(Boolean).sort();
984
+ const expected = children.map((child) => child.submodule_of.gitlink_path).sort();
985
+ if (JSON.stringify(changed) !== JSON.stringify(expected)) throw new SquashError(`${repo.id} final tree changed paths outside declared gitlinks`);
986
+ for (const child of children) {
987
+ const entry = gitlinkAt(path, repo.new_tree, child.submodule_of.gitlink_path);
988
+ if (!entry || entry.mode !== '160000' || entry.oid !== child.new_head) throw new SquashError(`${repo.id} final gitlink verification failed for ${child.id}`);
989
+ }
990
+ }
991
+ const revision = repo.baseline_sha ? `${repo.baseline_sha}..${repo.new_head}` : repo.new_head;
992
+ if (revList(path, [revision]).length !== 1) throw new SquashError(`${repo.id} baseline to new head does not contain exactly one commit`);
993
+ for (const item of worktrees(path, projectRoot)) {
994
+ if (item.branch === repo.branch && (item.dirty || item.operations.length)) throw new SquashError(`${repo.id} target worktree is not clean after rewrite`);
995
+ }
996
+ }
997
+
998
+ function apply(values) {
999
+ const projectRoot = resolve(required(values, 'root'));
1000
+ const { stateRoot, state } = loadRun(projectRoot, required(values, 'state_root'), required(values, 'change'));
1001
+ validateConfirmationManifest(state, 'local');
1002
+ const confirmation = required(values, 'confirm_plan');
1003
+ if (state.next_action !== 'confirm-local' || !state.local_digest || confirmation !== state.local_digest) throw new SquashError('local confirmation digest does not match the current manifest');
1004
+ const ids = state.local_manifest.map((item) => item.repository);
1005
+ state.confirmations.push({ kind: 'local', digest: confirmation, confirmed_at: now(), repositories: ids });
1006
+ state.phase = 'local-applying';
1007
+ state.updated_at = now();
1008
+ persist(stateRoot, state);
1009
+ for (const id of ids) {
1010
+ const repo = state.repositories.find((item) => item.id === id);
1011
+ try {
1012
+ revalidateLocal(projectRoot, state, repo);
1013
+ if (resolveCommit(resolve(projectRoot, repo.path), repo.branch) !== repo.end_sha) throw new SquashError(`${repo.id} branch moved after plan`);
1014
+ repo.new_tree = buildTree(projectRoot, state, repo);
1015
+ repo.new_head = createCommit(projectRoot, repo, repo.new_tree);
1016
+ repo.status = 'object-created';
1017
+ state.updated_at = now();
1018
+ persist(stateRoot, state);
1019
+ updateRefs(projectRoot, repo);
1020
+ repo.status = 'local-updated';
1021
+ state.updated_at = now();
1022
+ persist(stateRoot, state);
1023
+ alignAggregateWorktree(projectRoot, state, repo);
1024
+ verifyApplied(projectRoot, state, repo);
1025
+ repo.status = repo.publish ? 'local-verified' : 'local-only';
1026
+ state.updated_at = now();
1027
+ persist(stateRoot, state);
1028
+ } catch (error) {
1029
+ repo.error = sanitize(error.message, projectRoot);
1030
+ repo.status = 'blocked';
1031
+ state.error = repo.error;
1032
+ state.phase = 'blocked-partial';
1033
+ state.next_action = 'blocked';
1034
+ state.updated_at = now();
1035
+ persist(stateRoot, state);
1036
+ throw error;
1037
+ }
1038
+ }
1039
+ refreshNext(state);
1040
+ persist(stateRoot, state);
1041
+ return { change: state.change, phase: state.phase, next_action: state.next_action, plan_digest: state.local_digest, publish_digest: state.publish_digest };
1042
+ }
1043
+
1044
+ function publish(values) {
1045
+ const projectRoot = resolve(required(values, 'root'));
1046
+ const { stateRoot, state } = loadRun(projectRoot, required(values, 'state_root'), required(values, 'change'));
1047
+ validateConfirmationManifest(state, 'publish');
1048
+ const confirmation = required(values, 'confirm_publish');
1049
+ if (state.next_action !== 'confirm-publish' || !state.publish_digest || confirmation !== state.publish_digest) throw new SquashError('publish confirmation digest does not match the current manifest');
1050
+ const ids = state.publish_manifest.map((item) => item.repository);
1051
+ state.confirmations.push({ kind: 'publish', digest: confirmation, confirmed_at: now(), repositories: ids });
1052
+ state.phase = 'publishing';
1053
+ state.updated_at = now();
1054
+ persist(stateRoot, state);
1055
+ for (const id of ids) {
1056
+ const repo = state.repositories.find((item) => item.id === id);
1057
+ const path = resolve(projectRoot, repo.path);
1058
+ try {
1059
+ revalidateLocal(projectRoot, state, repo);
1060
+ if (resolveCommit(path, repo.branch) !== repo.new_head || resolveCommit(path, repo.backup_ref) !== repo.end_sha) throw new SquashError(`${repo.id} local rewrite drifted before publish`);
1061
+ const remote = inspectRemote(path, { id: repo.id, remote: { name: repo.remote.name, branch: repo.remote.branch }, sign: repo.sign }, repo.end_sha);
1062
+ if (remote.old_sha !== repo.remote.old_sha) throw new SquashError(`${repo.id} remote lease drifted before publish`);
1063
+ repo.status = 'publishing';
1064
+ state.updated_at = now();
1065
+ persist(stateRoot, state);
1066
+ git(path, ['push', repo.remote.name, `--force-with-lease=${repo.remote.branch}:${repo.remote.old_sha}`, `${repo.branch}:${repo.remote.branch}`], { timeout: 180_000 });
1067
+ const pushUrl = pushUrls(path, repo.remote.name)[0];
1068
+ if (remoteTip(path, pushUrl, repo.remote.branch) !== repo.new_head) throw new SquashError(`${repo.id} remote verification did not return new head`);
1069
+ repo.status = 'published';
1070
+ state.updated_at = now();
1071
+ persist(stateRoot, state);
1072
+ } catch (error) {
1073
+ repo.error = sanitize(error.message, projectRoot);
1074
+ repo.status = 'blocked';
1075
+ state.error = repo.error;
1076
+ state.phase = 'blocked-partial';
1077
+ state.next_action = 'blocked';
1078
+ state.updated_at = now();
1079
+ persist(stateRoot, state);
1080
+ throw error;
1081
+ }
1082
+ }
1083
+ refreshNext(state);
1084
+ persist(stateRoot, state);
1085
+ return { change: state.change, phase: state.phase, next_action: state.next_action, plan_digest: state.local_digest, publish_digest: state.publish_digest };
1086
+ }
1087
+
1088
+ function status(values) {
1089
+ const projectRoot = resolve(required(values, 'root'));
1090
+ const { stateRoot, state } = loadRun(projectRoot, required(values, 'state_root'), required(values, 'change'));
1091
+ if (state.repositories.length === 0) throw new SquashError(`saved run is blocked: ${state.error ?? 'repository plan is unavailable'}`);
1092
+ const observed = [];
1093
+ const problems = [];
1094
+ for (const repo of state.repositories) {
1095
+ const path = resolve(projectRoot, repo.path);
1096
+ const branch = resolveCommit(path, repo.branch, false);
1097
+ const backup = resolveCommit(path, repo.backup_ref, false);
1098
+ let remote = null;
1099
+ if (repo.publish && repo.remote) {
1100
+ try {
1101
+ const urls = pushUrls(path, repo.remote.name);
1102
+ if (urls.length !== 1) throw new SquashError('remote must still have exactly one push URL');
1103
+ remote = remoteTip(path, urls[0], repo.remote.branch);
1104
+ } catch (error) {
1105
+ problems.push(`${repo.id}: ${sanitize(error.message, projectRoot)}`);
1106
+ }
1107
+ }
1108
+ if (!problems.some((problem) => problem.startsWith(`${repo.id}:`))) {
1109
+ if (branch === repo.end_sha && backup === null) {
1110
+ if (repo.publish && remote !== repo.remote.old_sha) {
1111
+ problems.push(`${repo.id}: remote/local state is a recoverable partial combination`);
1112
+ } else {
1113
+ try {
1114
+ revalidateLocal(projectRoot, state, repo);
1115
+ repo.status = 'planned';
1116
+ repo.new_head = null;
1117
+ repo.new_tree = null;
1118
+ } catch (error) {
1119
+ problems.push(`${repo.id}: ${sanitize(error.message, projectRoot)}`);
1120
+ }
1121
+ }
1122
+ } else if (branch === repo.new_head && backup === repo.end_sha) {
1123
+ try {
1124
+ verifyApplied(projectRoot, state, repo);
1125
+ if (!repo.publish) repo.status = 'local-only';
1126
+ else if (remote === repo.new_head) repo.status = 'published';
1127
+ else if (remote === repo.remote.old_sha) repo.status = 'local-verified';
1128
+ else problems.push(`${repo.id}: remote branch has unknown drift`);
1129
+ } catch (error) {
1130
+ problems.push(`${repo.id}: ${sanitize(error.message, projectRoot)}`);
1131
+ }
1132
+ } else {
1133
+ problems.push(`${repo.id}: branch/backup refs have unknown drift`);
1134
+ }
1135
+ }
1136
+ observed.push({ repository: repo.id, branch, backup, remote });
1137
+ }
1138
+ if (problems.length) {
1139
+ state.error = problems.join('; ');
1140
+ state.phase = 'blocked-partial';
1141
+ state.next_action = 'blocked';
1142
+ state.updated_at = now();
1143
+ persist(stateRoot, state);
1144
+ throw new SquashError(`saved run is blocked: ${state.error}`);
1145
+ }
1146
+ state.error = null;
1147
+ for (const repo of state.repositories) repo.error = null;
1148
+ refreshNext(state);
1149
+ persist(stateRoot, state);
1150
+ return { change: state.change, phase: state.phase, next_action: state.next_action, plan_digest: state.local_digest, publish_digest: state.publish_digest, observed };
1151
+ }
1152
+
1153
+ export function main(argv = process.argv.slice(2)) {
1154
+ const { operation, values } = parseArgs(argv);
1155
+ let output;
1156
+ if (operation === 'plan') output = plan(values);
1157
+ else if (operation === 'apply') output = apply(values);
1158
+ else if (operation === 'publish') output = publish(values);
1159
+ else if (operation === 'status') output = status(values);
1160
+ else throw new SquashError(`unknown operation: ${operation}`);
1161
+ console.log(JSON.stringify(output));
1162
+ }
1163
+
1164
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
1165
+ try {
1166
+ main();
1167
+ } catch (error) {
1168
+ console.error(`git-history-squash: ${sanitize(error.message)}`);
1169
+ process.exit(2);
1170
+ }
1171
+ }