@astrosheep/keiyaku 2.9.11 → 2.9.12

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.
Files changed (48) hide show
  1. package/README.md +62 -72
  2. package/build/.tsbuildinfo +1 -1
  3. package/build/agents/providers/claude-agent-sdk/adapter.js +15 -2
  4. package/build/cli/commands/contract/bind/meta.js +4 -4
  5. package/build/cli/commands/metadata.js +3 -2
  6. package/build/cli/commands/projection/kill/handler.js +4 -5
  7. package/build/cli/commands/shared.js +1 -1
  8. package/build/cli/commands/task/catalog.js +2 -0
  9. package/build/cli/commands/task/compose/handler.js +18 -0
  10. package/build/cli/commands/task/compose/meta.js +10 -0
  11. package/build/cli/flags.js +5 -1
  12. package/build/cli/index.js +11 -6
  13. package/build/cli/parse-flags.js +21 -21
  14. package/build/cli/parse-metadata.js +5 -2
  15. package/build/cli/parse-selectors.js +4 -4
  16. package/build/cli/parse.js +23 -23
  17. package/build/cli/projection-address.js +7 -7
  18. package/build/cli/render/task.js +27 -0
  19. package/build/cli/types.js +1 -1
  20. package/build/core/addressing.js +3 -3
  21. package/build/core/call/context.js +3 -3
  22. package/build/core/call/prompt.js +1 -1
  23. package/build/core/contract-view.js +1 -1
  24. package/build/core/draft.js +51 -22
  25. package/build/core/projection/generation/model.js +1 -1
  26. package/build/core/projection/generation/projection-generation-runner.js +13 -3
  27. package/build/core/projection/generation/transitions.js +10 -0
  28. package/build/core/projection/projection-history.js +1 -1
  29. package/build/core/projection/projection-kill.js +5 -4
  30. package/build/core/projection/projection-runner-lock.js +58 -229
  31. package/build/core/projection/projection-wait.js +2 -2
  32. package/build/core/projection/projection-wake.js +2 -0
  33. package/build/core/repository-ledger/claim-evidence.js +1 -1
  34. package/build/core/repository-ledger/current-state-store.js +455 -337
  35. package/build/core/repository-ledger/read-model.js +3 -3
  36. package/build/core/repository-ledger/write-transaction.js +15 -13
  37. package/build/core/sqlite-process-lifetime-lock.js +194 -0
  38. package/build/core/task/compose.js +327 -0
  39. package/build/core/task/index.js +1 -0
  40. package/build/core/task/settlement-git.js +69 -26
  41. package/build/core/task/task-git-store.js +1 -0
  42. package/build/core/task/task-store-repository.js +1 -0
  43. package/build/flow-error.js +6 -1
  44. package/build/generated/version.js +2 -2
  45. package/package.json +2 -2
  46. package/skills/keiyaku-akuma/SKILL.md +6 -1
  47. package/skills/keiyaku-task/SKILL.md +23 -1
  48. package/skills/keiyaku-workflow/SKILL.md +33 -18
@@ -56,7 +56,7 @@ export async function resolveProjectionAddress(input) {
56
56
  });
57
57
  }
58
58
  if (isResponseArtifactId(input.address)) {
59
- throw new AddressResolutionError("EMPTY_PARAM", `artifact ${input.address} is a response artifact; ${input.verb} expects a projection id or alias`, { ...attempt, actual: "artifact" });
59
+ throw new AddressResolutionError("UNKNOWN_ADDRESS", `artifact ${input.address} is a response artifact; ${input.verb} expects a projection id or alias`, { ...attempt, actual: "artifact" });
60
60
  }
61
61
  const suffix = input.scope
62
62
  ? `within ${input.scope.spelling.asTyped}`
