@kontextmind/kxm 0.7.169 → 0.7.171
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +18 -0
- package/docs/adr/ADR-0005-obscura-default-playwright.md +2 -2
- package/docs/architecture/hosted-direction.md +7 -7
- package/docs/contributing/assignment-runner.md +6 -5
- package/docs/contributing/decisions.md +1 -1
- package/docs/contributing/learnings.md +9 -4
- package/docs/reference/cli-reference.md +27 -4
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +596 -269
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm-runs/SKILL.md +10 -0
- package/plugins/kxm/src/cli/supervise.ts +167 -0
- package/plugins/kxm/src/cli.ts +32 -0
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/supervise-state.ts +232 -0
- package/scripts/pr-land.mjs +115 -17
package/scripts/pr-land.mjs
CHANGED
|
@@ -26,6 +26,12 @@ const STAGES = Object.freeze([
|
|
|
26
26
|
const VERIFY_FRESH_MS = 30 * 60 * 1000;
|
|
27
27
|
const REBASE_ROUNDS = 5;
|
|
28
28
|
const POLL_MS = positiveInt(process.env.KXM_LAND_POLL_MS, 30_000);
|
|
29
|
+
const MERGE_STATE_WAIT_MS = positiveInt(process.env.KXM_LAND_MERGE_STATE_WAIT_MS, 120_000);
|
|
30
|
+
const MERGE_STATE_POLL_MS = positiveInt(process.env.KXM_LAND_MERGE_STATE_POLL_MS, 1_000);
|
|
31
|
+
const MERGE_STATE_POLL_CAP_MS = 30_000;
|
|
32
|
+
const MERGE_READY = new Set(["CLEAN", "HAS_HOOKS", "ABSENT", ""]);
|
|
33
|
+
const MERGE_REBASE = new Set(["BEHIND", "DIRTY"]);
|
|
34
|
+
const VERIFY_FAIL_LINE = /✖|not ok\b|error TS\d*|error MD\d*|out of date|npm ERR!|ELIFECYCLE|AssertionError|# fail\b/i;
|
|
29
35
|
const MERGE_WAIT_MS = 20 * 60 * 1000;
|
|
30
36
|
const RELEASE_WAIT_MS = 20 * 60 * 1000;
|
|
31
37
|
const RELEASE_PROGRESS_MS = 2 * 60 * 1000;
|
|
@@ -76,11 +82,15 @@ function run(command, args, timeout = 120_000) {
|
|
|
76
82
|
};
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
function
|
|
85
|
+
function redact(text) {
|
|
80
86
|
return String(text ?? "")
|
|
81
87
|
.replace(/ghp_[A-Za-z0-9]+/g, "[redacted]")
|
|
82
88
|
.replace(/github_pat_[A-Za-z0-9_]+/g, "[redacted]")
|
|
83
|
-
.replace(/Bearer\s+\S+/gi, "Bearer [redacted]")
|
|
89
|
+
.replace(/Bearer\s+\S+/gi, "Bearer [redacted]");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function safeDetail(text) {
|
|
93
|
+
return redact(text)
|
|
84
94
|
.replace(/\s+/g, " ")
|
|
85
95
|
.trim()
|
|
86
96
|
.slice(0, 500);
|
|
@@ -95,8 +105,8 @@ function pass(stage, extra = {}) {
|
|
|
95
105
|
return 0;
|
|
96
106
|
}
|
|
97
107
|
|
|
98
|
-
function refuse(stage, code, detail) {
|
|
99
|
-
const payload = { stage, ok: false, code };
|
|
108
|
+
function refuse(stage, code, detail, extra = {}) {
|
|
109
|
+
const payload = { stage, ok: false, code, ...extra };
|
|
100
110
|
if (detail) payload.detail = safeDetail(detail);
|
|
101
111
|
emit(payload);
|
|
102
112
|
return 1;
|
|
@@ -184,6 +194,30 @@ function writeLog(name, value) {
|
|
|
184
194
|
return path;
|
|
185
195
|
}
|
|
186
196
|
|
|
197
|
+
function writeTextLog(name, text) {
|
|
198
|
+
const path = join(logsDir(), name);
|
|
199
|
+
const body = text.endsWith("\n") ? text : `${text}\n`;
|
|
200
|
+
writeFileSync(path, body, { encoding: "utf8", mode: 0o600 });
|
|
201
|
+
return path;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function verifyFailureReport(stdout, stderr) {
|
|
205
|
+
const lines = `${stdout}\n${stderr}`.split(/\r?\n/);
|
|
206
|
+
const failing = [];
|
|
207
|
+
let step = "";
|
|
208
|
+
for (const line of lines) {
|
|
209
|
+
const text = line.trim();
|
|
210
|
+
if (!text) continue;
|
|
211
|
+
if (/^>\s+\S/.test(text) && !/^npm ERR!/i.test(text)) step = text.slice(0, 180);
|
|
212
|
+
if (VERIFY_FAIL_LINE.test(text)) failing.push(text.slice(0, 180));
|
|
213
|
+
}
|
|
214
|
+
const picked = (failing.length > 0 ? failing : lines.map((line) => line.trim()).filter(Boolean).slice(-4)).slice(-8);
|
|
215
|
+
return {
|
|
216
|
+
step,
|
|
217
|
+
excerpt: redact(picked.join(" | ")).replace(/\s+/g, " ").trim().slice(0, 700),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
187
221
|
function parseArgs(argv) {
|
|
188
222
|
const options = { json: false, dryRun: false };
|
|
189
223
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -262,7 +296,34 @@ function verifyStage() {
|
|
|
262
296
|
}
|
|
263
297
|
const verified = run("npm", ["run", "verify"], 3_600_000);
|
|
264
298
|
if (verified.status !== 0) {
|
|
265
|
-
|
|
299
|
+
const logName = `land-verify-${tree}.log`;
|
|
300
|
+
const logRel = `.kxm/logs/${logName}`;
|
|
301
|
+
const report = verifyFailureReport(verified.stdout, verified.stderr);
|
|
302
|
+
const excerpt = report.excerpt || "(no output)";
|
|
303
|
+
writeTextLog(logName, redact(`exit ${verified.status}\n--- stdout ---\n${verified.stdout}\n--- stderr ---\n${verified.stderr}\n`));
|
|
304
|
+
const receipt = {
|
|
305
|
+
stage: "verify",
|
|
306
|
+
ok: false,
|
|
307
|
+
tree,
|
|
308
|
+
exitCode: verified.status,
|
|
309
|
+
excerpt,
|
|
310
|
+
log: logRel,
|
|
311
|
+
finishedAt: new Date().toISOString(),
|
|
312
|
+
};
|
|
313
|
+
if (report.step) receipt.step = report.step;
|
|
314
|
+
writeLog(receiptName, receipt);
|
|
315
|
+
const step = report.step ? `; step ${report.step}` : "";
|
|
316
|
+
return refuse(
|
|
317
|
+
"verify",
|
|
318
|
+
"land_verify_failed",
|
|
319
|
+
`exit ${verified.status}; log ${logRel}${step}; ${excerpt}`,
|
|
320
|
+
{
|
|
321
|
+
exitCode: verified.status,
|
|
322
|
+
log: logRel,
|
|
323
|
+
excerpt,
|
|
324
|
+
...(report.step ? { step: report.step } : {}),
|
|
325
|
+
},
|
|
326
|
+
);
|
|
266
327
|
}
|
|
267
328
|
writeLog(receiptName, { stage: "verify", ok: true, tree, finishedAt: new Date().toISOString() });
|
|
268
329
|
return pass("verify", { detail: "verify: passed", tree });
|
|
@@ -572,7 +633,42 @@ function mergeState() {
|
|
|
572
633
|
prNumber = String(found.pr.number);
|
|
573
634
|
const viewed = ghJson(["pr", "view", prNumber, "--json", "mergeStateStatus,title,number"]);
|
|
574
635
|
if (!viewed.ok) return { ok: false, detail: viewed.result.stderr || viewed.result.stdout };
|
|
575
|
-
return { ok: true, status: String(viewed.value.mergeStateStatus ?? ""), title: viewed.value.title };
|
|
636
|
+
return { ok: true, status: String(viewed.value.mergeStateStatus ?? "").trim().toUpperCase(), title: viewed.value.title };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function classifyMergeStatus(status) {
|
|
640
|
+
if (MERGE_READY.has(status)) return "ready";
|
|
641
|
+
if (MERGE_REBASE.has(status)) return "rebase";
|
|
642
|
+
return "block";
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** UNKNOWN is not mergeable. Poll with bounded backoff until GitHub names a state. */
|
|
646
|
+
function awaitKnownMergeState(stage, failureCode) {
|
|
647
|
+
const deadline = Date.now() + MERGE_STATE_WAIT_MS;
|
|
648
|
+
let delay = Math.min(Math.max(MERGE_STATE_POLL_MS, 1), MERGE_STATE_POLL_CAP_MS);
|
|
649
|
+
let state = mergeState();
|
|
650
|
+
while (state.ok && state.status === "UNKNOWN") {
|
|
651
|
+
if (Date.now() >= deadline) {
|
|
652
|
+
return { ok: false, code: "land_merge_state_unknown", detail: "mergeStateStatus stayed UNKNOWN" };
|
|
653
|
+
}
|
|
654
|
+
emit({ stage, ok: true, detail: "mergeStateStatus UNKNOWN; polling" });
|
|
655
|
+
const remaining = deadline - Date.now();
|
|
656
|
+
if (remaining <= 0) {
|
|
657
|
+
return { ok: false, code: "land_merge_state_unknown", detail: "mergeStateStatus stayed UNKNOWN" };
|
|
658
|
+
}
|
|
659
|
+
sleep(Math.min(delay, remaining));
|
|
660
|
+
delay = Math.min(delay * 2, MERGE_STATE_POLL_CAP_MS);
|
|
661
|
+
state = mergeState();
|
|
662
|
+
}
|
|
663
|
+
if (!state.ok) return { ok: false, code: failureCode, detail: state.detail || "merge state unavailable" };
|
|
664
|
+
return { ok: true, state };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function readMergeStatus(stage, failureCode) {
|
|
668
|
+
const resolved = awaitKnownMergeState(stage, failureCode);
|
|
669
|
+
if (!resolved.ok) return resolved;
|
|
670
|
+
const status = resolved.state.status;
|
|
671
|
+
return { ok: true, status, action: classifyMergeStatus(status) };
|
|
576
672
|
}
|
|
577
673
|
|
|
578
674
|
function rebaseStage() {
|
|
@@ -583,17 +679,16 @@ function rebaseStage() {
|
|
|
583
679
|
"git rebase origin/main",
|
|
584
680
|
"git merge-tree --write-tree origin/main HEAD",
|
|
585
681
|
"git push --force-with-lease -u origin <branch>",
|
|
586
|
-
], `at most ${REBASE_ROUNDS} rounds`);
|
|
682
|
+
], `at most ${REBASE_ROUNDS} rounds; UNKNOWN is polled and never passed`);
|
|
587
683
|
}
|
|
588
684
|
const branch = branchName();
|
|
589
685
|
if (branch === "main") return refuse("rebase", "land_conflict_manual", "refusing to rebase branch main");
|
|
590
686
|
const manual = [];
|
|
591
687
|
for (let round = 1; round <= REBASE_ROUNDS; round += 1) {
|
|
592
|
-
const
|
|
593
|
-
if (!
|
|
594
|
-
if (
|
|
595
|
-
|
|
596
|
-
}
|
|
688
|
+
const read = readMergeStatus("rebase", "land_conflict_manual");
|
|
689
|
+
if (!read.ok) return refuse("rebase", read.code, read.detail);
|
|
690
|
+
if (read.action === "block") return refuse("rebase", "land_merge_not_ready", `mergeStateStatus ${read.status}`);
|
|
691
|
+
if (read.action === "ready") return pass("rebase", { detail: `mergeStateStatus ${read.status || "absent"}` });
|
|
597
692
|
const before = treeHash();
|
|
598
693
|
const clean = run("git", ["merge-tree", "--write-tree", "origin/main", "HEAD"]).status === 0;
|
|
599
694
|
const fetched = run("git", ["fetch", "origin"]);
|
|
@@ -623,12 +718,12 @@ function rebaseStage() {
|
|
|
623
718
|
const pushCode = pushStage();
|
|
624
719
|
if (pushCode !== 0) return pushCode;
|
|
625
720
|
}
|
|
626
|
-
const
|
|
627
|
-
if (
|
|
628
|
-
|
|
629
|
-
}
|
|
721
|
+
const read = readMergeStatus("rebase", "land_conflict_manual");
|
|
722
|
+
if (!read.ok) return refuse("rebase", read.code, read.detail);
|
|
723
|
+
if (read.action === "ready") return pass("rebase", { detail: `mergeStateStatus ${read.status || "absent"}` });
|
|
724
|
+
if (read.action === "block") return refuse("rebase", "land_merge_not_ready", `mergeStateStatus ${read.status}`);
|
|
630
725
|
const paths = [...new Set(manual)];
|
|
631
|
-
const detail = paths.length > 0 ? paths.join(", ") :
|
|
726
|
+
const detail = paths.length > 0 ? paths.join(", ") : `still ${read.status} after ${REBASE_ROUNDS} rounds`;
|
|
632
727
|
return refuse("rebase", "land_conflict_manual", detail);
|
|
633
728
|
}
|
|
634
729
|
|
|
@@ -764,6 +859,9 @@ function mergeStage() {
|
|
|
764
859
|
const found = ensurePr();
|
|
765
860
|
if (!found.ok || !found.pr?.number) return refuse("merge", "land_merge_failed", found.detail || "no open pull request");
|
|
766
861
|
prNumber = String(found.pr.number);
|
|
862
|
+
const read = readMergeStatus("merge", "land_merge_failed");
|
|
863
|
+
if (!read.ok) return refuse("merge", read.code, read.detail);
|
|
864
|
+
if (read.action !== "ready") return refuse("merge", "land_merge_not_ready", `mergeStateStatus ${read.status}`);
|
|
767
865
|
const title = String(found.pr.title ?? branchName());
|
|
768
866
|
const nodeId = String(found.pr.id ?? "");
|
|
769
867
|
const tags = newestTag();
|