@kontextmind/kxm 0.7.120 → 0.7.122

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.
@@ -0,0 +1,971 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Landing stages for one KXM branch. Each stage prints one JSON line
4
+ * {stage, ok, code?, detail?} and the process exits 0, 1 (refused), or 2 (usage).
5
+ * gh and git are argv arrays. GIT_* is stripped from every child.
6
+ * Writes only inside the repo and .kxm/logs/.
7
+ */
8
+
9
+ import { spawnSync } from "node:child_process";
10
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const STAGES = Object.freeze([
15
+ "verify",
16
+ "docs",
17
+ "push",
18
+ "pr",
19
+ "rebase",
20
+ "unblock",
21
+ "merge",
22
+ "release",
23
+ "milestone",
24
+ ]);
25
+
26
+ const VERIFY_FRESH_MS = 30 * 60 * 1000;
27
+ const REBASE_ROUNDS = 5;
28
+ const POLL_MS = positiveInt(process.env.KXM_LAND_POLL_MS, 30_000);
29
+ const MERGE_WAIT_MS = 20 * 60 * 1000;
30
+ const RELEASE_WAIT_MS = 20 * 60 * 1000;
31
+ const PUBLISH_WAIT_MS = 10 * 60 * 1000;
32
+ const DOCS_COMMIT = "docs(roadmap): regenerate after verify";
33
+ const GENERATOR = "plans/kxm-roadmap/update-dashboard.mjs";
34
+ const STATE_FILE = "plans/kxm-roadmap/state.json";
35
+ const PHASE_SNAPSHOT = ".kxm/logs/land-phases-before.json";
36
+ const RELEASE_CONTEXT = ".kxm/logs/land-release-context.json";
37
+ const LANDED_HEADING = "### Landed in this tree (unreleased)";
38
+ const UNRELEASED_HEADING = "## Unreleased";
39
+
40
+ const root = process.cwd();
41
+ let dryRun = false;
42
+ let bodyFile;
43
+ let prNumber;
44
+ let forceLease = false;
45
+ let phasesSnapshotted = false;
46
+
47
+ function positiveInt(value, fallback) {
48
+ const parsed = Number(value);
49
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
50
+ }
51
+
52
+ function childEnv() {
53
+ const env = {};
54
+ for (const [name, value] of Object.entries(process.env)) {
55
+ if (value === undefined) continue;
56
+ if (name.toUpperCase().startsWith("GIT_")) continue;
57
+ env[name] = value;
58
+ }
59
+ return env;
60
+ }
61
+
62
+ function run(command, args, timeout = 120_000) {
63
+ const result = spawnSync(command, args, {
64
+ cwd: root,
65
+ encoding: "utf8",
66
+ env: childEnv(),
67
+ timeout,
68
+ windowsHide: true,
69
+ });
70
+ return {
71
+ status: result.status ?? 1,
72
+ stdout: result.stdout ?? "",
73
+ stderr: `${result.stderr ?? ""}${result.error ? result.error.message : ""}`,
74
+ };
75
+ }
76
+
77
+ function safeDetail(text) {
78
+ return String(text ?? "")
79
+ .replace(/ghp_[A-Za-z0-9]+/g, "[redacted]")
80
+ .replace(/github_pat_[A-Za-z0-9_]+/g, "[redacted]")
81
+ .replace(/Bearer\s+\S+/gi, "Bearer [redacted]")
82
+ .replace(/\s+/g, " ")
83
+ .trim()
84
+ .slice(0, 500);
85
+ }
86
+
87
+ function emit(payload) {
88
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
89
+ }
90
+
91
+ function pass(stage, extra = {}) {
92
+ emit({ stage, ok: true, ...extra });
93
+ return 0;
94
+ }
95
+
96
+ function refuse(stage, code, detail) {
97
+ const payload = { stage, ok: false, code };
98
+ if (detail) payload.detail = safeDetail(detail);
99
+ emit(payload);
100
+ return 1;
101
+ }
102
+
103
+ function usage(detail) {
104
+ emit({ stage: "usage", ok: false, code: "usage", detail });
105
+ return 2;
106
+ }
107
+
108
+ function plan(stage, commands, detail) {
109
+ const payload = { stage, ok: true, dryRun: true, plan: commands };
110
+ if (detail) payload.detail = detail;
111
+ emit(payload);
112
+ return 0;
113
+ }
114
+
115
+ function sleep(ms) {
116
+ if (ms <= 0) return;
117
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
118
+ }
119
+
120
+ function gitText(args) {
121
+ const result = run("git", args);
122
+ return result.status === 0 ? result.stdout.trim() : "";
123
+ }
124
+
125
+ function branchName() {
126
+ const name = gitText(["rev-parse", "--abbrev-ref", "HEAD"]);
127
+ return name && name !== "HEAD" ? name : "";
128
+ }
129
+
130
+ function treeHash() {
131
+ return gitText(["rev-parse", "HEAD^{tree}"]);
132
+ }
133
+
134
+ function porcelain() {
135
+ const result = run("git", ["status", "--porcelain"]);
136
+ if (result.status !== 0) return { ok: false, detail: result.stderr, paths: [] };
137
+ const paths = [];
138
+ for (const line of result.stdout.split("\n")) {
139
+ if (line.length < 4) continue;
140
+ const rest = line.slice(3);
141
+ const renamed = rest.split(" -> ");
142
+ let path = renamed[renamed.length - 1] ?? rest;
143
+ if (path.startsWith('"') && path.endsWith('"')) {
144
+ path = path.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
145
+ }
146
+ paths.push(path);
147
+ }
148
+ return { ok: true, paths };
149
+ }
150
+
151
+ function gh(args, timeout = 120_000) {
152
+ return run("gh", args, timeout);
153
+ }
154
+
155
+ function ghJson(args) {
156
+ const result = gh(args);
157
+ if (result.status !== 0) return { ok: false, result };
158
+ try {
159
+ return { ok: true, value: JSON.parse(result.stdout) };
160
+ } catch {
161
+ return { ok: false, result };
162
+ }
163
+ }
164
+
165
+ function logsDir() {
166
+ const dir = join(root, ".kxm", "logs");
167
+ mkdirSync(dir, { recursive: true });
168
+ return dir;
169
+ }
170
+
171
+ function readJson(path) {
172
+ try {
173
+ return JSON.parse(readFileSync(path, "utf8"));
174
+ } catch {
175
+ return undefined;
176
+ }
177
+ }
178
+
179
+ function writeLog(name, value) {
180
+ const path = join(logsDir(), name);
181
+ writeFileSync(path, `${JSON.stringify(value)}\n`, { encoding: "utf8", mode: 0o600 });
182
+ return path;
183
+ }
184
+
185
+ function parseArgs(argv) {
186
+ const options = { json: false, dryRun: false };
187
+ for (let index = 0; index < argv.length; index += 1) {
188
+ const arg = argv[index];
189
+ if (arg === "--json") {
190
+ options.json = true;
191
+ continue;
192
+ }
193
+ if (arg === "--dry-run") {
194
+ options.dryRun = true;
195
+ continue;
196
+ }
197
+ if (arg === "--help" || arg === "-h") {
198
+ options.help = true;
199
+ continue;
200
+ }
201
+ if (arg === "--pr" || arg === "--body-file" || arg === "--stage") {
202
+ const value = argv[index + 1];
203
+ if (value === undefined || value.startsWith("--")) return { error: `${arg} requires a value` };
204
+ index += 1;
205
+ if (arg === "--pr") options.pr = value;
206
+ if (arg === "--body-file") options.bodyFile = value;
207
+ if (arg === "--stage") options.stage = value;
208
+ continue;
209
+ }
210
+ return { error: `unknown argument ${arg}` };
211
+ }
212
+ if (options.pr !== undefined && !/^[0-9]+$/.test(options.pr)) return { error: "--pr expects a pull request number" };
213
+ if (options.stage !== undefined && !STAGES.includes(options.stage)) return { error: `unknown stage ${options.stage}` };
214
+ return { options };
215
+ }
216
+
217
+ function ensurePr() {
218
+ if (prNumber) {
219
+ const viewed = ghJson(["pr", "view", prNumber, "--json", "number,title,id,body"]);
220
+ if (!viewed.ok) return { ok: false, detail: viewed.result.stderr || viewed.result.stdout };
221
+ return { ok: true, pr: viewed.value };
222
+ }
223
+ const branch = branchName();
224
+ if (!branch) return { ok: false, detail: "cannot resolve the current branch" };
225
+ const listed = ghJson(["pr", "list", "--head", branch, "--state", "open", "--json", "number,title"]);
226
+ if (!listed.ok) return { ok: false, detail: listed.result.stderr || listed.result.stdout };
227
+ const rows = Array.isArray(listed.value) ? listed.value : [];
228
+ if (rows.length === 0) return { ok: true, pr: undefined, branch };
229
+ const first = rows[0];
230
+ prNumber = String(first.number);
231
+ const viewed = ghJson(["pr", "view", prNumber, "--json", "number,title,id,body"]);
232
+ if (!viewed.ok) return { ok: true, pr: { number: first.number, title: first.title }, branch };
233
+ return { ok: true, pr: viewed.value, branch };
234
+ }
235
+
236
+ function verifyStage() {
237
+ if (dryRun) {
238
+ return plan("verify", [
239
+ "git status --porcelain",
240
+ "git rev-parse HEAD^{tree}",
241
+ "npm run verify",
242
+ ], "npm run verify is the first stage and is not replaced");
243
+ }
244
+ const status = porcelain();
245
+ if (!status.ok) return refuse("verify", "land_verify_failed", status.detail);
246
+ if (status.paths.length > 0) {
247
+ return refuse("verify", "land_dirty_tree", `working tree is not clean: ${status.paths.join(", ")}`);
248
+ }
249
+ const tree = treeHash();
250
+ if (!tree) return refuse("verify", "land_verify_failed", "git rev-parse HEAD^{tree} failed");
251
+ const receiptName = `land-verify-${tree}.json`;
252
+ const receiptPath = join(root, ".kxm", "logs", receiptName);
253
+ if (existsSync(receiptPath)) {
254
+ const age = Date.now() - statSync(receiptPath).mtimeMs;
255
+ const prior = readJson(receiptPath);
256
+ if (age >= 0 && age < VERIFY_FRESH_MS && prior && prior.ok === true && prior.tree === tree) {
257
+ return pass("verify", { detail: "verify: skipped (receipt fresh)", tree });
258
+ }
259
+ }
260
+ const verified = run("npm", ["run", "verify"], 3_600_000);
261
+ if (verified.status !== 0) {
262
+ return refuse("verify", "land_verify_failed", verified.stderr || verified.stdout);
263
+ }
264
+ writeLog(receiptName, { stage: "verify", ok: true, tree, finishedAt: new Date().toISOString() });
265
+ return pass("verify", { detail: "verify: passed", tree });
266
+ }
267
+
268
+ function docsAllowed(path) {
269
+ if (path === STATE_FILE) return false;
270
+ return path.startsWith("docs/roadmap/")
271
+ || path.startsWith("plans/kxm-roadmap/")
272
+ || path.startsWith("docs/architecture/");
273
+ }
274
+
275
+ function snapshotPhases() {
276
+ if (phasesSnapshotted) return;
277
+ phasesSnapshotted = true;
278
+ const statePath = join(root, STATE_FILE);
279
+ if (!existsSync(statePath)) return;
280
+ const raw = readFileSync(statePath, "utf8");
281
+ mkdirSync(join(root, ".kxm", "logs"), { recursive: true });
282
+ writeFileSync(join(root, PHASE_SNAPSHOT), raw.endsWith("\n") ? raw : `${raw}\n`, { encoding: "utf8", mode: 0o600 });
283
+ }
284
+
285
+ function docsStage() {
286
+ if (dryRun) {
287
+ return plan("docs", [
288
+ `node ${GENERATOR}`,
289
+ `git commit -m ${DOCS_COMMIT}`,
290
+ ], "skipped when the generator is absent; never edits state.json");
291
+ }
292
+ snapshotPhases();
293
+ const generator = join(root, GENERATOR);
294
+ if (!existsSync(generator)) {
295
+ return pass("docs", { detail: "docs: skipped (generator absent)" });
296
+ }
297
+ const generated = run(process.execPath, [generator], 600_000);
298
+ if (generated.status !== 0) {
299
+ return refuse("docs", "land_docs_failed", generated.stderr || generated.stdout);
300
+ }
301
+ const status = porcelain();
302
+ if (!status.ok) return refuse("docs", "land_docs_failed", status.detail);
303
+ if (status.paths.length === 0) return pass("docs", { detail: "docs: no generated changes" });
304
+ const blocked = status.paths.filter((path) => !docsAllowed(path));
305
+ if (blocked.length > 0) {
306
+ return refuse("docs", "land_docs_failed", `unexpected path ${blocked.join(", ")}`);
307
+ }
308
+ const added = run("git", ["add", "--", ...status.paths]);
309
+ if (added.status !== 0) return refuse("docs", "land_docs_failed", added.stderr);
310
+ const committed = run("git", ["commit", "-m", DOCS_COMMIT]);
311
+ if (committed.status !== 0) return refuse("docs", "land_docs_failed", committed.stderr || committed.stdout);
312
+ return pass("docs", { detail: DOCS_COMMIT });
313
+ }
314
+
315
+ function pushStage() {
316
+ const branch = branchName();
317
+ const args = ["push"];
318
+ if (forceLease) args.push("--force-with-lease");
319
+ args.push("-u", "origin", branch || "<branch>");
320
+ if (dryRun) return plan("push", [`git ${args.join(" ")}`]);
321
+ if (!branch) return refuse("push", "land_push_rejected", "cannot resolve the current branch");
322
+ const pushed = run("git", ["push", ...(forceLease ? ["--force-with-lease"] : []), "-u", "origin", branch]);
323
+ if (pushed.status !== 0) return refuse("push", "land_push_rejected", pushed.stderr || pushed.stdout);
324
+ return pass("push", { detail: forceLease ? "pushed with lease" : "pushed" });
325
+ }
326
+
327
+ function prStage() {
328
+ if (dryRun) {
329
+ return plan("pr", [
330
+ "gh pr list --head <branch> --state open --json number,title",
331
+ "gh pr create --body-file <path>",
332
+ ]);
333
+ }
334
+ const found = ensurePr();
335
+ if (!found.ok) return refuse("pr", "land_pr_body_missing", found.detail);
336
+ if (found.pr?.number) {
337
+ prNumber = String(found.pr.number);
338
+ return pass("pr", { detail: `reused #${prNumber}` });
339
+ }
340
+ if (!bodyFile) return refuse("pr", "land_pr_body_missing", "creating a pull request requires --body-file");
341
+ if (!existsSync(bodyFile.startsWith("/") ? bodyFile : join(root, bodyFile))) {
342
+ return refuse("pr", "land_pr_body_missing", `body file not found: ${bodyFile}`);
343
+ }
344
+ const branch = found.branch || branchName();
345
+ const created = gh(["pr", "create", "--head", branch, "--title", branch, "--body-file", bodyFile]);
346
+ if (created.status !== 0) return refuse("pr", "land_pr_body_missing", created.stderr || created.stdout);
347
+ const again = ensurePr();
348
+ if (!again.ok || !again.pr?.number) return refuse("pr", "land_pr_body_missing", "pull request was not created");
349
+ prNumber = String(again.pr.number);
350
+ return pass("pr", { detail: `created #${prNumber}` });
351
+ }
352
+
353
+ function bulletKey(bullet) {
354
+ return bullet.replace(/\s+/g, " ").trim();
355
+ }
356
+
357
+ function parseGroups(body) {
358
+ const groups = [];
359
+ let heading = "";
360
+ let bullets = [];
361
+ let current = null;
362
+ const flushBullet = () => {
363
+ if (!current) return;
364
+ bullets.push(current.join("\n").replace(/\n+$/g, ""));
365
+ current = null;
366
+ };
367
+ const flushGroup = () => {
368
+ flushBullet();
369
+ if (heading || bullets.length > 0) groups.push({ heading, bullets });
370
+ heading = "";
371
+ bullets = [];
372
+ };
373
+ for (const line of body.split("\n")) {
374
+ if (/^### /.test(line)) {
375
+ flushGroup();
376
+ heading = line.trim();
377
+ continue;
378
+ }
379
+ if (/^- /.test(line)) {
380
+ flushBullet();
381
+ current = [line];
382
+ continue;
383
+ }
384
+ if (current && (/^[ \t]/.test(line))) {
385
+ current.push(line);
386
+ continue;
387
+ }
388
+ flushBullet();
389
+ }
390
+ flushGroup();
391
+ return groups;
392
+ }
393
+
394
+ function combineBullets(ours, theirs, newestFirst) {
395
+ const seen = new Set();
396
+ const ordered = [];
397
+ for (const bullet of [...ours, ...theirs]) {
398
+ const key = bulletKey(bullet);
399
+ if (!key || seen.has(key)) continue;
400
+ seen.add(key);
401
+ ordered.push(bullet.trim());
402
+ }
403
+ if (!newestFirst) return ordered;
404
+ return ordered
405
+ .map((bullet, index) => ({ bullet, index, date: (bullet.match(/\d{4}-\d{2}-\d{2}/) ?? [""])[0] }))
406
+ .sort((left, right) => {
407
+ if (left.date === right.date) return left.index - right.index;
408
+ if (!left.date) return 1;
409
+ if (!right.date) return -1;
410
+ return left.date < right.date ? 1 : -1;
411
+ })
412
+ .map((item) => item.bullet);
413
+ }
414
+
415
+ function renderGroups(groups) {
416
+ const lines = [""];
417
+ for (const group of groups) {
418
+ if (group.bullets.length === 0 && !group.heading) continue;
419
+ if (group.heading) lines.push(group.heading, "");
420
+ for (const bullet of group.bullets) {
421
+ lines.push(bullet, "");
422
+ }
423
+ }
424
+ return `${lines.join("\n").replace(/\n+$/g, "\n")}`;
425
+ }
426
+
427
+ function sliceHeading(text, heading, endPattern) {
428
+ const lines = String(text ?? "").split("\n");
429
+ const start = lines.findIndex((line) => line.trim() === heading);
430
+ if (start < 0) return undefined;
431
+ let end = lines.length;
432
+ for (let index = start + 1; index < lines.length; index += 1) {
433
+ if (endPattern.test(lines[index] ?? "")) {
434
+ end = index;
435
+ break;
436
+ }
437
+ }
438
+ return {
439
+ before: lines.slice(0, start).join("\n"),
440
+ body: lines.slice(start + 1, end).join("\n"),
441
+ after: lines.slice(end).join("\n"),
442
+ };
443
+ }
444
+
445
+ function unionSection(ours, theirs, heading, newestFirst, endPattern) {
446
+ const own = sliceHeading(ours, heading, endPattern);
447
+ const other = sliceHeading(theirs, heading, endPattern);
448
+ if (!own && !other) return ours;
449
+ const base = own ?? { before: String(ours ?? "").replace(/\s*$/g, ""), body: "", after: "" };
450
+ const theirsBody = other?.body ?? "";
451
+ const ownGroups = parseGroups(base.body);
452
+ const theirGroups = parseGroups(theirsBody);
453
+ const seen = new Set();
454
+ const merged = [];
455
+ for (const group of ownGroups) {
456
+ seen.add(group.heading);
457
+ const match = theirGroups.find((item) => item.heading === group.heading);
458
+ merged.push({
459
+ heading: group.heading,
460
+ bullets: combineBullets(group.bullets, match?.bullets ?? [], newestFirst),
461
+ });
462
+ }
463
+ for (const group of theirGroups) {
464
+ if (seen.has(group.heading)) continue;
465
+ merged.push({ heading: group.heading, bullets: combineBullets([], group.bullets, newestFirst) });
466
+ }
467
+ const before = base.before.length > 0 ? `${base.before}\n` : "";
468
+ const after = base.after.length > 0 ? `\n${base.after}` : "";
469
+ const rendered = renderGroups(merged);
470
+ return `${before}${heading}\n${rendered}${after}`;
471
+ }
472
+
473
+ /** Unreleased bullets per heading, ours first, duplicates dropped. */
474
+ export function unionChangelogUnreleased(ours, theirs) {
475
+ return unionSection(ours, theirs, UNRELEASED_HEADING, false, /^## /);
476
+ }
477
+
478
+ /** "Landed in this tree (unreleased)" bullets, newest ISO date first. */
479
+ export function unionLandedTracker(ours, theirs) {
480
+ return unionSection(ours, theirs, LANDED_HEADING, true, /^#{2,3} /);
481
+ }
482
+
483
+ function showFile(rev, path) {
484
+ const result = run("git", ["show", `${rev}:${path}`]);
485
+ return result.status === 0 ? result.stdout : undefined;
486
+ }
487
+
488
+ function unmergedPaths() {
489
+ const result = run("git", ["diff", "--name-only", "--diff-filter=U"]);
490
+ if (result.status !== 0) return [];
491
+ return result.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
492
+ }
493
+
494
+ function abortRebase() {
495
+ if (run("git", ["rev-parse", "--verify", "--quiet", "REBASE_HEAD"]).status === 0) {
496
+ run("git", ["rebase", "--abort"]);
497
+ }
498
+ }
499
+
500
+ function resolveConflicts() {
501
+ const unresolved = [];
502
+ let dist = false;
503
+ let guard = 0;
504
+ while (guard < 20) {
505
+ guard += 1;
506
+ const paths = unmergedPaths();
507
+ if (paths.length === 0) {
508
+ const continued = run("git", ["-c", "core.editor=true", "-c", "sequence.editor=true", "rebase", "--continue"]);
509
+ if (continued.status === 0) return { ok: true, paths: [] };
510
+ if (unmergedPaths().length === 0) return { ok: false, paths: unresolved, detail: continued.stderr };
511
+ }
512
+ const pending = unmergedPaths();
513
+ dist = false;
514
+ const manuals = [];
515
+ for (const path of pending) {
516
+ if (path === "plugins/kxm/dist" || path.startsWith("plugins/kxm/dist/")) {
517
+ dist = true;
518
+ continue;
519
+ }
520
+ if (path === "CHANGELOG.md" || path === "plans/implementation-plan.md") continue;
521
+ manuals.push(path);
522
+ }
523
+ if (manuals.length > 0) return { ok: false, paths: manuals };
524
+ if (dist) {
525
+ const checked = run("git", ["checkout", "origin/main", "--", "plugins/kxm/dist"]);
526
+ if (checked.status !== 0) return { ok: false, paths: ["plugins/kxm/dist"], detail: checked.stderr };
527
+ const built = run("npm", ["run", "build"], 3_600_000);
528
+ if (built.status !== 0) return { ok: false, paths: ["plugins/kxm/dist"], detail: built.stderr || built.stdout };
529
+ const staged = run("git", ["add", "--", "plugins/kxm/dist"]);
530
+ if (staged.status !== 0) return { ok: false, paths: ["plugins/kxm/dist"], detail: staged.stderr };
531
+ }
532
+ for (const path of pending) {
533
+ if (path !== "CHANGELOG.md" && path !== "plans/implementation-plan.md") continue;
534
+ const oursRev = run("git", ["rev-parse", "--verify", "--quiet", "REBASE_HEAD"]).status === 0 ? "REBASE_HEAD" : "HEAD";
535
+ const ours = showFile(oursRev, path);
536
+ const theirs = showFile("origin/main", path);
537
+ if (ours === undefined || theirs === undefined) return { ok: false, paths: [path] };
538
+ const merged = path === "CHANGELOG.md"
539
+ ? unionChangelogUnreleased(ours, theirs)
540
+ : unionLandedTracker(ours, theirs);
541
+ writeFileSync(join(root, path), merged.endsWith("\n") ? merged : `${merged}\n`);
542
+ const staged = run("git", ["add", "--", path]);
543
+ if (staged.status !== 0) return { ok: false, paths: [path], detail: staged.stderr };
544
+ }
545
+ const continued = run("git", ["-c", "core.editor=true", "-c", "sequence.editor=true", "rebase", "--continue"]);
546
+ if (continued.status === 0) return { ok: true, paths: [] };
547
+ if (unmergedPaths().length === 0) return { ok: false, paths: pending, detail: continued.stderr };
548
+ }
549
+ return { ok: false, paths: unmergedPaths() };
550
+ }
551
+
552
+ function mergeState() {
553
+ const found = ensurePr();
554
+ if (!found.ok) return { ok: false, detail: found.detail };
555
+ if (!found.pr?.number) return { ok: true, status: "ABSENT" };
556
+ prNumber = String(found.pr.number);
557
+ const viewed = ghJson(["pr", "view", prNumber, "--json", "mergeStateStatus,title,number"]);
558
+ if (!viewed.ok) return { ok: false, detail: viewed.result.stderr || viewed.result.stdout };
559
+ return { ok: true, status: String(viewed.value.mergeStateStatus ?? ""), title: viewed.value.title };
560
+ }
561
+
562
+ function rebaseStage() {
563
+ if (dryRun) {
564
+ return plan("rebase", [
565
+ "gh pr view --json mergeStateStatus",
566
+ "git fetch origin",
567
+ "git rebase origin/main",
568
+ "git merge-tree --write-tree origin/main HEAD",
569
+ "git push --force-with-lease -u origin <branch>",
570
+ ], `at most ${REBASE_ROUNDS} rounds`);
571
+ }
572
+ const branch = branchName();
573
+ if (branch === "main") return refuse("rebase", "land_conflict_manual", "refusing to rebase branch main");
574
+ const manual = [];
575
+ for (let round = 1; round <= REBASE_ROUNDS; round += 1) {
576
+ const state = mergeState();
577
+ if (!state.ok) return refuse("rebase", "land_conflict_manual", state.detail);
578
+ if (state.status !== "BEHIND" && state.status !== "DIRTY") {
579
+ return pass("rebase", { detail: `mergeStateStatus ${state.status || "absent"}` });
580
+ }
581
+ const before = treeHash();
582
+ const clean = run("git", ["merge-tree", "--write-tree", "origin/main", "HEAD"]).status === 0;
583
+ const fetched = run("git", ["fetch", "origin"]);
584
+ if (fetched.status !== 0) return refuse("rebase", "land_conflict_manual", fetched.stderr || fetched.stdout);
585
+ const rebased = run("git", ["rebase", "origin/main"]);
586
+ if (rebased.status !== 0) {
587
+ const resolved = resolveConflicts();
588
+ if (!resolved.ok) {
589
+ for (const path of resolved.paths) manual.push(path);
590
+ abortRebase();
591
+ if (round === REBASE_ROUNDS) {
592
+ return refuse("rebase", "land_conflict_manual", manual.join(", ") || resolved.detail || "unresolved conflict");
593
+ }
594
+ continue;
595
+ }
596
+ }
597
+ const after = treeHash();
598
+ if (before && after && before !== after) {
599
+ const docsCode = docsStage();
600
+ if (docsCode !== 0) return docsCode;
601
+ }
602
+ if (!clean) {
603
+ const verifyCode = verifyStage();
604
+ if (verifyCode !== 0) return verifyCode;
605
+ }
606
+ forceLease = true;
607
+ const pushCode = pushStage();
608
+ if (pushCode !== 0) return pushCode;
609
+ }
610
+ const state = mergeState();
611
+ if (state.ok && state.status !== "BEHIND" && state.status !== "DIRTY") {
612
+ return pass("rebase", { detail: `mergeStateStatus ${state.status}` });
613
+ }
614
+ const paths = [...new Set(manual)];
615
+ const detail = paths.length > 0 ? paths.join(", ") : "still BEHIND or DIRTY after 5 rounds";
616
+ return refuse("rebase", "land_conflict_manual", detail);
617
+ }
618
+
619
+ function failedRunId(rollup) {
620
+ if (!Array.isArray(rollup)) return undefined;
621
+ for (const item of rollup) {
622
+ if (!item || typeof item !== "object") continue;
623
+ const conclusion = String(item.conclusion ?? "").toUpperCase();
624
+ const status = String(item.status ?? "").toUpperCase();
625
+ const failed = conclusion === "FAILURE" || conclusion === "FAILED" || conclusion === "CANCELLED" || status === "FAILURE";
626
+ if (!failed) continue;
627
+ const url = String(item.detailsUrl ?? item.url ?? "");
628
+ const match = url.match(/\/actions\/runs\/(\d+)/);
629
+ if (match) return match[1];
630
+ if (item.databaseId !== undefined) return String(item.databaseId);
631
+ return "";
632
+ }
633
+ return undefined;
634
+ }
635
+
636
+ function readChecks() {
637
+ const found = ensurePr();
638
+ if (!found.ok) return { ok: false, detail: found.detail };
639
+ if (!found.pr?.number) return { ok: false, detail: "no open pull request" };
640
+ prNumber = String(found.pr.number);
641
+ const viewed = ghJson(["pr", "view", prNumber, "--json", "statusCheckRollup,reviewDecision"]);
642
+ if (!viewed.ok) return { ok: false, detail: viewed.result.stderr || viewed.result.stdout };
643
+ return {
644
+ ok: true,
645
+ reviewDecision: String(viewed.value.reviewDecision ?? ""),
646
+ rollup: viewed.value.statusCheckRollup,
647
+ };
648
+ }
649
+
650
+ function unblockStage() {
651
+ if (dryRun) {
652
+ return plan("unblock", [
653
+ "gh pr view --json statusCheckRollup,reviewDecision",
654
+ "gh run rerun --failed <id>",
655
+ ], "a required review is reported, not bypassed");
656
+ }
657
+ const first = readChecks();
658
+ if (!first.ok) return refuse("unblock", "land_blocked", first.detail);
659
+ if (first.reviewDecision === "REVIEW_REQUIRED" || first.reviewDecision === "CHANGES_REQUESTED") {
660
+ return refuse("unblock", "land_blocked", `reviewDecision ${first.reviewDecision}`);
661
+ }
662
+ const runId = failedRunId(first.rollup);
663
+ if (runId === undefined) return pass("unblock", { detail: "checks clear" });
664
+ if (!runId) return refuse("unblock", "land_blocked", "failed check has no run id");
665
+ const rerun = gh(["run", "rerun", "--failed", runId]);
666
+ if (rerun.status !== 0) return refuse("unblock", "land_blocked", rerun.stderr || rerun.stdout);
667
+ const second = readChecks();
668
+ if (!second.ok) return refuse("unblock", "land_blocked", second.detail);
669
+ if (second.reviewDecision === "REVIEW_REQUIRED" || second.reviewDecision === "CHANGES_REQUESTED") {
670
+ return refuse("unblock", "land_blocked", `reviewDecision ${second.reviewDecision}`);
671
+ }
672
+ if (failedRunId(second.rollup) !== undefined) {
673
+ return refuse("unblock", "land_blocked", "failed check remained after one rerun");
674
+ }
675
+ return pass("unblock", { detail: `reran ${runId}` });
676
+ }
677
+
678
+ function nameWithOwner() {
679
+ const viewed = ghJson(["repo", "view", "--json", "nameWithOwner"]);
680
+ if (!viewed.ok || typeof viewed.value.nameWithOwner !== "string" || !viewed.value.nameWithOwner.includes("/")) {
681
+ return undefined;
682
+ }
683
+ return viewed.value.nameWithOwner;
684
+ }
685
+
686
+ function versionCompare(left, right) {
687
+ const split = (value) => value.replace(/^v/, "").split(/(\d+)/).filter(Boolean);
688
+ const a = split(left);
689
+ const b = split(right);
690
+ const count = Math.max(a.length, b.length);
691
+ for (let index = 0; index < count; index += 1) {
692
+ const x = a[index] ?? "";
693
+ const y = b[index] ?? "";
694
+ const xn = /^\d+$/.test(x);
695
+ const yn = /^\d+$/.test(y);
696
+ if (xn && yn) {
697
+ const delta = Number(x) - Number(y);
698
+ if (delta !== 0) return delta;
699
+ } else if (x !== y) {
700
+ return x < y ? -1 : 1;
701
+ }
702
+ }
703
+ return 0;
704
+ }
705
+
706
+ function newestTag() {
707
+ const listed = run("git", ["ls-remote", "--tags", "origin"]);
708
+ if (listed.status !== 0) return { ok: false, detail: listed.stderr || listed.stdout };
709
+ const tags = [];
710
+ for (const line of listed.stdout.split("\n")) {
711
+ if (!line.trim() || line.includes("^{}")) continue;
712
+ const ref = line.split("\t")[1] ?? "";
713
+ const name = ref.replace(/^refs\/tags\//, "");
714
+ if (name.startsWith("v")) tags.push(name);
715
+ }
716
+ tags.sort(versionCompare);
717
+ return { ok: true, tag: tags.length > 0 ? tags[tags.length - 1] : "" };
718
+ }
719
+
720
+ function readReleaseContext() {
721
+ return readJson(join(root, RELEASE_CONTEXT)) ?? {};
722
+ }
723
+
724
+ function writeReleaseContext(patch) {
725
+ const next = { ...readReleaseContext(), ...patch };
726
+ writeLog("land-release-context.json", next);
727
+ }
728
+
729
+ function poll(waitMs, read) {
730
+ const attempts = Math.max(1, Math.ceil(waitMs / Math.max(POLL_MS, 1)));
731
+ let last;
732
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
733
+ last = read();
734
+ if (last.done) return last;
735
+ if (attempt < attempts - 1) sleep(POLL_MS);
736
+ }
737
+ return last ?? { done: false };
738
+ }
739
+
740
+ function mergeStage() {
741
+ if (dryRun) {
742
+ return plan("merge", [
743
+ "gh api graphql enablePullRequestAutoMerge mergeMethod SQUASH",
744
+ "gh api -X PUT repos/{owner}/{repo}/pulls/{n}/merge -f merge_method=squash",
745
+ "gh pr view --json state",
746
+ ], "squash only; poll up to 20 minutes");
747
+ }
748
+ const found = ensurePr();
749
+ if (!found.ok || !found.pr?.number) return refuse("merge", "land_merge_failed", found.detail || "no open pull request");
750
+ prNumber = String(found.pr.number);
751
+ const title = String(found.pr.title ?? branchName());
752
+ const nodeId = String(found.pr.id ?? "");
753
+ const tags = newestTag();
754
+ writeReleaseContext({ tag: tags.ok ? tags.tag : "", title, recordedAt: new Date().toISOString() });
755
+ const ownerRepo = nameWithOwner();
756
+ if (!ownerRepo) return refuse("merge", "land_merge_failed", "gh repo view did not return nameWithOwner");
757
+ const query = "mutation($pullRequestId:ID!,$mergeMethod:PullRequestMergeMethod!){enablePullRequestAutoMerge(input:{pullRequestId:$pullRequestId,mergeMethod:$mergeMethod}){pullRequest{number}}}";
758
+ const auto = gh(["api", "graphql", "-f", `query=${query}`, "-f", `pullRequestId=${nodeId}`, "-f", "mergeMethod=SQUASH"]);
759
+ const autoText = `${auto.stdout}\n${auto.stderr}`;
760
+ if (auto.status !== 0 && /clean status/i.test(autoText)) {
761
+ const merged = gh([
762
+ "api",
763
+ "-X",
764
+ "PUT",
765
+ `repos/${ownerRepo}/pulls/${prNumber}/merge`,
766
+ "-f",
767
+ "merge_method=squash",
768
+ "-f",
769
+ `commit_title=${title} (#${prNumber})`,
770
+ ]);
771
+ if (merged.status !== 0) return refuse("merge", "land_merge_failed", merged.stderr || merged.stdout);
772
+ } else if (auto.status !== 0) {
773
+ return refuse("merge", "land_merge_failed", auto.stderr || auto.stdout);
774
+ }
775
+ const polled = poll(MERGE_WAIT_MS, () => {
776
+ const viewed = ghJson(["pr", "view", prNumber, "--json", "state"]);
777
+ if (viewed.ok && viewed.value.state === "MERGED") return { done: true, merged: true };
778
+ return { done: false };
779
+ });
780
+ if (!polled.merged) return refuse("merge", "land_merge_failed", "pull request was not MERGED within 20 minutes");
781
+ writeReleaseContext({ mergedAt: new Date().toISOString(), title });
782
+ return pass("merge", { detail: `squashed #${prNumber}` });
783
+ }
784
+
785
+ function selectRun(runs, title, after) {
786
+ if (!Array.isArray(runs)) return undefined;
787
+ const matches = runs.filter((runRow) => {
788
+ if (!runRow || typeof runRow !== "object") return false;
789
+ const display = String(runRow.displayTitle ?? "");
790
+ if (title && !display.includes(title)) return false;
791
+ const created = Date.parse(String(runRow.createdAt ?? ""));
792
+ if (Number.isFinite(after) && Number.isFinite(created) && created < after) return false;
793
+ return true;
794
+ });
795
+ matches.sort((left, right) => Date.parse(String(right.createdAt ?? "")) - Date.parse(String(left.createdAt ?? "")));
796
+ return matches[0];
797
+ }
798
+
799
+ function waitForWorkflow(workflow, title, after) {
800
+ let failure = "";
801
+ const polled = poll(RELEASE_WAIT_MS, () => {
802
+ const listed = ghJson(["run", "list", `--workflow=${workflow}`, "--json", "status,conclusion,displayTitle,createdAt"]);
803
+ if (!listed.ok) return { done: false, detail: listed.result.stderr || listed.result.stdout };
804
+ const match = selectRun(listed.value, title, after);
805
+ if (!match) return { done: false };
806
+ const conclusion = String(match.conclusion ?? "").toLowerCase();
807
+ const status = String(match.status ?? "").toLowerCase();
808
+ if (conclusion === "success") return { done: true, run: match };
809
+ if (conclusion && conclusion !== "success" && status === "completed") {
810
+ failure = `${workflow} concluded ${conclusion}`;
811
+ return { done: true, failed: true };
812
+ }
813
+ return { done: false };
814
+ });
815
+ if (polled.failed) return { ok: false, detail: failure };
816
+ if (!polled.run) return { ok: false, detail: `${workflow} did not succeed` };
817
+ return { ok: true, run: polled.run };
818
+ }
819
+
820
+ function releaseStage() {
821
+ if (dryRun) {
822
+ return plan("release", [
823
+ "gh run list --workflow=auto-release.yml --json status,conclusion,displayTitle,createdAt",
824
+ "git ls-remote --tags origin",
825
+ "gh run list --workflow=release.yml --json status,conclusion,displayTitle,createdAt",
826
+ "npm view @kontextmind/kxm@<version> version",
827
+ ], "npm poll is 10 minutes");
828
+ }
829
+ const context = readReleaseContext();
830
+ const found = ensurePr();
831
+ const title = String(context.title ?? found.pr?.title ?? "");
832
+ const after = Date.parse(String(context.mergedAt ?? ""));
833
+ const auto = waitForWorkflow("auto-release.yml", title, after);
834
+ if (!auto.ok) return refuse("release", "land_release_failed", auto.detail);
835
+ const tags = newestTag();
836
+ if (!tags.ok) return refuse("release", "land_release_failed", tags.detail);
837
+ const previous = String(context.tag ?? "");
838
+ if (!tags.tag || (previous && versionCompare(tags.tag, previous) <= 0)) {
839
+ return refuse("release", "land_release_failed", `tag ${tags.tag || "(none)"} is not newer than ${previous || "(none)"}`);
840
+ }
841
+ const release = waitForWorkflow("release.yml", title, after);
842
+ if (!release.ok) return refuse("release", "land_release_failed", release.detail);
843
+ const version = tags.tag.replace(/^v/, "");
844
+ const published = poll(PUBLISH_WAIT_MS, () => {
845
+ const viewed = run("npm", ["view", `@kontextmind/kxm@${version}`, "version"]);
846
+ if (viewed.status === 0 && viewed.stdout.trim() === version) return { done: true, version };
847
+ return { done: false };
848
+ });
849
+ if (!published.version) return refuse("release", "land_publish_timeout", `npm view @kontextmind/kxm@${version} version`);
850
+ return pass("release", { detail: `PUBLISHED ${published.version}` });
851
+ }
852
+
853
+ function taskStatuses(tasks) {
854
+ if (Array.isArray(tasks)) {
855
+ return tasks.map((task) => (task && typeof task === "object" && typeof task.status === "string" ? task.status : "open"));
856
+ }
857
+ if (tasks && typeof tasks === "object") {
858
+ return Object.values(tasks).map((task) => (task && typeof task === "object" && typeof task.status === "string" ? task.status : "open"));
859
+ }
860
+ return [];
861
+ }
862
+
863
+ function phaseList(value) {
864
+ const phases = value && typeof value === "object" ? value.phases : undefined;
865
+ const listed = [];
866
+ if (Array.isArray(phases)) {
867
+ for (const phase of phases) {
868
+ if (!phase || typeof phase !== "object") continue;
869
+ listed.push({ id: typeof phase.id === "string" ? phase.id : "", statuses: taskStatuses(phase.tasks) });
870
+ }
871
+ } else if (phases && typeof phases === "object") {
872
+ for (const [id, phase] of Object.entries(phases)) {
873
+ const body = phase && typeof phase === "object" ? phase : {};
874
+ listed.push({ id, statuses: taskStatuses(body.tasks) });
875
+ }
876
+ }
877
+ return listed;
878
+ }
879
+
880
+ function flippedPhase(before, after) {
881
+ const prior = new Map(before.map((phase) => [phase.id, phase]));
882
+ for (const phase of after) {
883
+ const previous = prior.get(phase.id);
884
+ if (!previous) continue;
885
+ const hadOpen = previous.statuses.some((status) => status !== "done");
886
+ const allDone = phase.statuses.length > 0 && phase.statuses.every((status) => status === "done");
887
+ if (hadOpen && allDone) return phase.id;
888
+ }
889
+ return undefined;
890
+ }
891
+
892
+ function milestoneText() {
893
+ if (bodyFile && existsSync(bodyFile.startsWith("/") ? bodyFile : join(root, bodyFile))) {
894
+ const text = readFileSync(bodyFile.startsWith("/") ? bodyFile : join(root, bodyFile), "utf8");
895
+ return text.match(/^Milestone:\s*(.+)\s*$/m)?.[1]?.trim();
896
+ }
897
+ const found = ensurePr();
898
+ const body = typeof found.pr?.body === "string" ? found.pr.body : "";
899
+ return body.match(/^Milestone:\s*(.+)\s*$/m)?.[1]?.trim();
900
+ }
901
+
902
+ function milestoneStage() {
903
+ if (dryRun) {
904
+ return plan("milestone", [
905
+ `read ${STATE_FILE}`,
906
+ "compare phases before and after docs",
907
+ ], "deep review is /reanalyze-roadmap, not this script");
908
+ }
909
+ const statePath = join(root, STATE_FILE);
910
+ if (!existsSync(statePath)) {
911
+ return pass("milestone", { detail: "milestone: skipped (state absent)", deep_review_required: false });
912
+ }
913
+ const afterValue = readJson(statePath);
914
+ if (!afterValue) return refuse("milestone", "land_milestone_failed", `${STATE_FILE} is not readable JSON`);
915
+ const beforePath = join(root, PHASE_SNAPSHOT);
916
+ const beforeValue = existsSync(beforePath) ? readJson(beforePath) : undefined;
917
+ const phase = beforeValue ? flippedPhase(phaseList(beforeValue), phaseList(afterValue)) : undefined;
918
+ let named;
919
+ try {
920
+ named = milestoneText();
921
+ } catch {
922
+ named = undefined;
923
+ }
924
+ if (phase || named) {
925
+ return pass("milestone", {
926
+ deep_review_required: true,
927
+ phase: phase || named,
928
+ detail: "deep review is /reanalyze-roadmap and is not run here",
929
+ });
930
+ }
931
+ return pass("milestone", { deep_review_required: false });
932
+ }
933
+
934
+ const STAGE_FN = {
935
+ verify: verifyStage,
936
+ docs: docsStage,
937
+ push: pushStage,
938
+ pr: prStage,
939
+ rebase: rebaseStage,
940
+ unblock: unblockStage,
941
+ merge: mergeStage,
942
+ release: releaseStage,
943
+ milestone: milestoneStage,
944
+ };
945
+
946
+ function main() {
947
+ const parsed = parseArgs(process.argv.slice(2));
948
+ if (parsed.error) return usage(parsed.error);
949
+ if (parsed.options.help) {
950
+ emit({
951
+ stage: "usage",
952
+ ok: true,
953
+ detail: "node scripts/pr-land.mjs [--pr <n>] [--body-file <path>] [--stage <name>] [--json] [--dry-run]",
954
+ });
955
+ return 0;
956
+ }
957
+ dryRun = parsed.options.dryRun;
958
+ bodyFile = parsed.options.bodyFile;
959
+ prNumber = parsed.options.pr;
960
+ const names = parsed.options.stage ? [parsed.options.stage] : STAGES;
961
+ for (const name of names) {
962
+ const code = STAGE_FN[name]();
963
+ if (code !== 0) return code;
964
+ }
965
+ return 0;
966
+ }
967
+
968
+ const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
969
+ if (invoked) {
970
+ process.exitCode = main();
971
+ }