@@ -65,22 +65,22 @@ export async function resolveProjectionAddress(input) {
65
65
  let alias;
66
66
  if (input.address.includes("/")) {
67
67
  if (!isProjectionId(input.address))
68
- throw new AddressResolutionError("EMPTY_PARAM", `projection ${input.address} has no match ${suffix}`, attempt);
68
+ throw new AddressResolutionError("UNKNOWN_ADDRESS", `projection ${input.address} has no match ${suffix}`, attempt);
69
69
  id = input.address;
70
70
  }
71
71
  else {
72
72
  if (!isProjectionAlias(input.address))
73
- throw new AddressResolutionError("EMPTY_PARAM", `projection alias ${input.address} has no match ${suffix}`, attempt);
73
+ throw new AddressResolutionError("UNKNOWN_ADDRESS", `projection alias ${input.address} has no match ${suffix}`, attempt);
74
74
  const target = await readProjectionAliasAtRoot(projectionAliasRootAtCoordinate(coordinate), input.address);
75
75
  if (!target)
76
- throw new AddressResolutionError("EMPTY_PARAM", `projection alias ${input.address} has no match ${suffix}`, attempt);
76
+ throw new AddressResolutionError("UNKNOWN_ADDRESS", `projection alias ${input.address} has no match ${suffix}`, attempt);
77
77
  id = target;
78
78
  alias = input.address;
79
79
  }
80
80
  const directory = projectionDirAtCoordinate(coordinate, id);
81
81
  if (!isRealDirectory(directory)) {
82
82
  const subject = alias ? `projection alias ${alias} points to missing projection ${id}` : `projection ${id}`;
83
- throw new AddressResolutionError("EMPTY_PARAM", `${subject} has no match ${suffix}`, attempt);
83
+ throw new AddressResolutionError("UNKNOWN_ADDRESS", `${subject} has no match ${suffix}`, attempt);
84
84
  }
85
85
  // Historical projection storage has no current identity to resolve. Tell
86
86
  // still reaches its typed no-mutation refusal instead of exposing storage
@@ -94,12 +94,12 @@ export async function resolveProjectionAddress(input) {
94
94
  catch (error) {
95
95
  if (error instanceof Error && /ENOENT|no such file/i.test(error.message)) {
96
96
  const subject = alias ? `projection alias ${alias} points to missing projection ${id}` : `projection ${id}`;
97
- throw new AddressResolutionError("EMPTY_PARAM", `${subject} has no match ${suffix}`, attempt);
97
+ throw new AddressResolutionError("UNKNOWN_ADDRESS", `${subject} has no match ${suffix}`, attempt);
98
98
  }
99
99
  throw error;
100
100
  }
101
101
  if (input.scope && (identity.binding.kind !== "commission" || identity.binding.commissionId !== input.scope.binding.commissionId)) {
102
- throw new AddressResolutionError("EMPTY_PARAM", `projection ${id} has no match ${suffix}`, attempt);
102
+ throw new AddressResolutionError("UNKNOWN_ADDRESS", `projection ${id} has no match ${suffix}`, attempt);
103
103
  }
104
104
  return { id, directory, ...(alias ? { alias } : {}) };
105
105
  }
@@ -32,6 +32,33 @@ export function renderTaskUpdateJson(result) {
32
32
  ...(result.bodyChange === undefined ? {} : { bodyChange: result.bodyChange }),
33
33
  });
34
34
  }
35
+ export function renderTaskComposition(plan, check) {
36
+ const lines = [check ? "Task composition check" : "Task composition"];
37
+ for (const allocation of plan.allocations)
38
+ lines.push(`allocate: ${allocation.coordinate} · ${allocation.title}`);
39
+ for (const delta of plan.deltas) {
40
+ const changes = [
41
+ ...(delta.parent ? [`parent ${delta.parent.before ?? "(none)"} -> ${delta.parent.after ?? "(none)"}`] : []),
42
+ ...(delta.needs ? [`needs ${delta.needs.before.join(",") || "(none)"} -> ${delta.needs.after.join(",") || "(none)"}`] : []),
43
+ ...(delta.supersedes ? [`supersedes ${delta.supersedes.before.join(",") || "(none)"} -> ${delta.supersedes.after.join(",") || "(none)"}`] : []),
44
+ ...(delta.relatesTo ? [`relates ${delta.relatesTo.before.join(",") || "(none)"} -> ${delta.relatesTo.after.join(",") || "(none)"}`] : []),
45
+ ];
46
+ lines.push(`change: ${delta.coordinate} · ${changes.join("; ")}`);
47
+ }
48
+ for (const warning of plan.warnings) {
49
+ const detail = warning.related === undefined ? warning.coordinate : `${warning.coordinate} · ${warning.related}`;
50
+ lines.push(`warning: ${warning.kind} · ${detail}`);
51
+ }
52
+ return lines.join("\n");
53
+ }
54
+ export function renderTaskCompositionJson(plan, check) {
55
+ return JSON.stringify({
56
+ mode: check ? "check" : "apply",
57
+ allocations: plan.allocations,
58
+ deltas: plan.deltas,
59
+ warnings: plan.warnings,
60
+ });
61
+ }
35
62
  export function renderTaskRow(task, completeId = false) {
36
63
  return completeId ? renderTaskMutationRow(task) : renderTaskListRow(task);
37
64
  }
