@lmzhen/dsh-evolution-core 0.27.3 → 0.29.0

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.
@@ -56,8 +56,70 @@ function serviceOf(ctx, name) {
56
56
  function messageOf(error) {
57
57
  return error instanceof Error ? error.message : String(error);
58
58
  }
59
+ function textOfValue(value) {
60
+ if (typeof value === "string") return value;
61
+ if (!Array.isArray(value)) return "";
62
+ return value.filter((block) => typeof block === "object" && block !== null && block.type === "text").map((block) => {
63
+ const text = block.text;
64
+ return typeof text === "string" ? text : "";
65
+ }).join("");
66
+ }
67
+ function abortLink(signal, timeoutMs) {
68
+ const controller = new AbortController();
69
+ const timer = setTimeout(() => {
70
+ controller.abort(/* @__PURE__ */ new Error("subagent turn timeout"));
71
+ }, timeoutMs);
72
+ const onAbort = () => {
73
+ controller.abort(signal?.reason);
74
+ };
75
+ signal?.addEventListener("abort", onAbort, { once: true });
76
+ return {
77
+ controller,
78
+ cleanup: () => {
79
+ clearTimeout(timer);
80
+ signal?.removeEventListener("abort", onAbort);
81
+ }
82
+ };
83
+ }
84
+ function waitForSubagentEnd(ctx, childId, signal, timeoutMs) {
85
+ return new Promise((resolve, reject) => {
86
+ let settled = false;
87
+ const finish = (fn) => {
88
+ if (settled) return;
89
+ settled = true;
90
+ cleanup();
91
+ fn();
92
+ };
93
+ const eventCtx = ctx;
94
+ const off = typeof eventCtx.on === "function" ? eventCtx.on("subagent/end", (info) => {
95
+ if (info.id !== childId) return;
96
+ finish(() => {
97
+ resolve(info);
98
+ });
99
+ }) : () => {};
100
+ const timer = setTimeout(() => {
101
+ finish(() => {
102
+ reject(/* @__PURE__ */ new Error("timed out waiting for subagent/end: " + childId));
103
+ });
104
+ }, timeoutMs);
105
+ const onAbort = () => {
106
+ finish(() => {
107
+ reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("conversation turn aborted"));
108
+ });
109
+ };
110
+ signal.addEventListener("abort", onAbort, { once: true });
111
+ function cleanup() {
112
+ clearTimeout(timer);
113
+ signal.removeEventListener("abort", onAbort);
114
+ off();
115
+ }
116
+ if (signal.aborted) onAbort();
117
+ });
118
+ }
59
119
  function createSubagentChannel(ctx) {
60
120
  let lastKind = "reviewer";
121
+ const active = /* @__PURE__ */ new Map();
122
+ const disposed = /* @__PURE__ */ new Set();
61
123
  return {
62
124
  get state() {
63
125
  return probeChannelState(ctx, lastKind);
@@ -179,6 +241,184 @@ function createSubagentChannel(ctx) {
179
241
  } catch {}
180
242
  }
181
243
  }
244
+ },
245
+ async open(spec) {
246
+ lastKind = spec.kind;
247
+ const probe = probeChannelState(ctx, spec.kind);
248
+ if (probe.kind === "unavailable") return probe;
249
+ if (spec.allow.length === 0) return {
250
+ kind: "unavailable",
251
+ code: "E-324",
252
+ reason: "allow set is empty"
253
+ };
254
+ const subagents = serviceOf(ctx, "subagents");
255
+ const presets = serviceOf(ctx, "agentPresets");
256
+ if (subagents === void 0 || presets === void 0) return {
257
+ kind: "unavailable",
258
+ code: "E-323",
259
+ reason: "subagents/agentPresets service unavailable"
260
+ };
261
+ const parentPresetId = presets.composedPreset(spec.parent.ctx);
262
+ if (parentPresetId === void 0) return {
263
+ kind: "unavailable",
264
+ code: "E-323",
265
+ reason: "parent preset could not be resolved"
266
+ };
267
+ const ensured = await ensureRestrictedPreset(ctx, {
268
+ kind: spec.kind,
269
+ parentPresetId,
270
+ allow: spec.allow
271
+ });
272
+ if (ensured.kind === "failed") return {
273
+ kind: "unavailable",
274
+ code: "E-324",
275
+ reason: ensured.reason
276
+ };
277
+ const link = abortLink(spec.signal, spec.timeoutMs);
278
+ let activation;
279
+ try {
280
+ activation = await subagents.startActivation({
281
+ provider: "spawn",
282
+ label: spec.label,
283
+ delivery: "caller",
284
+ request: {
285
+ parent: spec.parent,
286
+ prompt: [{
287
+ type: "text",
288
+ text: spec.promptText
289
+ }],
290
+ agentPreset: ensured.presetId,
291
+ ...spec.cwd !== void 0 ? { cwd: spec.cwd } : {},
292
+ ...spec.maxDepth !== void 0 ? { maxDepth: spec.maxDepth } : {},
293
+ ...spec.agentOptions !== void 0 ? { agentOptions: spec.agentOptions } : {}
294
+ },
295
+ signal: link.controller.signal
296
+ });
297
+ const result = await activation.result;
298
+ if (result.stopReason === "aborted") {
299
+ try {
300
+ await activation.dispose();
301
+ } catch {}
302
+ return { kind: "aborted" };
303
+ }
304
+ if (result.stopReason !== "completed") {
305
+ try {
306
+ await activation.dispose();
307
+ } catch {}
308
+ return {
309
+ kind: "unavailable",
310
+ code: "E-323",
311
+ reason: "round 1 did not complete: " + result.stopReason
312
+ };
313
+ }
314
+ const turn = {
315
+ childId: activation.childId,
316
+ stopReason: result.stopReason,
317
+ text: textOfValue(result.output),
318
+ ...result.structured !== void 0 ? { structured: result.structured } : {},
319
+ ...result.diagnostic !== void 0 ? { diagnostic: result.diagnostic } : {}
320
+ };
321
+ active.set(activation.childId, {
322
+ childId: activation.childId,
323
+ parent: spec.parent,
324
+ activation,
325
+ disposed: false
326
+ });
327
+ disposed.delete(activation.childId);
328
+ return {
329
+ kind: "ok",
330
+ childId: activation.childId,
331
+ turn
332
+ };
333
+ } catch (error) {
334
+ if (activation !== void 0) try {
335
+ await activation.dispose();
336
+ } catch {}
337
+ if (link.controller.signal.aborted) return { kind: "aborted" };
338
+ return {
339
+ kind: "unavailable",
340
+ code: "E-323",
341
+ reason: messageOf(error)
342
+ };
343
+ } finally {
344
+ link.cleanup();
345
+ }
346
+ },
347
+ async continueTurn(childId, promptText, options) {
348
+ if (disposed.has(childId)) return {
349
+ kind: "unavailable",
350
+ code: "E-324",
351
+ reason: "conversation is disposed"
352
+ };
353
+ const current = active.get(childId);
354
+ if (current === void 0 || current.disposed) return {
355
+ kind: "unavailable",
356
+ code: "E-323",
357
+ reason: "no open conversation for " + childId
358
+ };
359
+ const subagents = serviceOf(ctx, "subagents");
360
+ if (subagents?.sendMessage === void 0) return {
361
+ kind: "unavailable",
362
+ code: "E-323",
363
+ reason: "subagents.sendMessage unavailable"
364
+ };
365
+ const link = abortLink(options?.signal, options?.timeoutMs ?? 12e4);
366
+ let pending;
367
+ try {
368
+ pending = waitForSubagentEnd(ctx, childId, link.controller.signal, options?.timeoutMs ?? 12e4);
369
+ await subagents.sendMessage(current.parent, childId, [{
370
+ type: "text",
371
+ text: promptText
372
+ }], { signal: link.controller.signal });
373
+ } catch (error) {
374
+ link.cleanup();
375
+ if (options?.signal?.aborted === true) return { kind: "aborted" };
376
+ return {
377
+ kind: "unavailable",
378
+ code: "E-323",
379
+ reason: messageOf(error)
380
+ };
381
+ }
382
+ try {
383
+ const info = await pending;
384
+ if (info.stopReason === "aborted") return { kind: "aborted" };
385
+ return {
386
+ kind: "ok",
387
+ childId,
388
+ turn: {
389
+ childId,
390
+ stopReason: info.stopReason ?? "completed",
391
+ text: textOfValue(info.lastAssistantMessage),
392
+ ...info.diagnostic !== void 0 ? { diagnostic: info.diagnostic } : {}
393
+ }
394
+ };
395
+ } catch (error) {
396
+ if (options?.signal?.aborted === true) return { kind: "aborted" };
397
+ return {
398
+ kind: "unavailable",
399
+ code: "E-323",
400
+ reason: messageOf(error)
401
+ };
402
+ } finally {
403
+ link.cleanup();
404
+ }
405
+ },
406
+ async dispose(childId) {
407
+ const current = active.get(childId);
408
+ if (current === void 0 || current.disposed) {
409
+ disposed.add(childId);
410
+ return;
411
+ }
412
+ current.disposed = true;
413
+ active.delete(childId);
414
+ disposed.add(childId);
415
+ try {
416
+ await current.activation.dispose();
417
+ } catch (error) {
418
+ try {
419
+ ctx.logger.warn("subagent conversation: dispose failed: " + messageOf(error));
420
+ } catch {}
421
+ }
182
422
  }
