@lmzhen/dsh-evolution-merge 0.32.2 → 0.32.4

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/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { pairKeyOf, screen, selectCandidates } from "@lmzhen/dsh-evolution-merge-engine";
4
- import { DEFAULT_SKILL_LIMITS, SkillLibrary, UnitTransactions, authorityFrom, contentHash, createSubagentChannel, errorText, evolutionHome, evolutionIoAdapter, executeUnitUndo, isPresent, isProcessAlive, newRunRegistry, newSkillLibrary, parseFrontmatter, planUnitUndoAcross, policyStageLimits, probeAbsent, probeList, probePresent, probeText, probeUnknown, readTreeLock, readUnitEvidence, reportsSweepLockTarget, revisionAnchorOf, revisionFingerprint, revisionSupportDigest, runsFile, scanContentThreats, skillLibraryUnitBackend, supportRefs, sweepReports, transactIo, validateFrontmatter, validateSupportPath } from "@lmzhen/dsh-evolution-core";
4
+ import { DEFAULT_SKILL_LIMITS, SkillLibrary, UnitTransactions, authorityFrom, contentHash, createSubagentChannel, errorText, evolutionHome, evolutionIoAdapter, executeUnitUndo, isPresent, isProcessAlive, newRunRegistry, newSkillLibrary, parseFrontmatter, planUnitUndoAcross, policyStageLimits, probeAbsent, probeList, probePresent, probeText, probeUnknown, readTreeLock, readUnitEvidence, readUnitJournal, reportsSweepLockTarget, revisionAnchorOf, revisionFingerprint, revisionSupportDigest, runsFile, scanContentThreats, skillLibraryUnitBackend, supportRefs, sweepReports, transactIo, validateFrontmatter, validateSupportPath } from "@lmzhen/dsh-evolution-core";
5
5
  import { HOST_ROUTES, json, methodNotAllowed, registerHostRoutes } from "@lmzhen/dsh-evolution-core/http-routes";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { chmod, mkdir, open, readFile, stat, unlink } from "node:fs/promises";
@@ -70,11 +70,12 @@ function createEngineScreening(deps) {
70
70
  return async (cards, runId, signal) => {
71
71
  const corpusDir = await snapshotCards(deps.io, deps.home, runId, cards);
72
72
  const outDir = join(deps.home, "merge", "runs", runId, "engine");
73
+ const runner = deps.runner === void 0 ? void 0 : (prompt, cfg) => deps.runner(prompt, cfg, signal);
73
74
  const result = await deps.engine.screen({
74
75
  corpusDir,
75
76
  outDir,
76
77
  ...deps.cfg === void 0 ? {} : { cfg: deps.cfg },
77
- ...deps.runner === void 0 ? {} : { runner: deps.runner },
78
+ ...runner === void 0 ? {} : { runner },
78
79
  signal
79
80
  });
80
81
  if (!result.ok) throw new Error("engine screen failed: " + result.error.code + (result.error.stage === void 0 ? "" : " @" + result.error.stage) + (result.error.detail === void 0 ? "" : ": " + result.error.detail));
@@ -110,8 +111,11 @@ function append(chunk, text) {
110
111
  return text;
111
112
  }
112
113
  function createEngineRunner(llm, route) {
113
- return async (prompt) => {
114
+ return async (prompt, cfg, runSignal) => {
114
115
  const selection = route();
116
+ const timeout = Number(cfg?.timeoutMs);
117
+ const timeoutSignal = Number.isFinite(timeout) && timeout > 0 ? AbortSignal.timeout(timeout) : void 0;
118
+ const signal = runSignal === void 0 ? timeoutSignal : timeoutSignal === void 0 ? runSignal : typeof AbortSignal.any === "function" ? AbortSignal.any([runSignal, timeoutSignal]) : runSignal;
115
119
  let text = "";
116
120
  if (llm.stream !== void 0) {
117
121
  for await (const chunk of llm.stream({
@@ -124,7 +128,8 @@ function createEngineRunner(llm, route) {
124
128
  text: prompt
125
129
  }]
126
130
  }],
127
- temperature: 0
131
+ temperature: 0,
132
+ ...signal === void 0 ? {} : { signal }
128
133
  })) text = append(chunk, text);
129
134
  return text;
130
135
  }
@@ -133,7 +138,7 @@ function createEngineRunner(llm, route) {
133
138
  provider: normalizeProviderKey(selection.provider),
134
139
  model: selection.model,
135
140
  temperature: 0
136
- });
141
+ }, signal);
137
142
  for await (const chunk of call.stream({
138
143
  ...call.config,
139
144
  messages: [{
@@ -142,7 +147,8 @@ function createEngineRunner(llm, route) {
142
147
  type: "text",
143
148
  text: prompt
144
149
  }]
145
- }]
150
+ }],
151
+ ...signal === void 0 ? {} : { signal }
146
152
  })) text = append(chunk, text);
147
153
  return text;
148
154
  };