@@ -1 +1 @@
1
- export const TASK_SUBCOMMANDS = ["add", "show", "ls", "log", "start", "stop", "hold", "resume", "done", "drop", "update", "doctor"];
1
+ export const TASK_SUBCOMMANDS = ["add", "compose", "show", "ls", "log", "start", "stop", "hold", "resume", "done", "drop", "update", "doctor"];
@@ -237,7 +237,7 @@ export function resolveExplicitContractAddressFromRead(input) {
237
237
  const source = input.source ?? (input.raw.startsWith("@") ? "at-address" : "explicit-flag");
238
238
  const spelling = { asTyped: commissionSelectorAsTyped(query, source), source };
239
239
  if (isResponseArtifactId(query)) {
240
- throw new AddressResolutionError("EMPTY_PARAM", `artifact ${query} is a response artifact; expected a commission address`, { repo, workdir, expected: "commission", spelling, candidates: [], inference: [], actual: "artifact" });
240
+ throw new AddressResolutionError("UNKNOWN_ADDRESS", `artifact ${query} is a response artifact; expected a commission address`, { repo, workdir, expected: "commission", spelling, candidates: [], inference: [], actual: "artifact" });
241
241
  }
242
242
  const identities = explicitAddressIdentities(input.read, query);
243
243
  const attempt = {
@@ -282,7 +282,7 @@ export function resolveExplicitContractAddressFromRead(input) {
282
282
  if (unknown.status === "diseased" || unknown.status === "isolated" || unknown.status === "indeterminate") {
283
283
  throw contractReadOutcomeError(query, unknown);
284
284
  }
285
- throw new AddressResolutionError("EMPTY_PARAM", `unknown contract address @${query}`, attempt);
285
+ throw new AddressResolutionError("UNKNOWN_ADDRESS", `unknown contract address @${query}`, attempt);
286
286
  }
287
287
  export async function resolveContractAddress(cwd, raw, source = raw?.startsWith("@") ? "at-address" : "explicit-flag", effectiveDirectory = cwd) {
288
288
  const workdir = path.resolve(effectiveDirectory);
@@ -348,7 +348,7 @@ export async function resolveContractAddress(cwd, raw, source = raw?.startsWith(
348
348
  inference: attempt.inference,
349
349
  });
350
350
  }
351
- throw new AddressResolutionError(operable.length === 0 ? "EMPTY_PARAM" : "CONTRACT_AMBIGUOUS", operable.length === 0
351
+ throw new AddressResolutionError(operable.length === 0 ? "UNKNOWN_ADDRESS" : "CONTRACT_AMBIGUOUS", operable.length === 0
352
352
  ? "no active commission found; pass @place, @slug, full @commission-id, or --contract <addr>"
353
353
  : "multiple active commissions found; pass @place, @slug, full @commission-id, or --contract <addr>", attempt);
354
354
  }
@@ -24,7 +24,7 @@ export async function readCallKeiyakuContext(executionCwd, input, ports = {}) {
24
24
  const asTyped = input.contractAddressSource === "at-address"
25
25
  ? `@${input.contractId}`
26
26
  : `--contract ${input.contractId}`;
27
- throw new AddressResolutionError("EMPTY_PARAM", `commission address ${asTyped} cannot be combined with --bare`, buildResolutionAttempt({
27
+ throw new AddressResolutionError("INVALID_USAGE", `commission address ${asTyped} cannot be combined with --bare`, buildResolutionAttempt({
28
28
  repo: { kind: "user" },
29
29
  workdir: executionCwd,
30
30
  spelling: {
@@ -37,7 +37,7 @@ export async function readCallKeiyakuContext(executionCwd, input, ports = {}) {
37
37
  const asTyped = input.contractAddressSource === "at-address"
38
38
  ? `@${input.contractId}`
39
39
  : `--contract ${input.contractId}`;
40
- throw new AddressResolutionError("EMPTY_PARAM", `${asTyped} is a commission address, but ${ledgerCandidate} has no repository ledger; contract addressing requires a ledger`, buildResolutionAttempt({
40
+ throw new AddressResolutionError("UNKNOWN_ADDRESS", `${asTyped} is a commission address, but ${ledgerCandidate} has no repository ledger; contract addressing requires a ledger`, buildResolutionAttempt({
41
41
  repo: { kind: "user" },
42
42
  workdir: executionCwd,
43
43
  spelling: {
@@ -89,7 +89,7 @@ export async function readCallKeiyakuContext(executionCwd, input, ports = {}) {
89
89
  const asTyped = input.contractAddressSource === "at-address"
90
90
  ? `@${input.contractId}`
91
91
  : `--contract ${input.contractId}`;
92
- throw new AddressResolutionError("EMPTY_PARAM", `commission address ${asTyped} cannot be combined with --bare`, buildResolutionAttempt({
92
+ throw new AddressResolutionError("INVALID_USAGE", `commission address ${asTyped} cannot be combined with --bare`, buildResolutionAttempt({
93
93
  repo: repositoryAddress.repo,
94
94
  workdir: repositoryAddress.workdir,
95
95
  spelling: {
@@ -35,7 +35,7 @@ export function buildCallPrompt(input) {
35
35
  `Contract: ${bind.title}`,
36
36
  `Context:\n${bind.context}`,
37
37
  `Objective:\n${bind.objective}`,
38
- `Design:\n${bind.design}`,
38
+ `Design & Approach:\n${bind.design}`,
39
39
  `Scope:\n${bind.scope.map((value) => `- ${value}`).join("\n")}`,
40
40
  `Criteria:\n${bind.criteria.map((value) => `- ${value}`).join("\n")}`,
41
41
  `Verification:\n${bind.verification.map((value) => `- ${value}`).join("\n")}`,
@@ -246,7 +246,7 @@ export function renderContractView(input) {
246
246
  "## Objective",
247
247
  bind.data.objective,
248
248
  "",
249
- "## Design",
249
+ "## Design & Approach",
250
250
  bind.data.design,
251
251
  "",
252
252
  "## Scope",
@@ -3,8 +3,15 @@ import { parseToAST } from "./markdown/parser.js";
3
3
  import { extractListItems, renderSectionContent } from "./markdown/render.js";
4
4
  import { normalizeSectionTitle } from "./markdown/titles.js";
5
5
  import { markdownSourceSections, renderScopeFragment, validateScopeFragmentLines } from "./scope.js";
6
- const KNOWN_SECTIONS = new Set(["context", "objective", "design", "scope", "criteria", "verification"]);
7
- const RETIRED_OR_MALFORMED_CORE_SECTIONS = new Set(["title", "name", "checks"]);
6
+ const KNOWN_SECTIONS = new Map([
7
+ ["context", "context"],
8
+ ["objective", "objective"],
9
+ ["design & approach", "design"],
10
+ ["scope", "scope"],
11
+ ["criteria", "criteria"],
12
+ ["verification", "verification"],
13
+ ]);
14
+ const RETIRED_OR_MALFORMED_CORE_SECTIONS = new Set(["title", "name", "design", "checks"]);
8
15
  const BIND_DRAFT_SECTION_ORDER = ["title", "context", "objective", "design", "scope", "criteria", "verification"];
9
16
  function invalidBindDraft(message) {
10
17
  throw new FlowError("INVALID_KEIYAKU_DRAFT", `INVALID_BIND_DRAFT: ${message}`);
@@ -105,14 +112,19 @@ function collectBindDraftNodes(content) {
105
112
  continue;
106
113
  const normalized = normalizeSectionName(node.title);
107
114
  if (RETIRED_OR_MALFORMED_CORE_SECTIONS.has(normalized)) {
108
- const replacement = normalized === "checks" ? "Criteria and Verification" : "the H1 Title";
115
+ const replacement = normalized === "checks"
116
+ ? "Criteria and Verification"
117
+ : normalized === "design"
118
+ ? "Design & Approach"
119
+ : "the H1 Title";
109
120
  invalidBindDraft(`obsolete or malformed core section '${node.title}'; use ${replacement}`);
110
121
  }
111
- if (KNOWN_SECTIONS.has(normalized)) {
112
- if (sections.has(normalized)) {
122
+ const coreSection = KNOWN_SECTIONS.get(normalized);
123
+ if (coreSection) {
124
+ if (sections.has(coreSection)) {
113
125
  invalidBindDraft(`duplicate section '${node.title}' in bind draft`);
114
126
  }
115
- sections.set(normalized, node);
127
+ sections.set(coreSection, node);
116
128
  continue;
117
129
  }
118
130
  extensions.push({
@@ -155,7 +167,9 @@ export function parsePartialBindDraft(content) {
155
167
  if (!section)
156
168
  continue;
157
169
  const text = renderSectionContent(section).trim();
158
- const heading = `${sectionName[0].toUpperCase()}${sectionName.slice(1)}`;
170
+ const heading = sectionName === "design"
171
+ ? "Design & Approach"
172
+ : `${sectionName[0].toUpperCase()}${sectionName.slice(1)}`;
159
173
  if (!text)
160
174
  invalidBindDraft(`${heading} section cannot be empty`);
161
175
  partial[sectionName] = text;
@@ -229,7 +243,7 @@ export function renderMissingBindSectionsTemplate(missing) {
229
243
  continue;
230
244
  }
231
245
  if (section === "design") {
232
- lines.push("## Design", "<settled owner, boundaries, flow, state, and failure behavior>");
246
+ lines.push("## Design & Approach", "<settled owner, boundaries, flow, state, and failure behavior>");
233
247
  continue;
234
248
  }
235
249
  if (section === "scope") {
@@ -245,13 +259,23 @@ export function renderMissingBindSectionsTemplate(missing) {
245
259
  return lines.join("\n");
246
260
  }
247
261
  export function describeMissingBindSections(missing) {
248
- if (missing.length === 0)
262
+ const labels = {
263
+ title: "Title",
264
+ context: "Context",
265
+ objective: "Objective",
266
+ design: "Design & Approach",
267
+ scope: "Scope",
268
+ criteria: "Criteria",
269
+ verification: "Verification",
270
+ };
271
+ const named = missing.map((section) => labels[section]);
272
+ if (named.length === 0)
249
273
  return "";
250
- if (missing.length === 1)
251
- return missing[0];
252
- if (missing.length === 2)
253
- return `${missing[0]} or ${missing[1]}`;
254
- return `${missing.slice(0, -1).join(", ")}, or ${missing[missing.length - 1]}`;
274
+ if (named.length === 1)
275
+ return named[0];
276
+ if (named.length === 2)
277
+ return `${named[0]} or ${named[1]}`;
278
+ return `${named.slice(0, -1).join(", ")}, or ${named[named.length - 1]}`;
255
279
  }
256
280
  export function materializeBindDraft(partial) {
257
281
  const missing = missingBindDraftSections(partial);
@@ -272,7 +296,7 @@ export function materializeBindDraft(partial) {
272
296
  export function parseBindDraft(content) {
273
297
  const { title, sections, extensions } = collectBindDraftNodes(content);
274
298
  if (!title) {
275
- invalidBindDraft("missing H1 Title in bind draft; expected '# <title>' followed by Context, Objective, Design, Scope, and Criteria; Verification is optional");
299
+ invalidBindDraft("missing H1 Title in bind draft; expected '# <title>' followed by Context, Objective, Design & Approach, Scope, and Criteria; Verification is optional");
276
300
  }
277
301
  const context = sections.get("context");
278
302
  const objective = sections.get("objective");
@@ -285,7 +309,7 @@ export function parseBindDraft(content) {
285
309
  if (!objective)
286
310
  invalidBindDraft("missing Objective section in bind draft");
287
311
  if (!design)
288
- invalidBindDraft("missing Design section in bind draft");
312
+ invalidBindDraft("missing Design & Approach section in bind draft");
289
313
  if (!scope)
290
314
  invalidBindDraft("missing Scope section in bind draft");
291
315
  const parsedScope = parseScopeSection(content);
@@ -299,7 +323,7 @@ export function parseBindDraft(content) {
299
323
  if (!objectiveText)
300
324
  invalidBindDraft("Objective section cannot be empty");
301
325
  if (!designText)
302
- invalidBindDraft("Design section cannot be empty");
326
+ invalidBindDraft("Design & Approach section cannot be empty");
303
327
  return {
304
328
  title,
305
329
  context: contextText,
@@ -316,7 +340,7 @@ export function renderBindDraft(draft) {
316
340
  `# ${draft.title}`,
317
341
  `## Context\n${draft.context}`,
318
342
  `## Objective\n${draft.objective}`,
319
- `## Design\n${draft.design}`,
343
+ `## Design & Approach\n${draft.design}`,
320
344
  `## Scope\n${renderScopeFragment(draft.scope)}`,
321
345
  `## Criteria\n${draft.criteria.map((criterion) => `- ${criterion}`).join("\n")}`,
322
346
  ];
@@ -340,7 +364,6 @@ export function compileBindDraftFlags(input) {
340
364
  ["--context", input.context],
341
365
  ["--objective", input.objective],
342
366
  ["--design", input.design],
343
- ["--scope", input.scope],
344
367
  ["--criteria", input.criteria],
345
368
  ["--verification", input.verification],
346
369
  ]) {
@@ -348,6 +371,12 @@ export function compileBindDraftFlags(input) {
348
371
  throw new FlowError("EMPTY_PARAM", `${flag} must be a single line`);
349
372
  }
350
373
  }
374
+ const scope = typeof input.scope === "string" ? [input.scope] : input.scope;
375
+ for (const pattern of scope ?? []) {
376
+ if (pattern.includes("\n") || pattern.includes("\r")) {
377
+ throw new FlowError("EMPTY_PARAM", "--scope must be a single line");
378
+ }
379
+ }
351
380
  const sections = [];
352
381
  if (input.title !== undefined)
353
382
  sections.push(`# ${input.title}`);
@@ -356,9 +385,9 @@ export function compileBindDraftFlags(input) {
356
385
  if (input.objective !== undefined)
357
386
  sections.push(`## Objective\n${input.objective}`);
358
387
  if (input.design !== undefined)
359
- sections.push(`## Design\n${input.design}`);
360
- if (input.scope !== undefined)
361
- sections.push(`## Scope\n~~~\n${input.scope}\n~~~`);
388
+ sections.push(`## Design & Approach\n${input.design}`);
389
+ if (scope !== undefined)
390
+ sections.push(`## Scope\n~~~\n${scope.join("\n")}\n~~~`);
362
391
  if (input.criteria !== undefined)
363
392
  sections.push(`## Criteria\n- ${input.criteria}`);
364
393
  if (input.verification !== undefined)
@@ -247,7 +247,7 @@ export function validateHistory(records) {
247
247
  export function extractTerminalTimestamp(facts) {
248
248
  if (!facts)
249
249
  return undefined;
250
- for (const key of ["completedAt", "failedAt", "dismissedAt", "killedAt", "terminalAt"]) {
250
+ for (const key of ["terminalAt", "completedAt", "failedAt", "dismissedAt", "killedAt"]) {
251
251
  const value = facts[key];
252
252
  if (typeof value === "string" && Number.isFinite(Date.parse(value)))
253
253
  return value;
@@ -112,7 +112,10 @@ export async function runProjectionGeneration(input) {
112
112
  // Adoption is the provider-admission boundary. A kill that committed first
113
113
  // settles without constructing a provider execution.
114
114
  if (current.killIntent) {
115
- const killed = store.runnerKilledIfRequested({ executionId: input.executionId });
115
+ const killed = store.runnerKilledIfRequested({
116
+ executionId: input.executionId,
117
+ terminalAt: new Date(now()).toISOString(),
118
+ });
116
119
  if (killed.status !== "committed")
117
120
  return { status: "rejected" };
118
121
  settled = true;
@@ -126,7 +129,10 @@ export async function runProjectionGeneration(input) {
126
129
  }
127
130
  if (current.killIntent) {
128
131
  await prepared.dispose();
129
- const killed = store.runnerKilledIfRequested({ executionId: input.executionId });
132
+ const killed = store.runnerKilledIfRequested({
133
+ executionId: input.executionId,
134
+ terminalAt: new Date(now()).toISOString(),
135
+ });
130
136
  if (killed.status !== "committed")
131
137
  return { status: "rejected" };
132
138
  settled = true;
@@ -278,6 +284,7 @@ export async function runProjectionGeneration(input) {
278
284
  const killed = store.runnerKilledIfRequested({
279
285
  executionId: input.executionId,
280
286
  ...(facts && Object.keys(facts).length > 0 ? { facts } : {}),
287
+ terminalAt: new Date(now()).toISOString(),
281
288
  });
282
289
  if (killed.status !== "committed")
283
290
  return { status: "rejected" };
@@ -290,7 +297,10 @@ export async function runProjectionGeneration(input) {
290
297
  await handle.abort("force");
291
298
  if (sawInterruptIntent)
292
299
  return settleInterrupted();
293
- const killed = store.runnerKilledIfRequested({ executionId: input.executionId });
300
+ const killed = store.runnerKilledIfRequested({
301
+ executionId: input.executionId,
302
+ terminalAt: new Date(now()).toISOString(),
303
+ });
294
304
  if (killed.status !== "committed")
295
305
  return { status: "rejected" };
296
306
  settled = true;
@@ -111,6 +111,7 @@ export function requestInterruptIfOpen(database, readOnly, input) {
111
111
  }
112
112
  export function runnerKilledIfRequested(database, readOnly, input) {
113
113
  const executionId = assertExecutionId(input.executionId);
114
+ const terminalAt = assertIsoTimestamp(input.terminalAt, "terminalAt");
114
115
  const callerFacts = cloneFacts(input.facts ?? {}, "killed verdict facts");
115
116
  if ("state" in callerFacts) {
116
117
  throw new ProjectionGenerationStoreError("killed verdict facts.state is reserved");
@@ -121,6 +122,9 @@ export function runnerKilledIfRequested(database, readOnly, input) {
121
122
  if ("killedAt" in callerFacts) {
122
123
  throw new ProjectionGenerationStoreError("killed verdict facts.killedAt is reserved");
123
124
  }
125
+ if ("terminalAt" in callerFacts) {
126
+ throw new ProjectionGenerationStoreError("killed verdict facts.terminalAt is reserved");
127
+ }
124
128
  return writeTransition(database, readOnly, () => {
125
129
  const history = readValidatedHistory(database);
126
130
  const current = history.current;
@@ -137,6 +141,7 @@ export function runnerKilledIfRequested(database, readOnly, input) {
137
141
  state: "killed",
138
142
  killIntentSeq: current.killIntent.seq,
139
143
  killedAt: current.killIntent.facts.killedAt,
144
+ terminalAt,
140
145
  }, "killed verdict facts");
141
146
  return committed([appendRecord(database, "verdict", executionId, facts, history.records.length + 1)]);
142
147
  });
@@ -221,6 +226,7 @@ export function runnerVerdictIfOpen(database, readOnly, input) {
221
226
  }
222
227
  export function settleKillIfRunnerDead(database, readOnly, input) {
223
228
  const executionId = assertExecutionId(input.executionId);
229
+ const terminalAt = assertIsoTimestamp(input.terminalAt, "terminalAt");
224
230
  const releasedLockEvidence = cloneFacts(input.releasedLockEvidence, "released runner-lock evidence");
225
231
  if (Object.keys(releasedLockEvidence).length === 0) {
226
232
  throw new ProjectionGenerationStoreError("released runner-lock evidence must not be empty");
@@ -235,6 +241,9 @@ export function settleKillIfRunnerDead(database, readOnly, input) {
235
241
  if ("killedAt" in callerFacts) {
236
242
  throw new ProjectionGenerationStoreError("killed verdict facts.killedAt is reserved");
237
243
  }
244
+ if ("terminalAt" in callerFacts) {
245
+ throw new ProjectionGenerationStoreError("killed verdict facts.terminalAt is reserved");
246
+ }
238
247
  return writeTransition(database, readOnly, () => {
239
248
  const history = readValidatedHistory(database);
240
249
  const current = history.current;
@@ -251,6 +260,7 @@ export function settleKillIfRunnerDead(database, readOnly, input) {
251
260
  state: "killed",
252
261
  killIntentSeq: current.killIntent.seq,
253
262
  killedAt: current.killIntent.facts.killedAt,
263
+ terminalAt,
254
264
  releasedLockEvidence,
255
265
  }, "killed verdict facts");
256
266
  return committed([appendRecord(database, "verdict", executionId, facts, history.records.length + 1)]);
@@ -8,7 +8,7 @@ import { parseStructuredQuery, STRUCTURED_QUERY_COMPLETION, STRUCTURED_QUERY_GRA
8
8
  import { selectValidStoredAgentEvents } from "../stored-agent-event.js";
9
9
  import { extractTerminalTimestamp } from "./generation/model.js";
10
10
  import { readProjectionAlias } from "./projection-alias.js";
11
- import { foldPublicProjectionActivity } from "./projection-activity.js";
11
+ import { foldPublicProjectionActivity, } from "./projection-activity.js";
12
12
  import { isProjectionId, projectionDir, projectionRoot, readGenerationEventsWithRetention, readProjectionEvents } from "./projection-core.js";
13
13
  import { projectionCoordinateRoot } from "./projection-coordinate.js";
14
14
  import { readProjectionIdentity } from "./projection-identity.js";
@@ -31,7 +31,7 @@ export function interruptProjectionGeneration(projectionDirectory, nowMs = Date.
31
31
  store.close();
32
32
  }
33
33
  }
34
- function settleReleasedLeash(store, projectionDirectory, executionId) {
34
+ function settleReleasedLeash(store, projectionDirectory, executionId, terminalAt) {
35
35
  const current = store.readCurrentGeneration();
36
36
  if (current?.launch.executionId !== executionId
37
37
  || current.verdict) {
@@ -50,7 +50,8 @@ function settleReleasedLeash(store, projectionDirectory, executionId) {
50
50
  try {
51
51
  store.settleKillIfRunnerDead({
52
52
  executionId,
53
- releasedLockEvidence: { state: "acquired", executionId },
53
+ releasedLockEvidence: { state: "acquired", executionId, observedAt: terminalAt },
54
+ terminalAt,
54
55
  });
55
56
  }
56
57
  finally {
@@ -78,11 +79,11 @@ export function killProjectionGeneration(projectionDirectory, nowMs = Date.now()
78
79
  const executionId = current.launch.executionId;
79
80
  const request = store.requestKillIfOpen({ executionId, killedAt });
80
81
  if (request.status === "committed") {
81
- return settleReleasedLeash(store, projectionDirectory, executionId);
82
+ return settleReleasedLeash(store, projectionDirectory, executionId, new Date(nowMs).toISOString());
82
83
  }
83
84
  if (request.reason === "kill-requested") {
84
85
  // Idempotent first intent: still accepted, then one leash observation.
85
- return settleReleasedLeash(store, projectionDirectory, executionId);
86
+ return settleReleasedLeash(store, projectionDirectory, executionId, new Date(nowMs).toISOString());
86
87
  }
87
88
  if (request.reason === "already-terminal") {
88
89
  return { status: "already-closed", executionId };