183
423
  };
184
424
  }
@@ -53,6 +53,12 @@ export declare const HOST_ROUTES: {
53
53
  readonly skillHealth: {
54
54
  readonly rows: "/api/dsh-evolution/skill-health/rows";
55
55
  };
56
+ readonly merge: {
57
+ readonly view: "/api/dsh-evolution/merge/view";
58
+ readonly rows: "/api/dsh-evolution/merge/rows";
59
+ readonly run: "/api/dsh-evolution/merge/run";
60
+ readonly runs: "/api/dsh-evolution/merge/runs";
61
+ };
56
62
  readonly diagnostics: {
57
63
  readonly view: "/api/dsh-evolution/diagnostics";
58
64
  };
@@ -50,6 +50,12 @@ export const HOST_ROUTES = {
50
50
  skillHealth: {
51
51
  rows: '/api/dsh-evolution/skill-health/rows',
52
52
  },
53
+ merge: {
54
+ view: '/api/dsh-evolution/merge/view',
55
+ rows: '/api/dsh-evolution/merge/rows',
56
+ run: '/api/dsh-evolution/merge/run',
57
+ runs: '/api/dsh-evolution/merge/runs',
58
+ },
53
59
  diagnostics: {
54
60
  view: '/api/dsh-evolution/diagnostics',
55
61
  },
@@ -58,6 +58,9 @@ export * from './quality.ts';
58
58
  export * from './redact.ts';
59
59
  export * from './review-channel.ts';
60
60
  export * from './run-registry.ts';
61
+ export * from './tree-lock.ts';
62
+ export * from './unit-undo-plan.ts';
63
+ export * from './unit-undo.ts';
61
64
  export * from './reports.ts';
62
65
  export * from './long-runs.ts';
63
66
  export * from './serial.ts';
@@ -59,6 +59,9 @@ export * from "./quality.js";
59
59
  export * from "./redact.js";
60
60
  export * from "./review-channel.js";
61
61
  export * from "./run-registry.js";
62
+ export * from "./tree-lock.js";
63
+ export * from "./unit-undo-plan.js";
64
+ export * from "./unit-undo.js";
62
65
  export * from "./reports.js";
63
66
  export * from "./long-runs.js";
64
67
  export * from "./serial.js";
@@ -48,7 +48,7 @@ export declare function readParam(carrier: object | undefined, id: string): unkn
48
48
  * belongs to, the tier says who may change it. `internal` here is a group id with no rows today;
49
49
  * the `why: 'internal'` a non-E3 row carries is a different value on the other axis (the reason it
50
50
  * has no settings card) — the shared spelling is a coincidence, not a routing rule. */
51
- export type ParamGroup = 'library' | 'write-caps' | 'review' | 'memory' | 'curator' | 'deployment' | 'internal';
51
+ export type ParamGroup = 'library' | 'write-caps' | 'review' | 'memory' | 'curator' | 'deployment' | 'merge' | 'internal';
52
52
  /**
53
53
  * Chinese display name per group, one entry per line (the scripts read this block
54
54
  * by text, the same way they read PARAM_NAMESPACES). The raw group id is what
@@ -91,6 +91,7 @@ export const PARAM_GROUP_LABELS = Object.freeze({
91
91
  memory: '记忆',
92
92
  curator: '技能整理',
93
93
  deployment: '安装配置',
94
+ merge: '技能合并',
94
95
  internal: '内部',
95
96
  });
96
97
  /**
@@ -203,6 +204,11 @@ export const PARAM_EXPOSURE = Object.freeze([
203
204
  { id: 'install.presetRowOverrides', group: 'deployment', tier: 'E4', authority: 'install', owner: 'scripts', applies: 'restart', docAnchor: 'PARAMETERS.md#deployment', summary: 'Preset row overrides the installer injects (row-overrides.json).', why: 'install' },
204
205
  { id: 'timeout.gateDefault', group: 'deployment', tier: 'E2', authority: 'install', owner: 'scripts', applies: 'none', docAnchor: 'PARAMETERS.md#deployment', summary: 'Gate budget: the vitest --testTimeout the gate table and the CI step carry (the number lives in testing-policy.timeouts).', why: 'internal' },
205
206
  { id: 'timeout.specDefault', group: 'deployment', tier: 'E2', authority: 'install', owner: 'scripts', applies: 'none', docAnchor: 'PARAMETERS.md#deployment', summary: 'Local spec budget: the vitest testTimeout the family config generates per leaf tier (the number lives in testing-policy.timeouts).', why: 'internal' },
207
+ { id: 'mergeApproval', group: 'merge', tier: 'E3', authority: 'cordis', owner: 'evolution-merge', applies: 'live', docAnchor: 'PARAMETERS.md#merge', summary: 'Pause after round 1 and put the pair in the pending queue instead of authoring round 2 automatically.', label: '合并审批', hint: '开启后,第一轮判定完先停下、进入待批准清单;关闭则直接进入第二轮合并。', control: 'switch', unit: '', values: '', card: 'evolution-merge' },
208
+ { id: 'mergeSkippedTtlDays', group: 'merge', tier: 'E3', authority: 'cordis', owner: 'evolution-merge', applies: 'live', docAnchor: 'PARAMETERS.md#merge', summary: 'Days a skipped pair stays out of screening; 0 means it never expires.', label: '无需合并保留天数', hint: '判定无需合并的对,多少天内不再参与筛选;0 表示一直保留。', control: 'number', unit: '天', values: '', card: 'evolution-merge' },
209
+ { id: 'mergeRunTimeoutMinutes', group: 'merge', tier: 'E3', authority: 'cordis', owner: 'evolution-merge', applies: 'live', docAnchor: 'PARAMETERS.md#merge', summary: 'Wall-clock budget of one merge run; 0 means no limit.', label: '单次运行上限', hint: '一次合并运行最多持续多少分钟;0 表示不设上限。', control: 'number', unit: '分钟', values: '', card: 'evolution-merge' },
210
+ { id: 'mergePairTimeoutMinutes', group: 'merge', tier: 'E2', authority: 'cordis', owner: 'evolution-merge', applies: 'none', docAnchor: 'PARAMETERS.md#merge', summary: 'Wall-clock budget of one pair round (a stuck child guard).', why: 'internal' },
211
+ { id: 'mergeModelsRoot', group: 'merge', tier: 'E2', authority: 'cordis', owner: 'evolution-merge', applies: 'none', docAnchor: 'PARAMETERS.md#merge', summary: 'Local model root containing the int8 and pca64 model directories; empty falls back to SKILL_MERGE_MODELS_ROOT. No auto provisioning.', why: 'path' },
206
212
  ]);
207
213
  /** The settings namespace each OWNER PACKAGE registers (design §7.2).
208
214
  *
@@ -223,6 +229,7 @@ export const PARAM_NAMESPACES = Object.freeze({
223
229
  // 0.21.0 (G3): the commands row's handoff switch is E3 — same reason as the entry above: the card
224
230
  // and `/evolution params set` both resolve their settings namespace through this map.
225
231
  'evolution-commands': 'evolution-commands',
232
+ 'evolution-merge': 'evolution-merge',
226
233
  });
227
234
  /**
228
235
  * The rows whose settings namespace CHANGED with the platform-line move (G3): the
@@ -20,7 +20,7 @@ export interface PlatformServiceProbe {
20
20
  /** Where the probe runs. */
21
21
  readonly side: 'host' | 'client';
22
22
  }
23
- /** The 25 probes, in service-name order (the guard checks this list against the tree). */
23
+ /** The 26 probes, in service-name order (the guard checks this list against the tree). */
24
24
  export declare const PLATFORM_SERVICE_PROBES: readonly PlatformServiceProbe[];
25
25
  /**
26
26
  * The HOST-side probes that do not resolve in this runtime — what `/evolution doctor` prints as
@@ -11,7 +11,7 @@
11
11
  * `client` probes run in the browser half, where the host has no view of the seats.
12
12
  * @module @lmzhen/dsh-evolution-core
13
13
  */
14
- /** The 25 probes, in service-name order (the guard checks this list against the tree). */
14
+ /** The 26 probes, in service-name order (the guard checks this list against the tree). */
15
15
  export const PLATFORM_SERVICE_PROBES = Object.freeze([
16
16
  { service: 'agents', feature: 'review and curator subagent wake-ups', side: 'host' },
17
17
  { service: 'approval', feature: 'the platform approval policy the approval row shadows', side: 'host' },
@@ -22,6 +22,7 @@ export const PLATFORM_SERVICE_PROBES = Object.freeze([
22
22
  { service: 'evolutionIo', feature: 'every durable read and write (the IO seam)', side: 'host' },
23
23
  { service: 'hmr', feature: 'the preset install read-merge-write commit (the platform HMR lock serializes it)', side: 'host' },
24
24
  { service: 'evolutionMemoryBudget', feature: 'the memory-budget divergence section of this report', side: 'host' },
25
+ { service: 'evolutionMerge', feature: 'the merge command and its read-only panel routes', side: 'host' },
25
26
  { service: 'evolutionPolicy', feature: 'the policy snapshot that shadows row knobs', side: 'host' },
26
27
  { service: 'evolutionReplay', feature: '/evolution replay', side: 'host' },
27
28
  { service: 'evolutionState', feature: 'the state facade behind activity and replay', side: 'host' },
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 0.19.0 (S2) / 0.19.3 (restructure): the family's run-lifecycle owner.
3
3
  *
4
- * A "run" is work that can outlive one interaction — today the maintenance scan.
4
+ * A "run" is work that can outlive one interaction — the maintenance scan and merge workflows.
5
5
  * Before this module the only record of such work was the requesting invocation:
6
6
  * `lastMaintainAt` / `lastMaintainRunId` / `maintainInFlightSince` were
7
7
  * module-scope globals in evolution-commands, and the plan text existed ONLY in
@@ -38,8 +38,17 @@
38
38
  * @module
39
39
  */
40
40
  import type { EvolutionIoLike } from './io.ts';
41
- /** The kinds of long-running work the registry tracks. Extend as rows gain runs. */
42
- export type RunKind = 'maintain';
41
+ /** The kinds of long-running work the registry tracks. */
42
+ export type RunKind = 'maintain' | 'merge';
43
+ /** Which merge command opened a run. `kind` stays the work family; this is the command. */
44
+ export type RunCommand = 'scan' | 'pair' | 'approve' | 'resume';
45
+ /** Coarse, user-visible stage of a run. */
46
+ export type RunStage = 'screening' | 'decide' | 'author' | 'commit' | 'settle';
47
+ /** Progress of a run's current stage. */
48
+ export interface RunProgress {
49
+ readonly done: number;
50
+ readonly total: number;
51
+ }
43
52
  /** The four terminal-or-live states of a run. */
44
53
  export type RunState = 'running' | 'succeeded' | 'failed' | 'cancelled';
45
54
  /** One tracked run. `resultRef` is a path to the run's report, never its content. */
@@ -48,6 +57,11 @@ export interface RunRecord {
48
57
  kind: RunKind;
49
58
  state: RunState;
50
59
  startedAt: number;
60
+ /** The merge command that opened this run, when the kind is merge. */
61
+ command?: RunCommand | undefined;
62
+ /** The stage the owner last reported. */
63
+ stage?: RunStage | undefined;
64
+ progress?: RunProgress | undefined;
51
65
  /** The process that owns a RUNNING record. Required to tell an in-flight run of
52
66
  * another plane (both planes share one DSH_HOME) from the orphan of a dead one;
53
67
  * absent means the owner cannot be named, which is treated as gone. */
@@ -57,6 +71,10 @@ export interface RunRecord {
57
71
  resultRef?: string | undefined;
58
72
  /** Why the run did not succeed (an abort reason, a validation failure). */
59
73
  failure?: string | undefined;
74
+ /** Set when a tree restore made this run's effects un-undoable. */
75
+ supersededAt?: number | undefined;
76
+ /** The run this run was spawned from (approve/resume after an earlier command). */
77
+ parentRunId?: string | undefined;
60
78
  }
61
79
  /** What a started run gets: its id and the signal its work must honour. */
62
80
  export interface RunHandle {
@@ -65,6 +83,8 @@ export interface RunHandle {
65
83
  readonly signal: AbortSignal;
66
84
  /** When the run was registered — the same instant the record carries. */
67
85
  readonly startedAt: number;
86
+ readonly command?: RunCommand | undefined;
87
+ readonly parentRunId?: string | undefined;
68
88
  }
69
89
  /** How a run ended, as reported by its owner. */
70
90
  export interface RunOutcome {
@@ -90,15 +110,25 @@ export interface RunRegistry {
90
110
  * lifetime starts when the caller answers "started", so a plane that reads that answer
91
111
  * must find the run. The cost is one local index write on the ack path (milliseconds).
92
112
  */
93
- begin(kind: RunKind): Promise<RunHandle>;
113
+ begin(kind: RunKind, seed?: {
114
+ command?: RunCommand;
115
+ total?: number;
116
+ parentRunId?: string;
117
+ }): Promise<RunHandle>;
94
118
  /** Record a run's terminal state (first writer wins). */
95
119
  settle(id: string, outcome: RunOutcome): Promise<void>;
96
- /** The live run, when there is one (one kind today — see the implementation note). */
97
- inFlight(): RunRecord | undefined;
98
- /** When the newest terminal run ended (`undefined` when none). */
99
- lastSettledAt(): number | undefined;
120
+ /** Record the current stage/progress of a running run. */
121
+ stage(id: string, stage: RunStage, progress?: RunProgress): Promise<void>;
122
+ /** Mark runs as superseded by a tree restore; idempotent. */
123
+ supersede(ids: readonly string[]): Promise<void>;
124
+ /** Mark every run of a kind in the SHARED index (including foreign rows); returns the count. */
125
+ supersedeKind(kind: RunKind): Promise<number>;
126
+ /** The live run, optionally filtered by kind. */
127
+ inFlight(kind?: RunKind): RunRecord | undefined;
128
+ /** When the newest terminal run of a kind ended (`undefined` when none). */
129
+ lastSettledAt(kind?: RunKind): number | undefined;
100
130
  /** Every tracked run, newest first (retained history: cap applied). */
101
- runs(): readonly RunRecord[];
131
+ runs(kind?: RunKind): readonly RunRecord[];
102
132
  /** One run by id, converged view included. */
103
133
  find(id: string): RunRecord | undefined;
104
134
  /** Ask a running run to stop; it settles as `cancelled`. @returns whether it was running. */
@@ -119,7 +149,7 @@ export interface RunRegistryOptions {
119
149
  maxRecords?: number;
120
150
  }
121
151
  /** Current index schema. Bump only for a structural change. */
122
- export declare const RUNS_SCHEMA_VERSION = 1;
152
+ export declare const RUNS_SCHEMA_VERSION = 2;
123
153
  /** Retained run records, newest first. */
124
154
  export declare const DEFAULT_RUN_RECORDS = 50;
125
155
  /** Index path for a home. */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 0.19.0 (S2) / 0.19.3 (restructure): the family's run-lifecycle owner.
3
3
  *
4
- * A "run" is work that can outlive one interaction — today the maintenance scan.
4
+ * A "run" is work that can outlive one interaction — the maintenance scan and merge workflows.
5
5
  * Before this module the only record of such work was the requesting invocation:
6
6
  * `lastMaintainAt` / `lastMaintainRunId` / `maintainInFlightSince` were
7
7
  * module-scope globals in evolution-commands, and the plan text existed ONLY in
@@ -42,7 +42,7 @@ import { join } from 'node:path';
42
42
  import { clampedNumber } from "./numeric.js";
43
43
  import { isProcessAlive, transactIo } from "./io.js";
44
44
  /** Current index schema. Bump only for a structural change. */
45
- export const RUNS_SCHEMA_VERSION = 1;
45
+ export const RUNS_SCHEMA_VERSION = 2;
46
46
  /** Retained run records, newest first. */
47
47
  export const DEFAULT_RUN_RECORDS = 50;
48
48
  /** Index path for a home. */
@@ -99,10 +99,17 @@ function parseIndex(raw) {
99
99
  kind: record.kind,
100
100
  state: record.state,
101
101
  startedAt: record.startedAt,
102
+ ...(record.command === undefined ? {} : { command: record.command }),
103
+ ...(record.stage === undefined ? {} : { stage: record.stage }),
104
+ ...(record.progress === undefined
105
+ ? {}
106
+ : { progress: { done: record.progress.done, total: record.progress.total } }),
102
107
  ...(typeof record.pid === 'number' ? { pid: record.pid } : {}),
103
108
  ...(typeof record.endedAt === 'number' ? { endedAt: record.endedAt } : {}),
104
109
  ...(typeof record.resultRef === 'string' ? { resultRef: record.resultRef } : {}),
105
110
  ...(typeof record.failure === 'string' ? { failure: record.failure } : {}),
111
+ ...(typeof record.supersededAt === 'number' ? { supersededAt: record.supersededAt } : {}),
112
+ ...(typeof record.parentRunId === 'string' ? { parentRunId: record.parentRunId } : {}),
106
113
  });
107
114
  }
108
115
  return { ok: true, records, foreign, ...(notes.length > 0 ? { note: notes.join('; ') } : {}) };
@@ -131,14 +138,20 @@ export function newRunRegistry(options) {
131
138
  const orphanOf = (row) => ({ ...row, state: 'failed', endedAt: now(), failure: ORPHAN_FAILURE });
132
139
  /** Every row this process knows, newest first. ALL reads go through here. */
133
140
  const view = () => [...owned.values(), ...foreign.values()].sort((a, b) => b.startedAt - a.startedAt);
134
- /** Retained history: every running row plus the newest terminal ones (one cap, both planes). */
141
+ /** Retained history: every running row plus the newest terminal rows per kind. */
135
142
  const capped = (rows) => {
136
143
  const live = rows.filter(row => row.state === 'running');
137
144
  const done = rows
138
145
  .filter(row => TERMINAL.has(row.state))
139
- .sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt))
140
- .slice(0, maxRecords);
141
- return [...live, ...done].sort((a, b) => b.startedAt - a.startedAt);
146
+ .sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt));
147
+ const perKind = new Map();
148
+ for (const row of done) {
149
+ const kept = perKind.get(row.kind) ?? [];
150
+ if (kept.length < maxRecords)
151
+ kept.push(row);
152
+ perKind.set(row.kind, kept);
153
+ }
154
+ return [...live, ...[...perKind.values()].flat()].sort((a, b) => b.startedAt - a.startedAt);
142
155
  };
143
156
  /**
144
157
  * Update a surviving foreign row in place (identity stable), or add it.
@@ -159,9 +172,14 @@ export function newRunRegistry(options) {
159
172
  if (TERMINAL.has(existing.state) && !TERMINAL.has(row.state))
160
173
  return true;
161
174
  // Clear what the new row dropped, so nothing is inherited from the previous view.
175
+ delete existing.command;
176
+ delete existing.stage;
177
+ delete existing.progress;
162
178
  delete existing.endedAt;
163
179
  delete existing.resultRef;
164
180
  delete existing.failure;
181
+ delete existing.supersededAt;
182
+ delete existing.parentRunId;
165
183
  Object.assign(existing, row);
166
184
  return false;
167
185
  };
@@ -269,18 +287,77 @@ export function newRunRegistry(options) {
269
287
  ].filter((note) => note !== undefined);
270
288
  return { ok: true, ...(notes.length > 0 ? { note: notes.join('; ') } : {}) };
271
289
  },
272
- async begin(kind) {
290
+ async begin(kind, seed) {
273
291
  const id = randomUUID();
274
292
  const startedAt = now();
275
293
  const controller = new AbortController();
276
294
  controllers.set(id, controller);
277
295
  // The pid is what lets ANOTHER process tell this live run from an orphan.
278
- owned.set(id, { id, kind, state: 'running', startedAt, pid: process.pid });
296
+ owned.set(id, {
297
+ id,
298
+ kind,
299
+ state: 'running',
300
+ startedAt,
301
+ pid: process.pid,
302
+ ...(seed?.command !== undefined ? { command: seed.command } : {}),
303
+ ...(seed?.parentRunId !== undefined ? { parentRunId: seed.parentRunId } : {}),
304
+ ...(seed?.total !== undefined ? { progress: { done: 0, total: seed.total } } : {}),
305
+ });
279
306
  // Write-through (0.19.1, review P2): answering "started" promises a run another
280
307
  // plane can read, so the row reaches the index before this resolves. `persist()`
281
308
  // warns instead of throwing, so an unwritable home cannot withhold the handle.
282
309
  await persist();
283
- return { id, kind, signal: controller.signal, startedAt };
310
+ return {
311
+ id,
312
+ kind,
313
+ signal: controller.signal,
314
+ startedAt,
315
+ ...(seed?.command !== undefined ? { command: seed.command } : {}),
316
+ ...(seed?.parentRunId !== undefined ? { parentRunId: seed.parentRunId } : {}),
317
+ };
318
+ },
319
+ async stage(id, stage, progress) {
320
+ const row = owned.get(id);
321
+ if (row === undefined || row.state !== 'running')
322
+ return;
323
+ row.stage = stage;
324
+ if (progress !== undefined)
325
+ row.progress = { done: progress.done, total: progress.total };
326
+ await persist();
327
+ },
328
+ async supersede(ids) {
329
+ let changed = false;
330
+ for (const id of new Set(ids)) {
331
+ const row = owned.get(id);
332
+ if (row === undefined || row.supersededAt !== undefined)
333
+ continue;
334
+ row.supersededAt = now();
335
+ changed = true;
336
+ }
337
+ if (changed)
338
+ await persist();
339
+ },
340
+ async supersedeKind(kind) {
341
+ let marked = 0;
342
+ await transactIo(options.io, path, (current) => {
343
+ const onDisk = parseIndex(current);
344
+ const merged = new Map();
345
+ for (const row of onDisk.records)
346
+ merged.set(row.id, row);
347
+ for (const [id, row] of owned)
348
+ merged.set(id, row);
349
+ for (const row of merged.values()) {
350
+ if (row.kind !== kind || row.supersededAt !== undefined)
351
+ continue;
352
+ row.supersededAt = now();
353
+ marked += 1;
354
+ }
355
+ const kept = capped([...merged.values()]);
356
+ const live = kept.filter(row => row.state === 'running');
357
+ const done = kept.filter(row => TERMINAL.has(row.state));
358
+ return JSON.stringify({ schemaVersion: RUNS_SCHEMA_VERSION, runs: [...live, ...done, ...onDisk.foreign] }, null, 2);
359
+ });
360
+ return marked;
284
361
  },
285
362
  async settle(id, outcome) {
286
363
  const row = owned.get(id);
@@ -297,17 +374,14 @@ export function newRunRegistry(options) {
297
374
  controllers.delete(id);
298
375
  await persist();
299
376
  },
300
- // The registry tracks ONE kind today (`RunKind` = 'maintain'), so these reads take no
301
- // kind filter — a comparison the type system already knows is true. Re-add the
302
- // parameter (and the filter) together with the second `RunKind` member.
303
- inFlight() {
304
- return view().find(row => row.state === 'running');
377
+ inFlight(kind) {
378
+ return view().find(row => row.state === 'running' && (kind === undefined || row.kind === kind));
305
379
  },
306
- lastSettledAt() {
307
- return view().find(row => TERMINAL.has(row.state))?.endedAt;
380
+ lastSettledAt(kind) {
381
+ return view().find(row => TERMINAL.has(row.state) && (kind === undefined || row.kind === kind))?.endedAt;
308
382
  },
309
- runs() {
310
- return capped(view());
383
+ runs(kind) {
384
+ return kind === undefined ? capped(view()) : capped(view().filter(row => row.kind === kind));
311
385
  },
312
386
  find(id) {
313
387
  return owned.get(id) ?? foreign.get(id);
@@ -296,7 +296,7 @@ export declare function loadVersionContent(root: string, io: EvolutionIoLike, na
296
296
  */
297
297
  export declare function recordVersions(root: string, io: EvolutionIoLike, input: VersionRecordInput, keep: number): Promise<RecordedVersions | null>;
298
298
  /** What kind of change one recorded version carries, as a CLOSED set. */
299
- export type VersionActionKind = 'baseline' | 'create' | 'patch' | 'update' | 'restore' | 'delete' | 'archive' | 'consolidate' | 'restructure' | 'support-write' | 'support-remove' | 'other';
299
+ export type VersionActionKind = 'baseline' | 'create' | 'patch' | 'update' | 'restore' | 'delete' | 'archive' | 'consolidate' | 'restructure' | 'merge' | 'support-write' | 'support-remove' | 'other';
300
300
  /**
301
301
  * Classify one version action for display. Fail-closed like {@link versionTarget}: an action this
302
302
  * module does not know reads as `other` rather than being guessed, and `tests/skill-history.spec.ts`