@@ -633,6 +639,11 @@ function transitionV4(row, event, expectedVersion, at = (/* @__PURE__ */ new Dat
633
639
  };
634
640
  return illegal("pending/" + event.type);
635
641
  case "approved":
642
+ if (event.type === "run-attached") return {
643
+ ok: true,
644
+ row: advance(row, at, { runId: event.runId }),
645
+ version: expectedVersion + 1
646
+ };
636
647
  if (event.type === "staged") return {
637
648
  ok: true,
638
649
  row: advance(row, at, {
@@ -672,6 +683,11 @@ function transitionV4(row, event, expectedVersion, at = (/* @__PURE__ */ new Dat
672
683
  };
673
684
  return illegal("approved/" + event.type);
674
685
  case "fuse":
686
+ if (event.type === "run-attached") return {
687
+ ok: true,
688
+ row: advance(row, at, { runId: event.runId }),
689
+ version: expectedVersion + 1
690
+ };
675
691
  if (event.type === "committed") return {
676
692
  ok: true,
677
693
  row: null,
@@ -2334,7 +2350,7 @@ async function cleanupStage(io, home, now, rows, limit = 100) {
2334
2350
  const refs = /* @__PURE__ */ new Set();
2335
2351
  const indexRoot = join(home, "merge", "staging-index");
2336
2352
  const names = await io.exists(indexRoot) ? await listOrEmpty(io, indexRoot) : [];
2337
- for (const name of names.slice(0, limit)) {
2353
+ for (const name of names) {
2338
2354
  if (!name.endsWith(".json")) continue;
2339
2355
  const raw = await readTextOrNull(io, join(indexRoot, name));
2340
2356
  if (raw === null) continue;
@@ -3769,7 +3785,7 @@ function parseCursor(cursor, snapshot) {
3769
3785
  }
3770
3786
  }
3771
3787
  function buildQueuePage(snapshot, queue, expiredOnly, cursor, limit = 50) {
3772
- const selected = snapshot.rows.filter((row) => row.queue === queue && (queue === "skipped" ? true : expiredOnly ? row.expired : !row.expired));
3788
+ const selected = snapshot.rows.filter((row) => row.queue === queue && (expiredOnly ? row.expired : !row.expired));
3773
3789
  const offset = cursor === void 0 ? 0 : parseCursor(cursor, snapshot);
3774
3790
  const page = selected.slice(offset, offset + Math.max(0, Math.min(200, limit)));
3775
3791
  const next = offset + page.length < selected.length ? encodeCursor(snapshot, offset + page.length) : void 0;
@@ -4168,9 +4184,6 @@ async function mergeDoctor(io, home, configuredRoot) {
4168
4184
  for (const report of JSON.parse(raw)) if (report.ok === true && report.runId !== void 0) recoveryRuns.add(report.runId);
4169
4185
  }
4170
4186
  } catch {}
4171
- const receiptRuns = /* @__PURE__ */ new Set();
4172
- const receiptDir = await probeList(io, join(home, "merge", "receipts"));
4173
- if (receiptDir.kind === "present") for (const name of receiptDir.value) receiptRuns.add(name);
4174
4187
  const invalid = [];
4175
4188
  if (snapshot.blocked !== void 0) invalid.push({
4176
4189
  id: "state",
@@ -4182,13 +4195,14 @@ async function mergeDoctor(io, home, configuredRoot) {
4182
4195
  const artifactRow = row.state === "approved" ? "approved" : row.state === "fuse" ? "fuse" : row.state;
4183
4196
  const run = runId === void 0 ? "none" : row.state === "pending" ? "awaiting-approval" : "running";
4184
4197
  const staging = item.stage === null ? row.state === "fuse" ? "missing" : "none" : item.stage.state === "present" || item.stage.state === "reserved" ? "kept" : row.state === "fuse" ? "missing" : "none";
4185
- const phase = runId === void 0 ? null : await readJournal(io, home, runId);
4198
+ const receipt = runId === void 0 ? null : await readCommitReceipt(io, home, runId);
4199
+ const phase = receipt !== null ? await readUnitJournal(io, home, receipt.unitId) : runId === void 0 ? null : await readJournal(io, home, runId);
4186
4200
  const result = validateArtifacts({
4187
4201
  row: artifactRow,
4188
4202
  run,
4189
4203
  staging,
4190
- journal: phase === null ? "none" : phase.phase === "prepared" ? "prepared" : phase.phase === "target-written" || phase.phase === "sources-archived" ? "committing" : phase.phase === "committed" ? "committed" : phase.phase === "rolled-back" ? "rolled-back" : "failed",
4191
- evidence: runId !== void 0 && await readTextOrNull(io, join(home, "merge", "receipts", runId + ".json")) !== null ? "receipt" : "none",
4204
+ journal: phase === null ? "none" : phase.phase === "prepared" || phase.phase === "target-pending" ? "prepared" : phase.phase === "target-written" || phase.phase === "sources-archived" || phase.phase === "support-pending" || phase.phase === "source-archiving" ? "committing" : phase.phase === "committed" ? "committed" : phase.phase === "rolled-back" ? "rolled-back" : "failed",
4205
+ evidence: receipt === null ? "none" : "receipt",
4192
4206
  activeRuns: run === "running" || run === "awaiting-approval" ? 1 : 0,
4193
4207
  ...runId !== void 0 && recoveryRuns.has(runId) ? { recovery: true } : {}
4194
4208
  });
@@ -4628,6 +4642,18 @@ async function recoverCommitUnits(options) {
4628
4642
  journal = null;
4629
4643
  }
4630
4644
  if (journal === null || typeof journal.unitId !== "string") continue;
4645
+ if (journal.kind === "tree-change" || journal.kind === void 0 && journal.runId === journal.unitId) {
4646
+ reports.push({
4647
+ unitId: journal.unitId,
4648
+ runId: journal.runId,
4649
+ phase: journal.phase,
4650
+ branch: branchOf(journal.phase),
4651
+ action: "none",
4652
+ ok: true,
4653
+ code: "skill-unit-skipped"
4654
+ });
4655
+ continue;
4656
+ }
4631
4657
  if (options.isActive?.(journal.runId) === true) {
4632
4658
  reports.push({
4633
4659
  unitId: journal.unitId,
@@ -6213,6 +6239,24 @@ async function withMergeRun(options) {
6213
6239
  createdAt: new Date(startedAt).toISOString(),
6214
6240
  updatedAt: new Date(options.now()).toISOString()
6215
6241
  });
6242
+ const writeFailureMeta = async (stopReason) => {
6243
+ try {
6244
+ await writeRunMeta(options.io, options.home, {
6245
+ version: 1,
6246
+ runId: handle.id,
6247
+ pid: process.pid,
6248
+ ...options.parentRunId === void 0 ? {} : { parentRunId: options.parentRunId },
6249
+ ...options.pairKey === void 0 ? {} : { pairKey: options.pairKey },
6250
+ ...options.stagingRef === void 0 ? {} : { stagingRef: options.stagingRef },
6251
+ pairCount: options.pairCount ?? (options.pairKey === void 0 ? 0 : 1),
6252
+ phase: "settled",
6253
+ approval: "approved",
6254
+ stopReason,
6255
+ createdAt: new Date(startedAt).toISOString(),
6256
+ updatedAt: new Date(options.now()).toISOString()
6257
+ });
6258
+ } catch {}
6259
+ };
6216
6260
  const finish = async () => {
6217
6261
  let draft;
6218
6262
  const runController = new AbortController();
@@ -6304,6 +6348,7 @@ async function withMergeRun(options) {
6304
6348
  state: "failed",
6305
6349
  failure: "report write failed: " + (error instanceof Error ? error.message : String(error))
6306
6350
  });
6351
+ await writeFailureMeta("failed");
6307
6352
  return {
6308
6353
  ok: false,
6309
6354
  runId: handle.id,
@@ -6318,6 +6363,7 @@ async function withMergeRun(options) {
6318
6363
  resultRef: reportPath,
6319
6364
  failure: "post-report finalize failed: " + (error instanceof Error ? error.message : String(error))
6320
6365
  });
6366
+ await writeFailureMeta("failed");
6321
6367
  return {
6322
6368
  ok: false,
6323
6369
  runId: handle.id,
@@ -6467,6 +6513,19 @@ async function rejectPending(io, home, now, id) {
6467
6513
  return await act(io, home, now, id, "reject");
6468
6514
  }
6469
6515
  //#endregion
6516
+ //#region lib/types/domain/row-run.js
6517
+ /** Attach the in-flight child run to a row before its round2 starts (P1-8). */
6518
+ async function attachRunToRow(io, home, rowId, runId) {
6519
+ if (!await pairLedger(io, home).transact((tx) => {
6520
+ const current = tx.rows().find((row) => row.id === rowId);
6521
+ if (current === void 0) return false;
6522
+ return tx.transition(rowId, {
6523
+ type: "run-attached",
6524
+ runId
6525
+ }, current.version).ok;
6526
+ })) throw new Error("row is not attachable to run: " + rowId);
6527
+ }
6528
+ //#endregion
6470
6529
  //#region lib/types/domain/failure-route.js
6471
6530
  function casExpiredHook(code) {
6472
6531
  if (code === "target-changed" || code === "target-taken") return {
@@ -6749,6 +6808,7 @@ async function runApproveCommand(options) {
6749
6808
  now: options.now,
6750
6809
  ...options.detached === true ? { detached: true } : {},
6751
6810
  async body(ctx) {
6811
+ await attachRunToRow(options.io, options.home, row.id, ctx.runId);
6752
6812
  const stage = (name) => ctx.stage(name, {
6753
6813
  done: 0,
6754
6814
  total: 1
@@ -6882,6 +6942,7 @@ async function runResumeCommand(options) {
6882
6942
  now: options.now,
6883
6943
  ...options.detached === true ? { detached: true } : {},
6884
6944
  async body(ctx) {
6945
+ await attachRunToRow(options.io, options.home, row.id, ctx.runId);
6885
6946
  await ctx.stage("author", {
6886
6947
  done: 0,
6887
6948
  total: 1
@@ -7927,12 +7988,6 @@ async function assembleStagedCommit(io, home, runId, port, options) {
7927
7988
  });
7928
7989
  }
7929
7990
  //#endregion
7930
- //#region lib/types/commit-snapshot.js
7931
- /** Pre-commit snapshot: enough to restore the target and archived sources after a crash. */
7932
- function snapshotPath(home, runId) {
7933
- return join(home, "merge", "snapshots", runId.replace(/[^A-Za-z0-9_-]+/g, "_") + ".json");
7934
- }
7935
- //#endregion
7936
7991
  //#region lib/types/commit-execute.js
7937
7992
  async function commitStagedRun(io, home, runId, port, options) {
7938
7993
  const assembled = await assembleStagedCommit(io, home, runId, port, {
@@ -7973,7 +8028,7 @@ async function commitStagedRun(io, home, runId, port, options) {
7973
8028
  {
7974
8029
  const first = assembled.plan.sources[0] ?? assembled.plan.target;
7975
8030
  const unitPlan = {
7976
- runId,
8031
+ runId: options.parentRunId ?? runId,
7977
8032
  pairKey: pairKey(assembled.plan.target, first),
7978
8033
  target: assembled.plan.target,
7979
8034
  targetExists: assembled.plan.targetExists,
@@ -8079,9 +8134,6 @@ async function commitStagedRun(io, home, runId, port, options) {
8079
8134
  };
8080
8135
  try {
8081
8136
  await writeCommitReceipt(io, home, receipt);
8082
- try {
8083
- await io.remove(snapshotPath(home, runId));
8084
- } catch {}
8085
8137
  return {
8086
8138
  ok: true,
8087
8139
  plan: assembled.plan,
@@ -8218,7 +8270,9 @@ function buildValidationPlan(input) {
8218
8270
  const manifest = new Set(manifestFiles);
8219
8271
  const forwardRefs = [...refs].filter((ref) => !staged.has(ref)).sort();
8220
8272
  const reverseRefs = files.map((file) => file.path).filter((path) => path !== "SKILL.md" && !refs.has(path) && manifest.has(path)).sort();
8221
- const orphanFiles = [...new Set(input.existingSupport ?? [])].filter((path) => !staged.has(path)).sort();
8273
+ const existing = new Set(input.existingSupport ?? []);
8274
+ const orphanFiles = [...existing].filter((path) => !staged.has(path)).sort();
8275
+ const undeclaredFiles = [...staged].filter((path) => path !== "SKILL.md" && !manifest.has(path) && !existing.has(path)).sort();
8222
8276
  const byPath = /* @__PURE__ */ new Map();
8223
8277
  const supportSources = input.supportSources ?? [];
8224
8278
  for (const file of supportSources) {
@@ -8249,7 +8303,8 @@ function buildValidationPlan(input) {
8249
8303
  q09: {
8250
8304
  forwardRefs,
8251
8305
  reverseRefs,
8252
- orphanFiles
8306
+ orphanFiles,
8307
+ undeclaredFiles
8253
8308
  },
8254
8309
  supportConflicts,
8255
8310
  threat,
@@ -8276,6 +8331,12 @@ function buildValidationPlan(input) {
8276
8331
  severity: "block",
8277
8332
  message: "target tree support is not declared by plan"
8278
8333
  });
8334
+ if (undeclaredFiles.length > 0) findings.push({
8335
+ rule: "q09-undeclared",
8336
+ path: undeclaredFiles.join(","),
8337
+ severity: "block",
8338
+ message: "staged support file is not declared by the authoring manifest"
8339
+ });
8279
8340
  if (supportConflicts.length > 0) findings.push({
8280
8341
  rule: "support-conflict",
8281
8342
  path: supportConflicts.map((item) => item.path).join(","),
@@ -8317,7 +8378,8 @@ function buildValidationPlan(input) {
8317
8378
  q09: {
8318
8379
  forwardRefs,
8319
8380
  reverseRefs,
8320
- orphanFiles
8381
+ orphanFiles,
8382
+ undeclaredFiles
8321
8383
  },
8322
8384
  supportConflicts,
8323
8385
  threat,
@@ -8332,28 +8394,80 @@ function buildValidationPlan(input) {
8332
8394
  }
8333
8395
  //#endregion
8334
8396
  //#region lib/types/domain/authoring-manifest.js
8397
+ function asManifest(value) {
8398
+ if (value === null || typeof value !== "object") return null;
8399
+ const record = value;
8400
+ if (!Array.isArray(record.files) || record.files.some((file) => typeof file !== "string")) return null;
8401
+ if (record.target !== void 0 && typeof record.target !== "string") return null;
8402
+ return {
8403
+ ...typeof record.target === "string" ? { target: record.target } : {},
8404
+ files: record.files
8405
+ };
8406
+ }
8407
+ /** Every balanced object literal in a prose/fenced answer (string-aware). */
8408
+ function balancedObjects(text) {
8409
+ const found = [];
8410
+ let depth = 0;
8411
+ let start = -1;
8412
+ let quoted = false;
8413
+ let escaped = false;
8414
+ for (let index = 0; index < text.length; index += 1) {
8415
+ const char = text[index];
8416
+ if (quoted) {
8417
+ if (escaped) escaped = false;
8418
+ else if (char === "\\") escaped = true;
8419
+ else if (char === "\"") quoted = false;
8420
+ continue;
8421
+ }
8422
+ if (char === "\"") {
8423
+ quoted = true;
8424
+ continue;
8425
+ }
8426
+ if (char === "{") {
8427
+ if (depth === 0) start = index;
8428
+ depth += 1;
8429
+ continue;
8430
+ }
8431
+ if (char !== "}" || depth === 0) continue;
8432
+ depth -= 1;
8433
+ if (depth === 0 && start >= 0) {
8434
+ found.push(text.slice(start, index + 1));
8435
+ start = -1;
8436
+ }
8437
+ }
8438
+ return found;
8439
+ }
8335
8440
  /**
8336
- * Parse `{"target":..., "files":[...]}` from a child's final turn text.
8337
- * @param text - the child's answer (JSON is expected; prose is tolerated).
8338
- * @returns the manifest, or null when the turn carried no well-formed envelope.
8441
+ * Parse the authoring manifest from a child's final turn.
8442
+ *
8443
+ * P2-20: a child that wraps the envelope in a fenced code block or in one prose
8444
+ * sentence is the normal shape, not a malformed answer. A strict whole-text
8445
+ * JSON.parse returned null there, and null used to mean "every staged file was
8446
+ * declared" - the exact files the check exists to catch. Try the whole answer,
8447
+ * then every fenced body, then every balanced object literal.
8448
+ * @param text - the child's answer.
8449
+ * @returns the first valid envelope, or null when the turn really declared none.
8339
8450
  */
8340
8451
  function parseAuthoringManifest(text) {
8452
+ if (typeof text !== "string") return null;
8341
8453
  try {
8342
- const value = JSON.parse(text);
8343
- if (!Array.isArray(value.files) || value.files.some((file) => typeof file !== "string")) return null;
8344
- if (value.target !== void 0 && typeof value.target !== "string") return null;
8345
- return {
8346
- ...typeof value.target === "string" ? { target: value.target } : {},
8347
- files: value.files
8348
- };
8349
- } catch {
8350
- return null;
8351
- }
8454
+ const direct = asManifest(JSON.parse(text));
8455
+ if (direct !== null) return direct;
8456
+ } catch {}
8457
+ for (const match of text.matchAll(/\x60\x60\x60(?:json)?\s*([\s\S]*?)\x60\x60\x60/g)) try {
8458
+ const parsed = asManifest(JSON.parse(match[1] ?? ""));
8459
+ if (parsed !== null) return parsed;
8460
+ } catch {}
8461
+ for (const candidate of balancedObjects(text)) try {
8462
+ const parsed = asManifest(JSON.parse(candidate));
8463
+ if (parsed !== null) return parsed;
8464
+ } catch {}
8465
+ return null;
8352
8466
  }
8353
- /** Files a manifest promises for a staged tree (declared list, or the tree when undeclared). */
8467
+ /** Files a manifest promises for a staged tree. */
8354
8468
  function manifestFilesOf(manifest, stagedPaths) {
8355
8469
  if (manifest !== null) return manifest.files;
8356
- return ["SKILL.md", ...stagedPaths];
8470
+ return ["SKILL.md"];
8357
8471
  }
8358
8472
  /**
8359
8473
  * The first file the manifest promises but the staged tree does not contain.
@@ -8492,6 +8606,7 @@ async function runAuthoring(options) {
8492
8606
  lengths
8493
8607
  };
8494
8608
  const commit = await commitStagedRun(options.io, options.home, stageKey, options.commitPort, {
8609
+ parentRunId: options.runId,
8495
8610
  origin: "foreground",
8496
8611
  summary: options.summary,
8497
8612
  now: options.now,
@@ -23,7 +23,7 @@ export interface EngineScreenPort {
23
23
  done?: number;
24
24
  total?: number;
25
25
  }) => void;
26
- readonly runner?: (prompt: string, cfg?: Record<string, unknown>) => Promise<string>;
26
+ readonly runner?: (prompt: string, cfg?: Record<string, unknown>, signal?: AbortSignal | undefined) => Promise<string>;
27
27
  }): Promise<EngineScreenResult>;
28
28
  }
29
29
  export interface CandidateSelection {
@@ -46,7 +46,7 @@ export interface EngineScreeningDeps {
46
46
  readonly engine: EngineScreenPort;
47
47
  readonly select: (report: Record<string, unknown>) => CandidateSelection;
48
48
  readonly cfg?: Record<string, unknown>;
49
- readonly runner?: (prompt: string, cfg?: Record<string, unknown>) => Promise<string>;
49
+ readonly runner?: (prompt: string, cfg?: Record<string, unknown>, signal?: AbortSignal | undefined) => Promise<string>;
50
50
  readonly onSelection?: (selection: CandidateSelection, runId: string, cards: readonly SkillCard[]) => Promise<void>;
51
51
  }
52
52
  /** Write the exact card bodies as a support-free corpus snapshot and return its root. */
@@ -26,11 +26,12 @@ export function createEngineScreening(deps) {
26
26
  return async (cards, runId, signal) => {
27
27
  const corpusDir = await snapshotCards(deps.io, deps.home, runId, cards);
28
28
  const outDir = join(deps.home, 'merge', 'runs', runId, 'engine');
29
+ const runner = deps.runner === undefined ? undefined : (prompt, cfg) => deps.runner(prompt, cfg, signal);
29
30
  const result = await deps.engine.screen({
30
31
  corpusDir,
31
32
  outDir,
32
33
  ...(deps.cfg === undefined ? {} : { cfg: deps.cfg }),
33
- ...(deps.runner === undefined ? {} : { runner: deps.runner }),
34
+ ...(runner === undefined ? {} : { runner }),
34
35
  signal,
35
36
  });
36
37
  if (!result.ok) {
@@ -19,5 +19,5 @@ export interface LlmServiceLike {
19
19
  stream(input: Record<string, unknown>): AsyncIterable<LlmChunkLike>;
20
20
  }>;
21
21
  }
22
- export declare function createEngineRunner(llm: LlmServiceLike, route: () => ModelRoute): (prompt: string) => Promise<string>;
22
+ export declare function createEngineRunner(llm: LlmServiceLike, route: () => ModelRoute): (prompt: string, cfg?: Record<string, unknown>, signal?: AbortSignal | undefined) => Promise<string>;
23
23
  //# sourceMappingURL=model-runner.d.ts.map
@@ -13,8 +13,11 @@ function append(chunk, text) {
13
13
  return text;
14
14
  }
15
15
  export function createEngineRunner(llm, route) {
16
- return async (prompt) => {
16
+ return async (prompt, cfg, runSignal) => {
17
17
  const selection = route();
18
+ const timeout = Number(cfg?.timeoutMs);
19
+ const timeoutSignal = Number.isFinite(timeout) && timeout > 0 ? AbortSignal.timeout(timeout) : undefined;
20
+ const signal = runSignal === undefined ? timeoutSignal : timeoutSignal === undefined ? runSignal : (typeof AbortSignal.any === 'function' ? AbortSignal.any([runSignal, timeoutSignal]) : runSignal);
18
21
  let text = '';
19
22
  if (llm.stream !== undefined) {
20
23
  for await (const chunk of llm.stream({
@@ -22,16 +25,18 @@ export function createEngineRunner(llm, route) {
22
25
  model: selection.model,
23
26
  messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
24
27
  temperature: 0,
28
+ ...(signal === undefined ? {} : { signal }),
25
29
  }))
26
30
  text = append(chunk, text);
27
31
  return text;
28
32
  }
29
33
  if (llm.prepareCall === undefined)
30
34
  throw new Error('llm service has neither stream nor prepareCall');
31
- const call = await llm.prepareCall({ provider: normalizeProviderKey(selection.provider), model: selection.model, temperature: 0 });
35
+ const call = await llm.prepareCall({ provider: normalizeProviderKey(selection.provider), model: selection.model, temperature: 0 }, signal);
32
36
  for await (const chunk of call.stream({
33
37
  ...call.config,
34
38
  messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
39
+ ...(signal === undefined ? {} : { signal }),
35
40
  }))
36
41
  text = append(chunk, text);
37
42
  return text;
@@ -9,6 +9,7 @@ import { presentStage } from "./domain/stage-aggregate.js";
9
9
  import { v4ToLegacy } from "./domain/row-v4.js";
10
10
  import { loadMergeState } from "./state.js";
11
11
  import { withMergeRun } from "./run-shell.js";
12
+ import { attachRunToRow } from "./domain/row-run.js";
12
13
  import { recordRound2Outcome } from "./round2-outcome.js";
13
14
  import { failure } from "./domain/errors.js";
14
15
  export async function runApproveCommand(options) {
@@ -58,6 +59,7 @@ export async function runApproveCommand(options) {
58
59
  now: options.now,
59
60
  ...(options.detached === true ? { detached: true } : {}),
60
61
  async body(ctx) {
62
+ await attachRunToRow(options.io, options.home, row.id, ctx.runId);
61
63
  const stage = (name) => ctx.stage(name, { done: 0, total: 1 });
62
64
  await stage('author');
63
65
  let outcome;
@@ -71,6 +71,10 @@ export async function runAuthoring(options) {
71
71
  if (hasBlockingFinding(findings))
72
72
  return { ok: false, code: findings.some(finding => finding.rule === 'limit') ? 'merge-over-limit' : 'merge-validation', message: 'staged tree failed the rule gate', findings, lengths };
73
73
  const commit = await commitStagedRun(options.io, options.home, stageKey, options.commitPort, {
74
+ // P1-6: the staged key is `run-1/pairs/abc`; the ROW's run id is the parent.
75
+ // Without it the scan path writes its unit journal/receipt under the staged
76
+ // address, so crash recovery looks for a run that was never registered.
77
+ parentRunId: options.runId,
74
78
  origin: 'foreground',
75
79
  summary: options.summary,
76
80
  now: options.now,
@@ -3,7 +3,6 @@ import { writeCommitReceipt } from "./commit-receipt.js";
3
3
  import { pairKey } from "./contracts.js";
4
4
  import { writeJournal } from "./domain/commit-journal-store.js";
5
5
  import { evidenceSha256 as digestEvidence } from "./domain/evidence-hash.js";
6
- import { snapshotPath } from "./commit-snapshot.js";
7
6
  export async function commitStagedRun(io, home, runId, port, options) {
8
7
  const assembled = await assembleStagedCommit(io, home, runId, port, {
9
8
  origin: options.origin,
@@ -27,7 +26,9 @@ export async function commitStagedRun(io, home, runId, port, options) {
27
26
  return { ok: false, message: 'commit port missing: UnitTransaction or commitMerge required', plan: assembled.plan };
28
27
  {
29
28
  const first = assembled.plan.sources[0] ?? assembled.plan.target;
30
- const unitPlan = { runId, pairKey: pairKey(assembled.plan.target, first), target: assembled.plan.target, targetExists: assembled.plan.targetExists, sources: assembled.plan.sources, body: assembled.plan.body, supportFiles: assembled.plan.supportFiles, expectedTargetBody: assembled.plan.expectedTargetBody, expectedSourceBodies: assembled.plan.expectedSourceBodies, origin: options.origin, summary: options.summary, ...(options.limits === undefined ? {} : { skillLimits: options.limits }), ...(options.validationPlan === undefined ? {} : { validationPlan: options.validationPlan }), ...(options.targetRevision === undefined ? {} : { targetRevision: options.targetRevision }), ...(options.sourceRevisions === undefined ? {} : { sourceRevisions: options.sourceRevisions }), ...(options.supportDigest === undefined ? {} : { supportDigest: options.supportDigest }) };
29
+ // P1-6: the unit's run identity is the canonical parent run, not the stage key.
30
+ const unitRunId = options.parentRunId ?? runId;
31
+ const unitPlan = { runId: unitRunId, pairKey: pairKey(assembled.plan.target, first), target: assembled.plan.target, targetExists: assembled.plan.targetExists, sources: assembled.plan.sources, body: assembled.plan.body, supportFiles: assembled.plan.supportFiles, expectedTargetBody: assembled.plan.expectedTargetBody, expectedSourceBodies: assembled.plan.expectedSourceBodies, origin: options.origin, summary: options.summary, ...(options.limits === undefined ? {} : { skillLimits: options.limits }), ...(options.validationPlan === undefined ? {} : { validationPlan: options.validationPlan }), ...(options.targetRevision === undefined ? {} : { targetRevision: options.targetRevision }), ...(options.sourceRevisions === undefined ? {} : { sourceRevisions: options.sourceRevisions }), ...(options.supportDigest === undefined ? {} : { supportDigest: options.supportDigest }) };
31
32
  try {
32
33
  await writeJournal(io, home, journal);
33
34
  }
@@ -87,10 +88,6 @@ export async function commitStagedRun(io, home, runId, port, options) {
87
88
  };
88
89
  try {
89
90
  await writeCommitReceipt(io, home, receipt);
90
- try {
91
- await io.remove(snapshotPath(home, runId));
92
- }
93
- catch { /* committed journal is the record */ }
94
91
  return { ok: true, plan: assembled.plan, result, receipt, ...(warning === undefined ? {} : { warning }) };
95
92
  }
96
93
  catch (error) {
@@ -8,7 +8,6 @@ export interface CommitRecoveryReport {
8
8
  readonly target?: string;
9
9
  readonly sources?: readonly string[];
10
10
  readonly journalPath?: string;
11
- readonly snapshotPath?: string;
12
11
  readonly unitId?: string;
13
12
  }
14
13
  export declare function recoverCommitJournals(io: EvolutionIoLike, home: string, now: number, isActive?: (runId: string) => boolean): Promise<readonly CommitRecoveryReport[]>;
@@ -1,7 +1,6 @@
1
1
  /** Startup replay for non-terminal commit journals: report only, never write skills. */
2
2
  import { join } from 'node:path';
3
3
  import { writeJournal } from "./domain/commit-journal-store.js";
4
- import { snapshotPath } from "./commit-snapshot.js";
5
4
  import { readCommitReceipt } from "./commit-receipt.js";
6
5
  import { readUnitEvidence } from '@lmzhen/dsh-evolution-core';
7
6
  export async function recoverCommitJournals(io, home, now, isActive) {
@@ -52,7 +51,7 @@ export async function recoverCommitJournals(io, home, now, isActive) {
52
51
  continue;
53
52
  reports.push({
54
53
  runId: journal.runId, phase: journal.phase, action: 'manual-required', ok: false,
55
- target: journal.target, sources: journal.sources, journalPath: path, snapshotPath: snapshotPath(home, journal.runId),
54
+ target: journal.target, sources: journal.sources, journalPath: path,
56
55
  });
57
56
  }
58
57
  catch (error) {
@@ -7,11 +7,12 @@ import { readTextOrNull } from "./domain/read-probe.js";
7
7
  import { createHash } from 'node:crypto';
8
8
  import { stat } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
- import { probeList, readTreeLock, runsFile } from '@lmzhen/dsh-evolution-core';
10
+ import { probeList, readTreeLock, readUnitJournal, runsFile } from '@lmzhen/dsh-evolution-core';
11
11
  import { migrationJournalPath, migrationReportPath, v2BackupPath } from "./domain/state-bootstrap.js";
12
12
  import { readMergeSnapshot } from "./domain/merge-snapshot.js";
13
13
  import { validateArtifacts } from "./domain/consistency.js";
14
14
  import { readJournal } from "./domain/commit-journal-store.js";
15
+ import { readCommitReceipt } from "./commit-receipt.js";
15
16
  import { stagingRoot } from "./staging.js";
16
17
  import { readReconcileReport } from "./domain/reconcile.js";
17
18
  export const MERGE_DOCTOR_SCHEMA_VERSION = 3;
@@ -72,11 +73,6 @@ export async function mergeDoctor(io, home, configuredRoot) {
72
73
  recoveryRuns.add(report.runId);
73
74
  }
74
75
  catch { }
75
- const receiptRuns = new Set();
76
- const receiptDir = await probeList(io, join(home, 'merge', 'receipts'));
77
- if (receiptDir.kind === 'present')
78
- for (const name of receiptDir.value)
79
- receiptRuns.add(name);
80
76
  const invalid = [];
81
77
  if (snapshot.blocked !== undefined)
82
78
  invalid.push({ id: 'state', reason: snapshot.blocked.code });
@@ -86,9 +82,10 @@ export async function mergeDoctor(io, home, configuredRoot) {
86
82
  const artifactRow = row.state === 'approved' ? 'approved' : row.state === 'fuse' ? 'fuse' : row.state;
87
83
  const run = runId === undefined ? 'none' : row.state === 'pending' ? 'awaiting-approval' : 'running';
88
84
  const staging = item.stage === null ? (row.state === 'fuse' ? 'missing' : 'none') : item.stage.state === 'present' || item.stage.state === 'reserved' ? 'kept' : row.state === 'fuse' ? 'missing' : 'none';
89
- const phase = runId === undefined ? null : await readJournal(io, home, runId);
90
- const journal = phase === null ? 'none' : phase.phase === 'prepared' ? 'prepared' : phase.phase === 'target-written' || phase.phase === 'sources-archived' ? 'committing' : phase.phase === 'committed' ? 'committed' : phase.phase === 'rolled-back' ? 'rolled-back' : 'failed';
91
- const evidence = runId !== undefined && await readTextOrNull(io, join(home, 'merge', 'receipts', runId + '.json')) !== null ? 'receipt' : 'none';
85
+ const receipt = runId === undefined ? null : await readCommitReceipt(io, home, runId);
86
+ const phase = receipt !== null ? await readUnitJournal(io, home, receipt.unitId) : runId === undefined ? null : await readJournal(io, home, runId);
87
+ const journal = phase === null ? 'none' : phase.phase === 'prepared' || phase.phase === 'target-pending' ? 'prepared' : phase.phase === 'target-written' || phase.phase === 'sources-archived' || phase.phase === 'support-pending' || phase.phase === 'source-archiving' ? 'committing' : phase.phase === 'committed' ? 'committed' : phase.phase === 'rolled-back' ? 'rolled-back' : 'failed';
88
+ const evidence = receipt === null ? 'none' : 'receipt';
92
89
  const result = validateArtifacts({ row: artifactRow, run, staging, journal, evidence, activeRuns: run === 'running' || run === 'awaiting-approval' ? 1 : 0, ...(runId !== undefined && recoveryRuns.has(runId) ? { recovery: true } : {}) });
93
90
  if (!result.ok)
94
91
  invalid.push({ id: row.id, reason: result.reason ?? 'not-allowed' });
@@ -8,12 +8,18 @@ export interface AuthoringManifest {
8
8
  readonly files: readonly string[];
9
9
  }
10
10
  /**
11
- * Parse `{"target":..., "files":[...]}` from a child's final turn text.
12
- * @param text - the child's answer (JSON is expected; prose is tolerated).
13
- * @returns the manifest, or null when the turn carried no well-formed envelope.
11
+ * Parse the authoring manifest from a child's final turn.
12
+ *
13
+ * P2-20: a child that wraps the envelope in a fenced code block or in one prose
14
+ * sentence is the normal shape, not a malformed answer. A strict whole-text
15
+ * JSON.parse returned null there, and null used to mean "every staged file was
16
+ * declared" - the exact files the check exists to catch. Try the whole answer,
17
+ * then every fenced body, then every balanced object literal.
18
+ * @param text - the child's answer.
19
+ * @returns the first valid envelope, or null when the turn really declared none.
14
20
  */
15
21
  export declare function parseAuthoringManifest(text: string): AuthoringManifest | null;
16
- /** Files a manifest promises for a staged tree (declared list, or the tree when undeclared). */
22
+ /** Files a manifest promises for a staged tree. */
17
23
  export declare function manifestFilesOf(manifest: AuthoringManifest | null, stagedPaths: readonly string[]): readonly string[];
18
24
  /**
19
25
  * The first file the manifest promises but the staged tree does not contain.
@@ -1,26 +1,100 @@
1
+ function asManifest(value) {
2
+ if (value === null || typeof value !== 'object')
3
+ return null;
4
+ const record = value;
5
+ if (!Array.isArray(record.files) || record.files.some(file => typeof file !== 'string'))
6
+ return null;
7
+ if (record.target !== undefined && typeof record.target !== 'string')
8
+ return null;
9
+ return { ...(typeof record.target === 'string' ? { target: record.target } : {}), files: record.files };
10
+ }
11
+ /** Every balanced object literal in a prose/fenced answer (string-aware). */
12
+ function balancedObjects(text) {
13
+ const found = [];
14
+ let depth = 0;
15
+ let start = -1;
16
+ let quoted = false;
17
+ let escaped = false;
18
+ for (let index = 0; index < text.length; index += 1) {
19
+ const char = text[index];
20
+ if (quoted) {
21
+ if (escaped)
22
+ escaped = false;
23
+ else if (char === '\\')
24
+ escaped = true;
25
+ else if (char === '"')
26
+ quoted = false;
27
+ continue;
28
+ }
29
+ if (char === '"') {
30
+ quoted = true;
31
+ continue;
32
+ }
33
+ if (char === '{') {
34
+ if (depth === 0)
35
+ start = index;
36
+ depth += 1;
37
+ continue;
38
+ }
39
+ if (char !== '}' || depth === 0)
40
+ continue;
41
+ depth -= 1;
42
+ if (depth === 0 && start >= 0) {
43
+ found.push(text.slice(start, index + 1));
44
+ start = -1;
45
+ }
46
+ }
47
+ return found;
48
+ }
1
49
  /**
2
- * Parse `{"target":..., "files":[...]}` from a child's final turn text.
3
- * @param text - the child's answer (JSON is expected; prose is tolerated).
4
- * @returns the manifest, or null when the turn carried no well-formed envelope.
50
+ * Parse the authoring manifest from a child's final turn.
51
+ *
52
+ * P2-20: a child that wraps the envelope in a fenced code block or in one prose
53
+ * sentence is the normal shape, not a malformed answer. A strict whole-text
54
+ * JSON.parse returned null there, and null used to mean "every staged file was
55
+ * declared" - the exact files the check exists to catch. Try the whole answer,
56
+ * then every fenced body, then every balanced object literal.
57
+ * @param text - the child's answer.
58
+ * @returns the first valid envelope, or null when the turn really declared none.
5
59
  */
6
60
  export function parseAuthoringManifest(text) {
61
+ // Author callbacks in tests (and a bare void answer) can hand undefined;
62
+ // "no answer" is the same as "no envelope", not a crash.
63
+ if (typeof text !== 'string')
64
+ return null;
7
65
  try {
8
- const value = JSON.parse(text);
9
- if (!Array.isArray(value.files) || value.files.some(file => typeof file !== 'string'))
10
- return null;
11
- if (value.target !== undefined && typeof value.target !== 'string')
12
- return null;
13
- return { ...(typeof value.target === 'string' ? { target: value.target } : {}), files: value.files };
66
+ const direct = asManifest(JSON.parse(text));
67
+ if (direct !== null)
68
+ return direct;
14
69
  }
15
- catch {
16
- return null;
70
+ catch { /* not the whole answer */ }
71
+ for (const match of text.matchAll(/\x60\x60\x60(?:json)?\s*([\s\S]*?)\x60\x60\x60/g)) {
72
+ try {
73
+ const parsed = asManifest(JSON.parse(match[1] ?? ''));
74
+ if (parsed !== null)
75
+ return parsed;
76
+ }
77
+ catch { /* try the next fence/object */ }
78
+ }
79
+ for (const candidate of balancedObjects(text)) {
80
+ try {
81
+ const parsed = asManifest(JSON.parse(candidate));
82
+ if (parsed !== null)
83
+ return parsed;
84
+ }
85
+ catch { /* try the next candidate */ }
17
86
  }
87
+ return null;
18
88
  }
19
- /** Files a manifest promises for a staged tree (declared list, or the tree when undeclared). */
89
+ /** Files a manifest promises for a staged tree. */
20
90
  export function manifestFilesOf(manifest, stagedPaths) {
91
+ // P2-20: an answer that declared nothing is NOT a declaration of every staged
92
+ // file. Only SKILL.md is inherently required; any other staged file must be
93
+ // named (or be an existing target support file) or q09-undeclared blocks it.
21
94
  if (manifest !== null)
22
95
  return manifest.files;
23
- return ['SKILL.md', ...stagedPaths];
96
+ void stagedPaths;
97
+ return ['SKILL.md'];
24
98
  }
25
99
  /**
26
100
  * The first file the manifest promises but the staged tree does not contain.
@@ -0,0 +1,4 @@
1
+ import type { EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
2
+ /** Attach the in-flight child run to a row before its round2 starts (P1-8). */
3
+ export declare function attachRunToRow(io: EvolutionIoLike, home: string, rowId: string, runId: string): Promise<void>;
4
+ //# sourceMappingURL=row-run.d.ts.map
@@ -0,0 +1,14 @@
1
+ import { pairLedger } from "./pair-ledger.js";
2
+ /** Attach the in-flight child run to a row before its round2 starts (P1-8). */
3
+ export async function attachRunToRow(io, home, rowId, runId) {
4
+ const attached = await pairLedger(io, home).transact(tx => {
5
+ const current = tx.rows().find(row => row.id === rowId);
6
+ if (current === undefined)
7
+ return false;
8
+ const moved = tx.transition(rowId, { type: 'run-attached', runId }, current.version);
9
+ return moved.ok;
10
+ });
11
+ if (!attached)
12
+ throw new Error('row is not attachable to run: ' + rowId);
13
+ }
14
+ //# sourceMappingURL=row-run.js.map
@@ -80,6 +80,9 @@ export type V4Event = {
80
80
  readonly type: 'staged';
81
81
  readonly stagingRef?: string;
82
82
  readonly stagingExpiresAt?: number;
83
+ } | {
84
+ readonly type: 'run-attached';
85
+ readonly runId: string;
83
86
  } | {
84
87
  readonly type: 'committed';
85
88
  readonly unitId: CommitUnitRef;
@@ -35,6 +35,8 @@ export function transitionV4(row, event, expectedVersion, at = new Date().toISOS
35
35
  return { ok: true, row: null, version: expectedVersion + 1 };
36
36
  return illegal('pending/' + event.type);
37
37
  case 'approved':
38
+ if (event.type === 'run-attached')
39
+ return { ok: true, row: advance(row, at, { runId: event.runId }), version: expectedVersion + 1 };
38
40
  if (event.type === 'staged')
39
41
  return { ok: true, row: advance(row, at, { state: 'fuse', ...(event.stagingRef === undefined ? {} : { stagingRef: event.stagingRef }), ...(event.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: event.stagingExpiresAt }) }), version: expectedVersion + 1 };
40
42
  if (event.type === 'committed')
@@ -49,6 +51,8 @@ export function transitionV4(row, event, expectedVersion, at = new Date().toISOS
49
51
  return { ok: true, row: null, version: expectedVersion + 1 };
50
52
  return illegal('approved/' + event.type);
51
53
  case 'fuse':
54
+ if (event.type === 'run-attached')
55
+ return { ok: true, row: advance(row, at, { runId: event.runId }), version: expectedVersion + 1 };
52
56
  if (event.type === 'committed')
53
57
  return { ok: true, row: null, version: expectedVersion + 1 };
54
58
  if (event.type === 'skipped')
@@ -224,7 +224,9 @@ export async function cleanupStage(io, home, now, rows, limit = 100) {
224
224
  const refs = new Set();
225
225
  const indexRoot = join(home, 'merge', 'staging-index');
226
226
  const names = await io.exists(indexRoot) ? await listOrEmpty(io, indexRoot) : [];
227
- for (const name of names.slice(0, limit)) {
227
+ // P1-7: refs must be collected from EVERY index before deleting run trees; the
228
+ // limit only bounds how many orphan run dirs one sweep removes, never the ref set.
229
+ for (const name of names) {
228
230
  if (!name.endsWith('.json'))
229
231
  continue;
230
232
  const raw = await readTextOrNull(io, join(indexRoot, name));
@@ -23,6 +23,7 @@ export interface ValidationPlan {
23
23
  readonly forwardRefs: readonly string[];
24
24
  readonly reverseRefs: readonly string[];
25
25
  readonly orphanFiles: readonly string[];
26
+ readonly undeclaredFiles: readonly string[];
26
27
  };
27
28
  readonly supportConflicts: readonly {
28
29
  readonly path: string;
@@ -15,6 +15,9 @@ export function buildValidationPlan(input) {
15
15
  const reverseRefs = files.map(file => file.path).filter(path => path !== 'SKILL.md' && !refs.has(path) && manifest.has(path)).sort();
16
16
  const existing = new Set(input.existingSupport ?? []);
17
17
  const orphanFiles = [...existing].filter(path => !staged.has(path)).sort();
18
+ // P2-20: a clean JSON subset must not exempt the staged files it omitted.
19
+ // Existing target support files stay exempt (round 2 legitimately updates them).
20
+ const undeclaredFiles = [...staged].filter(path => path !== 'SKILL.md' && !manifest.has(path) && !existing.has(path)).sort();
18
21
  const byPath = new Map();
19
22
  // Conflict means TWO SKILLS contributing different bytes at one path. Staged edits and
20
23
  // the target's previous file are not conflicts: the target file is a CAS baseline that
@@ -32,7 +35,7 @@ export function buildValidationPlan(input) {
32
35
  const sourceThreatFiles = participantCards.flatMap(card => (card.files ?? []).filter(file => scanContentThreats(file.content) !== null).map(file => card.name + '/' + file.path));
33
36
  const threat = { body: scanContentThreats(input.tree.body) !== null || participantCards.some(card => scanContentThreats(card.body) !== null), files: [...files.filter(file => scanContentThreats(file.content) !== null).map(file => file.path), ...sourceThreatFiles] };
34
37
  const sourceRevisions = input.sourceRevisions ?? Object.fromEntries(participantCards.map(card => [card.name, digest(card.body)]));
35
- const revision = digest(JSON.stringify({ target: input.target, body: digest(input.tree.body), files: files.map(file => [file.path, file.digest]), q09: { forwardRefs, reverseRefs, orphanFiles }, supportConflicts, threat, sourceRevisions, targetRevision: input.targetRevision ?? null }));
38
+ const revision = digest(JSON.stringify({ target: input.target, body: digest(input.tree.body), files: files.map(file => [file.path, file.digest]), q09: { forwardRefs, reverseRefs, orphanFiles, undeclaredFiles }, supportConflicts, threat, sourceRevisions, targetRevision: input.targetRevision ?? null }));
36
39
  const findings = [];
37
40
  const stageDigest = digest(JSON.stringify(files.map(file => [file.path, file.digest])));
38
41
  if (forwardRefs.length > 0)
@@ -41,6 +44,8 @@ export function buildValidationPlan(input) {
41
44
  findings.push({ rule: 'q09-reverse', path: reverseRefs.join(','), severity: 'block', message: 'staged manifest file is not referenced by body' });
42
45
  if (orphanFiles.length > 0)
43
46
  findings.push({ rule: 'q09-orphan', path: orphanFiles.join(','), severity: 'block', message: 'target tree support is not declared by plan' });
47
+ if (undeclaredFiles.length > 0)
48
+ findings.push({ rule: 'q09-undeclared', path: undeclaredFiles.join(','), severity: 'block', message: 'staged support file is not declared by the authoring manifest' });
44
49
  if (supportConflicts.length > 0)
45
50
  findings.push({ rule: 'support-conflict', path: supportConflicts.map(item => item.path).join(','), severity: 'block', message: 'same support path has different contents' });
46
51
  if (threat.body)
@@ -52,6 +57,6 @@ export function buildValidationPlan(input) {
52
57
  for (const file of files)
53
58
  if (file.bytes > input.limits.skillLimits.maxSkillFileBytes)
54
59
  findings.push({ rule: 'limit', path: file.path, severity: 'block', message: 'file exceeds maxSkillFileBytes' });
55
- return { planVersion: 1, stageDigest, target: input.target, body: input.tree.body, files, supportFiles, manifestFiles, q09: { forwardRefs, reverseRefs, orphanFiles }, supportConflicts, threat, sourceRevisions, targetRevision: input.targetRevision ?? null, limitsDigest: input.limits.limitsDigest, ...(input.promptVersion === undefined ? {} : { promptVersion: input.promptVersion }), ...(input.promptHash === undefined ? {} : { promptHash: input.promptHash }), revision, findings };
60
+ return { planVersion: 1, stageDigest, target: input.target, body: input.tree.body, files, supportFiles, manifestFiles, q09: { forwardRefs, reverseRefs, orphanFiles, undeclaredFiles }, supportConflicts, threat, sourceRevisions, targetRevision: input.targetRevision ?? null, limitsDigest: input.limits.limitsDigest, ...(input.promptVersion === undefined ? {} : { promptVersion: input.promptVersion }), ...(input.promptHash === undefined ? {} : { promptHash: input.promptHash }), revision, findings };
56
61
  }
57
62
  //# sourceMappingURL=validation-plan.js.map
@@ -33,6 +33,12 @@ export async function recoverCommitUnits(options) {
33
33
  }
34
34
  if (journal === null || typeof journal.unitId !== 'string')
35
35
  continue;
36
+ // P1-4: legacy tree-change journals may still sit in the merge directory; they are ordinary
37
+ // skill units, never merge runs. Never finalize/rollback/project them as a merge.
38
+ if (journal.kind === 'tree-change' || (journal.kind === undefined && journal.runId === journal.unitId)) {
39
+ reports.push({ unitId: journal.unitId, runId: journal.runId, phase: journal.phase, branch: branchOf(journal.phase), action: 'none', ok: true, code: 'skill-unit-skipped' });
40
+ continue;
41
+ }
36
42
  if (options.isActive?.(journal.runId) === true) {
37
43
  reports.push({ unitId: journal.unitId, runId: journal.runId, phase: journal.phase, branch: branchOf(journal.phase), action: 'none', ok: true, code: 'skipped-active-run' });
38
44
  continue;
@@ -6,6 +6,7 @@ import { errorText } from '@lmzhen/dsh-evolution-core';
6
6
  import { loadMergeState } from "./state.js";
7
7
  import { runScanCommand } from "./scan-command.js";
8
8
  import { withMergeRun } from "./run-shell.js";
9
+ import { attachRunToRow } from "./domain/row-run.js";
9
10
  import { recordRound2Outcome } from "./round2-outcome.js";
10
11
  import { pairLedger } from "./domain/pair-ledger.js";
11
12
  import { presentStage } from "./domain/stage-aggregate.js";
@@ -75,6 +76,7 @@ export async function runResumeCommand(options) {
75
76
  parentRunId, now: options.now,
76
77
  ...(options.detached === true ? { detached: true } : {}),
77
78
  async body(ctx) {
79
+ await attachRunToRow(options.io, options.home, row.id, ctx.runId);
78
80
  await ctx.stage('author', { done: 0, total: 1 });
79
81
  let outcome;
80
82
  try {
@@ -33,6 +33,20 @@ export async function withMergeRun(options) {
33
33
  phase: 'running', approval: options.approval.mergeApproval ? 'pending' : 'approved',
34
34
  createdAt: new Date(startedAt).toISOString(), updatedAt: new Date(options.now()).toISOString(),
35
35
  });
36
+ const writeFailureMeta = async (stopReason) => {
37
+ try {
38
+ await writeRunMeta(options.io, options.home, {
39
+ version: 1, runId: handle.id, pid: process.pid,
40
+ ...(options.parentRunId === undefined ? {} : { parentRunId: options.parentRunId }),
41
+ ...(options.pairKey === undefined ? {} : { pairKey: options.pairKey }),
42
+ ...(options.stagingRef === undefined ? {} : { stagingRef: options.stagingRef }),
43
+ pairCount: options.pairCount ?? (options.pairKey === undefined ? 0 : 1),
44
+ phase: 'settled', approval: 'approved', stopReason,
45
+ createdAt: new Date(startedAt).toISOString(), updatedAt: new Date(options.now()).toISOString(),
46
+ });
47
+ }
48
+ catch { /* best-effort: the registry settle below remains authoritative */ }
49
+ };
36
50
  const finish = async () => {
37
51
  let draft;
38
52
  const runController = new AbortController();
@@ -115,6 +129,7 @@ export async function withMergeRun(options) {
115
129
  }
116
130
  catch (error) {
117
131
  await options.registry.settle(handle.id, { state: 'failed', failure: 'report write failed: ' + (error instanceof Error ? error.message : String(error)) });
132
+ await writeFailureMeta('failed');
118
133
  return { ok: false, runId: handle.id, message: 'report write failed' };
119
134
  }
120
135
  try {
@@ -122,6 +137,7 @@ export async function withMergeRun(options) {
122
137
  }
123
138
  catch (error) {
124
139
  await options.registry.settle(handle.id, { state: 'failed', resultRef: reportPath, failure: 'post-report finalize failed: ' + (error instanceof Error ? error.message : String(error)) });
140
+ await writeFailureMeta('failed');
125
141
  return { ok: false, runId: handle.id, message: 'post-report finalize failed', reportPath };
126
142
  }
127
143
  const runState = report.stopReason === 'completed'
@@ -107,7 +107,9 @@ export function parseCursor(cursor, snapshot) {
107
107
  }
108
108
  }
109
109
  export function buildQueuePage(snapshot, queue, expiredOnly, cursor, limit = 50) {
110
- const selected = snapshot.rows.filter(row => row.queue === queue && (queue === 'skipped' ? true : expiredOnly ? row.expired : !row.expired));
110
+ // P2-17: skipped rows are a normal queue. Expired ones belong to the expired bucket
111
+ // (counts.expired); the default list must not show them beside an active badge.
112
+ const selected = snapshot.rows.filter(row => row.queue === queue && (expiredOnly ? row.expired : !row.expired));
111
113
  const offset = cursor === undefined ? 0 : parseCursor(cursor, snapshot);
112
114
  const page = selected.slice(offset, offset + Math.max(0, Math.min(200, limit)));
113
115
  const next = offset + page.length < selected.length ? encodeCursor(snapshot, offset + page.length) : undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-merge",
3
3
  "description": "Skill auto-merge domain: screening, two-round authoring, queues and commit orchestration (community build)",
4
- "version": "0.32.2",
4
+ "version": "0.32.4",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -32,9 +32,9 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@deepseek-ai/schemastery": "^3.18.5",
35
- "@lmzhen/dsh-evolution-core": "^0.32.2",
36
- "@lmzhen/dsh-evolution-commands": "^0.32.2",
37
- "@lmzhen/dsh-evolution-merge-engine": "0.32.2"
35
+ "@lmzhen/dsh-evolution-core": "^0.32.4",
36
+ "@lmzhen/dsh-evolution-commands": "^0.32.4",
37
+ "@lmzhen/dsh-evolution-merge-engine": "0.32.4"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@deepseek-ai/cordis": "^4.0.5"
@@ -1,38 +0,0 @@
1
- import type { CommitMergePlan, EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
2
- export interface CommitSnapshot {
3
- readonly runId: string;
4
- readonly target: string;
5
- readonly create: boolean;
6
- readonly sources: readonly string[];
7
- readonly targetBody: string | null;
8
- readonly sourceBodies: readonly {
9
- readonly name: string;
10
- readonly body: string;
11
- }[];
12
- readonly targetSupport?: readonly {
13
- readonly path: string;
14
- readonly content: string;
15
- }[];
16
- readonly targetSupportAfter?: readonly {
17
- readonly path: string;
18
- readonly content: string;
19
- }[];
20
- readonly sourceSupport?: readonly {
21
- readonly name: string;
22
- readonly path: string;
23
- readonly content: string;
24
- }[];
25
- readonly at: string;
26
- }
27
- export declare function snapshotPath(home: string, runId: string): string;
28
- export declare function writeCommitSnapshot(io: EvolutionIoLike, home: string, runId: string, plan: CommitMergePlan, now: number, port?: {
29
- readSupportFiles?(name: string): Promise<readonly {
30
- readonly path: string;
31
- readonly content: string;
32
- }[]>;
33
- }): Promise<CommitSnapshot>;
34
- export declare function readCommitSnapshot(io: EvolutionIoLike, home: string, runId: string): Promise<CommitSnapshot | null>;
35
- export declare function isSafeSkillName(name: string): boolean;
36
- export declare function skillFilePath(root: string, name: string, path: string): string;
37
- export declare function skillBodyPath(root: string, name: string): string;
38
- //# sourceMappingURL=commit-snapshot.d.ts.map
@@ -1,65 +0,0 @@
1
- /** Pre-commit snapshot: enough to restore the target and archived sources after a crash. */
2
- import { join, resolve, sep } from 'node:path';
3
- export function snapshotPath(home, runId) {
4
- return join(home, 'merge', 'snapshots', runId.replace(/[^A-Za-z0-9_-]+/g, '_') + '.json');
5
- }
6
- export async function writeCommitSnapshot(io, home, runId, plan, now, port) {
7
- if (!isSafeSkillName(plan.target) || plan.sources.some(name => !isSafeSkillName(name))) {
8
- throw new Error('unsafe skill name in commit plan: ' + plan.target);
9
- }
10
- const readSupport = port?.readSupportFiles === undefined
11
- ? undefined
12
- : (name) => port.readSupportFiles?.(name) ?? Promise.resolve([]);
13
- const targetSupport = readSupport === undefined || !plan.targetExists ? [] : await readSupport(plan.target);
14
- const sourceSupport = [];
15
- if (readSupport !== undefined) {
16
- for (const name of plan.sources) {
17
- for (const file of await readSupport(name))
18
- sourceSupport.push({ name, path: file.path, content: file.content });
19
- }
20
- }
21
- const snapshot = {
22
- runId, target: plan.target, create: !plan.targetExists, sources: plan.sources,
23
- targetBody: plan.expectedTargetBody,
24
- sourceBodies: plan.expectedSourceBodies.map(item => ({ name: item.name, body: item.body })),
25
- ...(targetSupport.length === 0 ? {} : { targetSupport }),
26
- ...(plan.supportFiles.length === 0 ? {} : { targetSupportAfter: plan.supportFiles }),
27
- ...(sourceSupport.length === 0 ? {} : { sourceSupport }),
28
- at: new Date(now).toISOString(),
29
- };
30
- await io.writeText(snapshotPath(home, runId), JSON.stringify(snapshot, null, 2));
31
- return snapshot;
32
- }
33
- export async function readCommitSnapshot(io, home, runId) {
34
- const raw = await io.readText(snapshotPath(home, runId));
35
- if (raw === null)
36
- return null;
37
- try {
38
- return JSON.parse(raw);
39
- }
40
- catch {
41
- return null;
42
- }
43
- }
44
- export function isSafeSkillName(name) {
45
- return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && name !== '.' && name !== '..';
46
- }
47
- export function skillFilePath(root, name, path) {
48
- if (!isSafeSkillName(name) || path.startsWith('/') || path.split('/').includes('..'))
49
- throw new Error('unsafe skill file: ' + name + '/' + path);
50
- const base = resolve(root, name);
51
- const file = resolve(base, path);
52
- if (!file.startsWith(base + sep))
53
- throw new Error('skill file escapes skill: ' + name + '/' + path);
54
- return file;
55
- }
56
- export function skillBodyPath(root, name) {
57
- if (!isSafeSkillName(name))
58
- throw new Error('unsafe skill name: ' + name);
59
- const base = resolve(root);
60
- const path = resolve(base, name, 'SKILL.md');
61
- if (!path.startsWith(base + sep))
62
- throw new Error('skill path escapes root: ' + name);
63
- return path;
64
- }
65
- //# sourceMappingURL=commit-snapshot.js.map