@kontextmind/kxm 0.7.170 → 0.7.172

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.
@@ -25438,7 +25438,7 @@ function sessionTokenFixHint(policy) {
25438
25438
  }
25439
25439
 
25440
25440
  // plugins/kxm/src/mcp-server.ts
25441
- var VERSION = "0.7.170";
25441
+ var VERSION = "0.7.172";
25442
25442
  var CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
25443
25443
  var inbox = /* @__PURE__ */ new Map();
25444
25444
  var notifiedInbox = /* @__PURE__ */ new Set();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.170",
3
+ "version": "0.7.172",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -32,6 +32,9 @@ serve` serves that site with `python3 ops/docs-site/serve.py` and passes
32
32
  | `kxm lane drop <unit>` | Remove the worktree and the record. The branch is not deleted | `--force`, `--json` |
33
33
  | `kxm lane run <unit>` | Create the lane if needed, start a run from a brief, and drive it | `--brief <file>` (required), `--workflow <id>`, `--base <ref>`, `--wait`, `--timeout-ms <n>`, `--json` |
34
34
  | `kxm land` | Verify, regenerate docs, push, open or reuse a pull request, rebase, unblock, squash-merge, watch the release, and note a milestone | `--pr <n>`, `--stage <name>`, `--body-file <path>`, `--json`, `--dry-run` |
35
+ | `kxm supervise status` | Show persisted in-flight lanes, pull request and CI state, and backoff timers | `--json` |
36
+ | `kxm supervise record --lane <unit>` | Record one lane observation in `.kxm/state/supervisor.json` | `--pr`, `--merge-state`, `--ci-status`, `--ci-conclusion`, `--backoff-ms`, `--settled` |
37
+ | `kxm supervise tick` | Clear elapsed backoff timers and append `.kxm/logs/supervisor.log` | `--json`, `--dry-run` |
35
38
  | `kxm assign run` | Dispatch one assignment manifest. The runner performs every check | `--manifest <path>` |
36
39
  | `kxm assign witness` | Run the fixed witness for an existing record | `--record-dir <path>` |
37
40
  | `kxm assign plan-current` | Stamp or advance the current-plan pointer | `--task-dir <path>`, `--plan <path>`, `--sha256 <hex>`, `--base-commit <sha>`, `--expected-generation <n>` |
@@ -89,6 +92,13 @@ generated pages. `push` publishes the branch. `pr` reuses the open pull
89
92
  request or creates one from `--body-file`. `rebase` rebases onto
90
93
  `origin/main` for at most five rounds, resolving only the dist rebuild, the
91
94
  CHANGELOG Unreleased union, and the tracker "Landed in this tree" union.
95
+ `UNKNOWN` is polled and never treated as ready; `CLEAN` and `HAS_HOOKS`
96
+ proceed, `BEHIND` and `DIRTY` rebase, and any other known state blocks. A
97
+ failed `verify` names the exit code, the failing step, and a short excerpt,
98
+ and writes the full log under `.kxm/logs/`.
99
+ `kxm supervise` stores in-flight lanes, last-seen pull request and CI state,
100
+ and backoff timers in `.kxm/state/supervisor.json`. A later process loads
101
+ that file. `tick` clears a backoff whose deadline has passed.
92
102
  `unblock` reruns one failed check and reports a required review. `merge`
93
103
  squash-merges. `release` waits for the tag and the npm publish. `milestone`
94
104
  reports `deep_review_required` when a phase flips to done or the body contains
@@ -0,0 +1,167 @@
1
+ import { appendFileSync, chmodSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { discoverKxmProjectRoot } from "../project-config.ts";
4
+ import {
5
+ applyObservation,
6
+ formatTickLine,
7
+ loadSupervisorState,
8
+ resolveSupervisorNow,
9
+ resumeTick,
10
+ saveSupervisorState,
11
+ type SupervisorState,
12
+ } from "../supervise-state.ts";
13
+ import { print, printPlan, type Runtime } from "./types.ts";
14
+
15
+ export interface SuperviseRecordOptions {
16
+ lane: string;
17
+ pr?: string;
18
+ mergeState?: string;
19
+ ciStatus?: string;
20
+ ciConclusion?: string;
21
+ backoffMs?: string;
22
+ settled?: boolean;
23
+ }
24
+
25
+ function refuse(runtime: Runtime, error: string, text: string): number {
26
+ print(runtime.io, runtime.json, { ok: false, command: "supervise", error }, text);
27
+ return 1;
28
+ }
29
+
30
+ function requireProject(runtime: Runtime): number | undefined {
31
+ if (!discoverKxmProjectRoot(runtime.cwd)) {
32
+ return refuse(runtime, "project_required", "supervise requires a KXM project (run kxm init first)");
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ function nowOf(runtime: Runtime): string | number {
38
+ const now = resolveSupervisorNow(runtime.env.KXM_SUPERVISE_NOW, new Date());
39
+ if (now === "supervisor_clock_invalid") {
40
+ return refuse(runtime, "supervisor_clock_invalid", "KXM_SUPERVISE_NOW must be an ISO-8601 UTC timestamp");
41
+ }
42
+ return now;
43
+ }
44
+
45
+ function loaded(runtime: Runtime, now: string): SupervisorState | number {
46
+ const state = loadSupervisorState(runtime.dirs.state, now);
47
+ if (state === "supervisor_unreadable") {
48
+ return refuse(runtime, "supervisor_unreadable", "supervisor state is unreadable and was left unchanged");
49
+ }
50
+ if (state === "supervisor_schema_unsupported") {
51
+ return refuse(runtime, "supervisor_schema_unsupported", "supervisor state schema is not kxm.supervisor.v1 and was left unchanged");
52
+ }
53
+ return state;
54
+ }
55
+
56
+ function laneText(state: SupervisorState): string {
57
+ const units = Object.keys(state.lanes).sort();
58
+ if (units.length === 0) return "supervisor: no lanes";
59
+ const lines = [`supervisor: ${units.length} lane${units.length === 1 ? "" : "s"}`];
60
+ for (const unit of units) {
61
+ const lane = state.lanes[unit];
62
+ if (!lane) continue;
63
+ const flight = lane.inFlight ? "in-flight" : "settled";
64
+ const pr = lane.pr ? ` pr ${lane.pr.number} ${lane.pr.mergeState}` : "";
65
+ const ci = lane.ci ? ` ci ${lane.ci.status}/${lane.ci.conclusion}` : "";
66
+ const backoff = lane.backoffUntil ? ` backoff ${lane.backoffUntil}` : "";
67
+ lines.push(`${unit} ${flight}${pr}${ci}${backoff}`);
68
+ }
69
+ return lines.join("\n");
70
+ }
71
+
72
+ function show(runtime: Runtime, state: SupervisorState, extra: Record<string, unknown> = {}): number {
73
+ print(runtime.io, runtime.json, { ok: true, command: "supervise", ...extra, state }, laneText(state));
74
+ return 0;
75
+ }
76
+
77
+ export function cmdSuperviseStatus(runtime: Runtime): number {
78
+ const missing = requireProject(runtime);
79
+ if (missing !== undefined) return missing;
80
+ const now = nowOf(runtime);
81
+ if (typeof now === "number") return now;
82
+ const state = loaded(runtime, now);
83
+ if (typeof state === "number") return state;
84
+ return show(runtime, state);
85
+ }
86
+
87
+ function appendTick(runtime: Runtime, line: string): void {
88
+ mkdirSync(runtime.dirs.logs, { recursive: true });
89
+ const path = join(runtime.dirs.logs, "supervisor.log");
90
+ appendFileSync(path, `${line}\n`, { encoding: "utf8", mode: 0o600 });
91
+ try { chmodSync(path, 0o600); } catch { /* Windows may not expose POSIX modes. */ }
92
+ }
93
+
94
+ export function cmdSuperviseTick(runtime: Runtime): number {
95
+ const missing = requireProject(runtime);
96
+ if (missing !== undefined) return missing;
97
+ const now = nowOf(runtime);
98
+ if (typeof now === "number") return now;
99
+ const state = loaded(runtime, now);
100
+ if (typeof state === "number") return state;
101
+ const tick = resumeTick(state, now);
102
+ const line = formatTickLine(now, tick.resumed, tick.waiting);
103
+ if (runtime.dryRun) {
104
+ printPlan(runtime, { command: "supervise", resumed: tick.resumed, waiting: tick.waiting, log: line, state: tick.state }, [
105
+ { action: "write", target: join(runtime.dirs.state, "supervisor.json") },
106
+ { action: "write", target: join(runtime.dirs.logs, "supervisor.log") },
107
+ ], "resume lanes whose backoff has elapsed");
108
+ return 0;
109
+ }
110
+ saveSupervisorState(runtime.dirs.state, tick.state);
111
+ appendTick(runtime, line);
112
+ return show(runtime, tick.state, { resumed: tick.resumed, waiting: tick.waiting, log: line });
113
+ }
114
+
115
+ function parseBackoff(value: string | undefined): number | undefined | "supervisor_backoff_invalid" {
116
+ if (value === undefined) return undefined;
117
+ if (!/^[0-9]+$/.test(value)) return "supervisor_backoff_invalid";
118
+ const parsed = Number(value);
119
+ if (!Number.isSafeInteger(parsed)) return "supervisor_backoff_invalid";
120
+ return parsed;
121
+ }
122
+
123
+ export function cmdSuperviseRecord(runtime: Runtime, options: SuperviseRecordOptions): number {
124
+ const missing = requireProject(runtime);
125
+ if (missing !== undefined) return missing;
126
+ const now = nowOf(runtime);
127
+ if (typeof now === "number") return now;
128
+ const state = loaded(runtime, now);
129
+ if (typeof state === "number") return state;
130
+ const hasPr = options.pr !== undefined || options.mergeState !== undefined;
131
+ if (hasPr && (options.pr === undefined || options.mergeState === undefined || !/^[0-9]+$/.test(options.pr))) {
132
+ return refuse(runtime, "supervisor_pr_invalid", "record a pull request with both --pr and --merge-state");
133
+ }
134
+ const hasCi = options.ciStatus !== undefined || options.ciConclusion !== undefined;
135
+ if (hasCi && (options.ciStatus === undefined || options.ciConclusion === undefined)) {
136
+ return refuse(runtime, "supervisor_ci_invalid", "record CI with both --ci-status and --ci-conclusion");
137
+ }
138
+ const backoffMs = parseBackoff(options.backoffMs);
139
+ if (backoffMs === "supervisor_backoff_invalid") {
140
+ return refuse(runtime, "supervisor_backoff_invalid", "--backoff-ms must be a whole number of milliseconds from 0 through 86400000");
141
+ }
142
+ const next = applyObservation(state, {
143
+ unit: options.lane,
144
+ inFlight: options.settled !== true,
145
+ ...(hasPr ? { pr: { number: Number(options.pr), mergeState: String(options.mergeState).trim().toUpperCase() } } : {}),
146
+ ...(hasCi ? { ci: { status: String(options.ciStatus).trim().toLowerCase(), conclusion: String(options.ciConclusion).trim().toLowerCase() } } : {}),
147
+ ...(backoffMs !== undefined ? { backoffMs } : {}),
148
+ }, now);
149
+ if (typeof next === "string") {
150
+ const text = next === "lane_unit_invalid"
151
+ ? `lane unit ${options.lane} must match the KXM identifier pattern`
152
+ : next === "supervisor_pr_invalid"
153
+ ? "pull request number and merge state are not valid"
154
+ : next === "supervisor_ci_invalid"
155
+ ? "CI status and conclusion are not valid"
156
+ : "--backoff-ms must be a whole number of milliseconds from 0 through 86400000";
157
+ return refuse(runtime, next, text);
158
+ }
159
+ if (runtime.dryRun) {
160
+ printPlan(runtime, { command: "supervise", state: next }, [
161
+ { action: "write", target: join(runtime.dirs.state, "supervisor.json") },
162
+ ], `record lane ${options.lane}`);
163
+ return 0;
164
+ }
165
+ saveSupervisorState(runtime.dirs.state, next);
166
+ return show(runtime, next);
167
+ }
@@ -136,6 +136,7 @@ import {
136
136
  } from "./cli/lanes.ts";
137
137
 
138
138
  import { cmdLand } from "./cli/land.ts";
139
+ import { cmdSuperviseRecord, cmdSuperviseStatus, cmdSuperviseTick } from "./cli/supervise.ts";
139
140
  import { cmdAssign } from "./cli/assign.ts";
140
141
 
141
142
  import {
@@ -232,6 +233,7 @@ const DRY_RUN_COMMANDS: ReadonlySet<string> = new Set([
232
233
  "task create", "task list", "task get", "task run", "task sync",
233
234
  "studio layout", "studio serve",
234
235
  "docs build", "docs serve",
236
+ "supervise status", "supervise tick", "supervise record",
235
237
  ]);
236
238
 
237
239
  class DryRunRefused extends Error {
@@ -540,6 +542,36 @@ function createProgram(ctx: CliContext, result: { code: number }, argv: readonly
540
542
  });
541
543
  });
542
544
 
545
+ const superviseCmd = addGlobalOptions(program.command("supervise").description("Persist roadmap supervisor state across restarts"));
546
+ superviseCmd.helpCommand("help", "Show supervise help");
547
+ addGlobalOptions(superviseCmd.command("status").description("Show persisted in-flight lanes, pull request and CI state, and backoff timers"))
548
+ .action(async function superviseStatusAction(this: Command) {
549
+ result.code = cmdSuperviseStatus(runtimeFrom(ctx, this));
550
+ });
551
+ addGlobalOptions(superviseCmd.command("tick").description("Resume lanes whose backoff has elapsed and append a supervisor log line"))
552
+ .action(async function superviseTickAction(this: Command) {
553
+ result.code = cmdSuperviseTick(runtimeFrom(ctx, this));
554
+ });
555
+ addGlobalOptions(superviseCmd.command("record").description("Record one lane observation in the supervisor state"))
556
+ .requiredOption("--lane <unit>", "Lane unit")
557
+ .option("--pr <n>", "Last-seen pull request number")
558
+ .option("--merge-state <status>", "Last-seen mergeStateStatus")
559
+ .option("--ci-status <status>", "Last-seen CI status")
560
+ .option("--ci-conclusion <conclusion>", "Last-seen CI conclusion")
561
+ .option("--backoff-ms <n>", "Backoff from now, in milliseconds; 0 clears it")
562
+ .option("--settled", "Mark the lane not in flight")
563
+ .action(async function superviseRecordAction(this: Command, options: { lane: string; pr?: string; mergeState?: string; ciStatus?: string; ciConclusion?: string; backoffMs?: string; settled?: boolean }) {
564
+ result.code = cmdSuperviseRecord(runtimeFrom(ctx, this), {
565
+ lane: options.lane,
566
+ ...(options.pr !== undefined ? { pr: options.pr } : {}),
567
+ ...(options.mergeState !== undefined ? { mergeState: options.mergeState } : {}),
568
+ ...(options.ciStatus !== undefined ? { ciStatus: options.ciStatus } : {}),
569
+ ...(options.ciConclusion !== undefined ? { ciConclusion: options.ciConclusion } : {}),
570
+ ...(options.backoffMs !== undefined ? { backoffMs: options.backoffMs } : {}),
571
+ settled: options.settled === true,
572
+ });
573
+ });
574
+
543
575
  addGlobalOptions(program.command("land").description("Land the current branch: verify, regenerate docs, push, pull request, rebase, unblock, squash-merge, release, and milestone"))
544
576
  .option("--pr <n>", "Existing pull request number")
545
577
  .option("--stage <name>", "Run one stage: verify, docs, push, pr, rebase, unblock, merge, release, or milestone")
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
11
11
  import type { HubEvent, MessageRecord } from "./protocol.ts";
12
12
  import { sessionTokenFixHint } from "./session-token-hint.ts";
13
13
 
14
- const VERSION = "0.7.170";
14
+ const VERSION = "0.7.172";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -0,0 +1,232 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { kxmResourceIdentifier } from "./project-config.ts";
4
+
5
+ /** First shape of the roadmap supervisor record. An unknown schema is refused
6
+ * and left on disk. A later shape migrates in this module the way the Runtime
7
+ * registry copies schema 1 to 2; this file is not a hub database table. */
8
+ export const SUPERVISOR_SCHEMA = "kxm.supervisor.v1";
9
+
10
+ const TIMESTAMP_RE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,9})?Z$/;
11
+ const MERGE_STATE_RE = /^[A-Z][A-Z0-9_]{0,31}$/;
12
+ const CI_TOKEN_RE = /^[a-z][a-z0-9_-]{0,31}$/;
13
+ const MAX_BACKOFF_MS = 24 * 60 * 60 * 1000;
14
+
15
+ export interface SupervisorPullRequest {
16
+ number: number;
17
+ mergeState: string;
18
+ seenAt: string;
19
+ }
20
+
21
+ export interface SupervisorCi {
22
+ status: string;
23
+ conclusion: string;
24
+ seenAt: string;
25
+ }
26
+
27
+ export interface SupervisorLane {
28
+ inFlight: boolean;
29
+ pr?: SupervisorPullRequest;
30
+ ci?: SupervisorCi;
31
+ backoffUntil?: string;
32
+ }
33
+
34
+ export interface SupervisorState {
35
+ schema: typeof SUPERVISOR_SCHEMA;
36
+ updatedAt: string;
37
+ lanes: Record<string, SupervisorLane>;
38
+ }
39
+
40
+ export interface SupervisorObservation {
41
+ unit: string;
42
+ inFlight: boolean;
43
+ pr?: { number: number; mergeState: string };
44
+ ci?: { status: string; conclusion: string };
45
+ /** Set when the caller passed a backoff. Zero clears a stored timer. */
46
+ backoffMs?: number;
47
+ }
48
+
49
+ export type SupervisorLoadError = "supervisor_unreadable" | "supervisor_schema_unsupported";
50
+
51
+ export function supervisorStatePath(stateDir: string): string {
52
+ return join(stateDir, "supervisor.json");
53
+ }
54
+
55
+ export function emptySupervisorState(now: string): SupervisorState {
56
+ return { schema: SUPERVISOR_SCHEMA, updatedAt: now, lanes: {} };
57
+ }
58
+
59
+ export function isSupervisorTimestamp(value: string): boolean {
60
+ return TIMESTAMP_RE.test(value) && Number.isFinite(Date.parse(value));
61
+ }
62
+
63
+ export function resolveSupervisorNow(override: string | undefined, fallback: Date): string | "supervisor_clock_invalid" {
64
+ if (override === undefined || override.trim() === "") return fallback.toISOString();
65
+ const text = override.trim();
66
+ return isSupervisorTimestamp(text) ? text : "supervisor_clock_invalid";
67
+ }
68
+
69
+ function isPullRequest(value: unknown): value is SupervisorPullRequest {
70
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
71
+ const record = value as Record<string, unknown>;
72
+ if (Object.keys(record).some((key) => key !== "number" && key !== "mergeState" && key !== "seenAt")) return false;
73
+ return Number.isInteger(record.number) && (record.number as number) > 0
74
+ && typeof record.mergeState === "string" && MERGE_STATE_RE.test(record.mergeState)
75
+ && typeof record.seenAt === "string" && isSupervisorTimestamp(record.seenAt);
76
+ }
77
+
78
+ function isCi(value: unknown): value is SupervisorCi {
79
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
80
+ const record = value as Record<string, unknown>;
81
+ if (Object.keys(record).some((key) => key !== "status" && key !== "conclusion" && key !== "seenAt")) return false;
82
+ return typeof record.status === "string" && CI_TOKEN_RE.test(record.status)
83
+ && typeof record.conclusion === "string" && CI_TOKEN_RE.test(record.conclusion)
84
+ && typeof record.seenAt === "string" && isSupervisorTimestamp(record.seenAt);
85
+ }
86
+
87
+ function isLane(value: unknown): value is SupervisorLane {
88
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
89
+ const record = value as Record<string, unknown>;
90
+ if (Object.keys(record).some((key) => key !== "inFlight" && key !== "pr" && key !== "ci" && key !== "backoffUntil")) return false;
91
+ if (typeof record.inFlight !== "boolean") return false;
92
+ if (record.pr !== undefined && !isPullRequest(record.pr)) return false;
93
+ if (record.ci !== undefined && !isCi(record.ci)) return false;
94
+ if (record.backoffUntil !== undefined && (typeof record.backoffUntil !== "string" || !isSupervisorTimestamp(record.backoffUntil))) return false;
95
+ return true;
96
+ }
97
+
98
+ function isState(value: unknown): value is SupervisorState {
99
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
100
+ const record = value as Record<string, unknown>;
101
+ if (Object.keys(record).some((key) => key !== "schema" && key !== "updatedAt" && key !== "lanes")) return false;
102
+ if (record.schema !== SUPERVISOR_SCHEMA) return false;
103
+ if (typeof record.updatedAt !== "string" || !isSupervisorTimestamp(record.updatedAt)) return false;
104
+ if (!record.lanes || typeof record.lanes !== "object" || Array.isArray(record.lanes)) return false;
105
+ for (const [unit, lane] of Object.entries(record.lanes as Record<string, unknown>)) {
106
+ if (!kxmResourceIdentifier(unit) || !isLane(lane)) return false;
107
+ }
108
+ return true;
109
+ }
110
+
111
+ /** Read `supervisor.json`. A missing file is empty state. A malformed file or
112
+ * an unknown schema is refused and is not replaced. */
113
+ export function loadSupervisorState(stateDir: string, now: string): SupervisorState | SupervisorLoadError {
114
+ const path = supervisorStatePath(stateDir);
115
+ if (!existsSync(path)) return emptySupervisorState(now);
116
+ let parsed: unknown;
117
+ try {
118
+ parsed = JSON.parse(readFileSync(path, "utf8"));
119
+ } catch {
120
+ return "supervisor_unreadable";
121
+ }
122
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "supervisor_unreadable";
123
+ const schema = (parsed as { schema?: unknown }).schema;
124
+ if (schema !== SUPERVISOR_SCHEMA) return "supervisor_schema_unsupported";
125
+ if (!isState(parsed)) return "supervisor_unreadable";
126
+ return parsed;
127
+ }
128
+
129
+ export function saveSupervisorState(stateDir: string, state: SupervisorState): void {
130
+ const lanes: Record<string, SupervisorLane> = {};
131
+ for (const unit of Object.keys(state.lanes).sort()) {
132
+ const lane = state.lanes[unit];
133
+ if (!lane) continue;
134
+ const next: SupervisorLane = { inFlight: lane.inFlight };
135
+ if (lane.pr) next.pr = { number: lane.pr.number, mergeState: lane.pr.mergeState, seenAt: lane.pr.seenAt };
136
+ if (lane.ci) next.ci = { status: lane.ci.status, conclusion: lane.ci.conclusion, seenAt: lane.ci.seenAt };
137
+ if (lane.backoffUntil) next.backoffUntil = lane.backoffUntil;
138
+ lanes[unit] = next;
139
+ }
140
+ const body = `${JSON.stringify({ schema: SUPERVISOR_SCHEMA, updatedAt: state.updatedAt, lanes }, null, 2)}\n`;
141
+ mkdirSync(stateDir, { recursive: true });
142
+ const path = supervisorStatePath(stateDir);
143
+ const temporary = `${path}.tmp`;
144
+ writeFileSync(temporary, body, { encoding: "utf8", mode: 0o600 });
145
+ renameSync(temporary, path);
146
+ try { chmodSync(path, 0o600); } catch { /* Windows may not expose POSIX modes. */ }
147
+ }
148
+
149
+ export function applyObservation(
150
+ state: SupervisorState,
151
+ observation: SupervisorObservation,
152
+ now: string,
153
+ ): SupervisorState | "lane_unit_invalid" | "supervisor_pr_invalid" | "supervisor_ci_invalid" | "supervisor_backoff_invalid" {
154
+ if (!kxmResourceIdentifier(observation.unit)) return "lane_unit_invalid";
155
+ if (!isSupervisorTimestamp(now)) return "supervisor_backoff_invalid";
156
+ if (observation.pr) {
157
+ if (!Number.isInteger(observation.pr.number) || observation.pr.number <= 0 || !MERGE_STATE_RE.test(observation.pr.mergeState)) {
158
+ return "supervisor_pr_invalid";
159
+ }
160
+ }
161
+ if (observation.ci) {
162
+ if (!CI_TOKEN_RE.test(observation.ci.status) || !CI_TOKEN_RE.test(observation.ci.conclusion)) return "supervisor_ci_invalid";
163
+ }
164
+ if (observation.backoffMs !== undefined) {
165
+ if (!Number.isInteger(observation.backoffMs) || observation.backoffMs < 0 || observation.backoffMs > MAX_BACKOFF_MS) {
166
+ return "supervisor_backoff_invalid";
167
+ }
168
+ }
169
+ const prior = state.lanes[observation.unit];
170
+ const next: SupervisorLane = { inFlight: observation.inFlight };
171
+ if (observation.pr) next.pr = { number: observation.pr.number, mergeState: observation.pr.mergeState, seenAt: now };
172
+ else if (prior?.pr) next.pr = { ...prior.pr };
173
+ if (observation.ci) next.ci = { status: observation.ci.status, conclusion: observation.ci.conclusion, seenAt: now };
174
+ else if (prior?.ci) next.ci = { ...prior.ci };
175
+ if (observation.backoffMs === undefined) {
176
+ if (prior?.backoffUntil) next.backoffUntil = prior.backoffUntil;
177
+ } else if (observation.backoffMs > 0) {
178
+ next.backoffUntil = new Date(Date.parse(now) + observation.backoffMs).toISOString();
179
+ }
180
+ return {
181
+ schema: SUPERVISOR_SCHEMA,
182
+ updatedAt: now,
183
+ lanes: { ...state.lanes, [observation.unit]: next },
184
+ };
185
+ }
186
+
187
+ export interface SupervisorTick {
188
+ state: SupervisorState;
189
+ resumed: string[];
190
+ waiting: string[];
191
+ }
192
+
193
+ /** Keep every lane. A backoff that has elapsed is cleared so the lane can be
194
+ * acted on again. A backoff still in the future is left untouched. */
195
+ export function resumeTick(state: SupervisorState, now: string): SupervisorTick {
196
+ const nowMs = Date.parse(now);
197
+ const lanes: Record<string, SupervisorLane> = {};
198
+ const resumed: string[] = [];
199
+ const waiting: string[] = [];
200
+ for (const unit of Object.keys(state.lanes).sort()) {
201
+ const lane = state.lanes[unit];
202
+ if (!lane) continue;
203
+ const until = lane.backoffUntil === undefined ? undefined : Date.parse(lane.backoffUntil);
204
+ const next: SupervisorLane = { inFlight: lane.inFlight };
205
+ if (lane.pr) next.pr = { ...lane.pr };
206
+ if (lane.ci) next.ci = { ...lane.ci };
207
+ if (until !== undefined && until > nowMs && lane.backoffUntil !== undefined) {
208
+ next.backoffUntil = lane.backoffUntil;
209
+ waiting.push(unit);
210
+ } else if (until !== undefined) {
211
+ resumed.push(unit);
212
+ }
213
+ lanes[unit] = next;
214
+ }
215
+ return {
216
+ state: { schema: SUPERVISOR_SCHEMA, updatedAt: now, lanes },
217
+ resumed,
218
+ waiting,
219
+ };
220
+ }
221
+
222
+ export function formatTickLine(now: string, resumed: readonly string[], waiting: readonly string[]): string {
223
+ const clock = now.slice(11, 16);
224
+ if (resumed.length === 0 && waiting.length === 0) {
225
+ return `[supervisor]: no change. No action. - (${clock})`;
226
+ }
227
+ const parts: string[] = [];
228
+ if (resumed.length > 0) parts.push(`resumed ${resumed.join(", ")}`);
229
+ if (waiting.length > 0) parts.push(`waiting ${waiting.join(", ")}`);
230
+ const acted = resumed.length > 0 ? `Acted: resume ${resumed.join(", ")}` : "No action";
231
+ return `[supervisor]: ${parts.join("; ")}. ${acted}. - (${clock})`;
232
+ }