@astrosheep/keiyaku 2.9.6 → 2.9.7

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 (54) hide show
  1. package/build/.tsbuildinfo +1 -1
  2. package/build/agents/harness/outcome.js +10 -0
  3. package/build/cli/commands/contract/bind/handler.js +10 -4
  4. package/build/cli/commands/contract/bind/meta.js +6 -6
  5. package/build/cli/commands/contract/petition/handler.js +16 -3
  6. package/build/cli/commands/contract/petition/meta.js +4 -4
  7. package/build/cli/commands/metadata.js +3 -2
  8. package/build/cli/commands/projection/tell/handler.js +10 -2
  9. package/build/cli/commands/projection/tell/meta.js +3 -3
  10. package/build/cli/commands/task/catalog.js +2 -0
  11. package/build/cli/commands/task/log/handler.js +12 -0
  12. package/build/cli/commands/task/log/meta.js +8 -0
  13. package/build/cli/flags.js +8 -0
  14. package/build/cli/index.js +10 -6
  15. package/build/cli/parse-flags.js +6 -0
  16. package/build/cli/parse-metadata.js +1 -1
  17. package/build/cli/render/line-width.js +33 -0
  18. package/build/cli/render/path-prefix-compaction.js +88 -0
  19. package/build/cli/render/petition.js +4 -0
  20. package/build/cli/render/projection-activity.js +93 -20
  21. package/build/cli/render/shared.js +34 -12
  22. package/build/cli/render/success-response.js +2 -0
  23. package/build/cli/render/tool-presentation.js +3 -3
  24. package/build/cli/render/wait.js +68 -48
  25. package/build/cli/subagent-guard.js +3 -0
  26. package/build/cli/types.js +1 -1
  27. package/build/core/bind.js +111 -6
  28. package/build/core/draft.js +1 -1
  29. package/build/core/projection/generation/database.js +21 -2
  30. package/build/core/projection/generation/model.js +24 -3
  31. package/build/core/projection/generation/projection-generation-continuation.js +75 -10
  32. package/build/core/projection/generation/projection-generation-execution.js +4 -3
  33. package/build/core/projection/generation/projection-generation-runner.js +96 -53
  34. package/build/core/projection/generation/projection-generation-runtime.js +19 -0
  35. package/build/core/projection/generation/store.js +9 -0
  36. package/build/core/projection/generation/transitions.js +105 -3
  37. package/build/core/projection/index.js +2 -2
  38. package/build/core/projection/projection-core.js +1 -1
  39. package/build/core/projection/projection-kill.js +31 -0
  40. package/build/core/projection/projection-life-protocol.js +10 -0
  41. package/build/core/projection/projection-wait.js +53 -15
  42. package/build/core/projection/projection-wake.js +35 -3
  43. package/build/core/projection/tell/database.js +18 -0
  44. package/build/core/projection/tell/model.js +1 -0
  45. package/build/core/projection/tell/store.js +102 -55
  46. package/build/core/registry.js +82 -69
  47. package/build/core/scope.js +9 -9
  48. package/build/core/task/index.js +2 -2
  49. package/build/core/task/task-contract.js +18 -0
  50. package/build/core/task/task-git-store.js +50 -0
  51. package/build/generated/version.js +2 -2
  52. package/package.json +1 -1
  53. package/skills/keiyaku-akuma/SKILL.md +10 -0
  54. package/skills/keiyaku-workflow/SKILL.md +76 -7
@@ -1,7 +1,6 @@
1
1
  import { ProjectionStateError, randomHex8 } from "../../atomic-publish.js";
2
- import { withRead, withWrite } from "./database.js";
3
- import { assertTellIdRandomHex, originalFromRow, parseFence, requireExecutionId, submittedFromRow, } from "./model.js";
4
- const PENDING_TELL_LIMIT = 10_000;
2
+ import { withRead, withReadSnapshot, withWrite } from "./database.js";
3
+ import { assertTellIdRandomHex, originalFromRow, PENDING_PROJECTION_TELL_STATES, parseFence, requireExecutionId, submittedFromRow, } from "./model.js";
5
4
  function selectTell(database, tellId) {
6
5
  return database.prepare(`
7
6
  SELECT
@@ -26,6 +25,77 @@ export function listTellIds(projectionDirectory, state) {
26
25
  ORDER BY tell_id
27
26
  `).all(state).map((row) => row.tell_id));
28
27
  }
28
+ /** List continuation fence metadata without loading operator tell bodies. */
29
+ export function listTellMetadataFromDatabase(database, states) {
30
+ const placeholders = states.map(() => "?").join(", ");
31
+ return database.prepare(`
32
+ SELECT tell_id, state, execution_id, effort
33
+ FROM projection_tells
34
+ WHERE state IN (${placeholders})
35
+ ORDER BY tell_id
36
+ `).all(...states).map((row) => ({
37
+ tellId: row.tell_id,
38
+ state: row.state,
39
+ ...(row.execution_id ? { executionId: row.execution_id } : {}),
40
+ ...(row.effort ? { effort: row.effort } : {}),
41
+ }));
42
+ }
43
+ /** List continuation fence metadata without loading operator tell bodies. */
44
+ export function listTellMetadata(projectionDirectory, states) {
45
+ return withRead(projectionDirectory, (database) => listTellMetadataFromDatabase(database, states));
46
+ }
47
+ export function readPendingTellWindowFromDatabase(database, limit) {
48
+ if (!Number.isSafeInteger(limit) || limit < 0) {
49
+ throw new ProjectionStateError("pending tell window limit must be a non-negative safe integer");
50
+ }
51
+ const pendingPlaceholders = PENDING_PROJECTION_TELL_STATES.map(() => "?").join(", ");
52
+ const total = database.prepare(`
53
+ SELECT COUNT(*) AS count
54
+ FROM projection_tells
55
+ WHERE state IN (${pendingPlaceholders})
56
+ `).get(...PENDING_PROJECTION_TELL_STATES);
57
+ const rows = database.prepare(`
58
+ SELECT tell_id, text, created_at, state
59
+ FROM projection_tells
60
+ WHERE state IN (${pendingPlaceholders})
61
+ ORDER BY tell_id
62
+ LIMIT ?
63
+ `).all(...PENDING_PROJECTION_TELL_STATES, limit);
64
+ return {
65
+ totals: { undelivered: Number(total.count) },
66
+ window: rows.map((row) => ({ tellId: row.tell_id, text: row.text, createdAt: row.created_at, state: row.state })),
67
+ };
68
+ }
69
+ export function readPendingTellWindow(projectionDirectory, limit) {
70
+ return withReadSnapshot(projectionDirectory, (database) => readPendingTellWindowFromDatabase(database, limit));
71
+ }
72
+ /** Whether a failed successor fence still retains delivered mail for replay. */
73
+ export function hasRetainedDeliveredTellFromDatabase(database, retainedFence) {
74
+ if (retainedFence.length === 0)
75
+ return false;
76
+ const statement = database.prepare(`
77
+ SELECT 1
78
+ FROM projection_tells
79
+ WHERE tell_id = ? AND state = 'delivered'
80
+ LIMIT 1
81
+ `);
82
+ return retainedFence.some((tellId) => statement.get(tellId) !== undefined);
83
+ }
84
+ export function hasRetainedDeliveredTell(projectionDirectory, retainedFence) {
85
+ return withRead(projectionDirectory, (database) => hasRetainedDeliveredTellFromDatabase(database, retainedFence));
86
+ }
87
+ /** Whether interrupted replay has any delivered tell left to return to inbox. */
88
+ export function hasDeliveredTellFromDatabase(database, executionId) {
89
+ return database.prepare(`
90
+ SELECT 1
91
+ FROM projection_tells
92
+ WHERE state = 'delivered' AND execution_id = ?
93
+ LIMIT 1
94
+ `).get(executionId) !== undefined;
95
+ }
96
+ export function hasDeliveredTell(projectionDirectory, executionId) {
97
+ return withRead(projectionDirectory, (database) => hasDeliveredTellFromDatabase(database, executionId));
98
+ }
29
99
  export function readTellOriginal(projectionDirectory, state, tellId) {
30
100
  return withRead(projectionDirectory, (database) => {
31
101
  const row = selectTell(database, tellId);
@@ -49,14 +119,6 @@ export function writeInboxTell(projectionDirectory, text, nowMs, options) {
49
119
  }
50
120
  const tellId = `${nowMs}-${assertTellIdRandomHex((options?.randomHex ?? randomHex8)())}`;
51
121
  return withWrite(projectionDirectory, (database) => {
52
- const pending = database.prepare(`
53
- SELECT COUNT(*) AS count
54
- FROM projection_tells
55
- WHERE state IN ('inbox', 'inflight', 'submitted', 'delivered')
56
- `).get();
57
- if (Number(pending.count) >= PENDING_TELL_LIMIT) {
58
- throw new ProjectionStateError(`projection has reached the ${PENDING_TELL_LIMIT} pending tell limit`);
59
- }
60
122
  try {
61
123
  database.prepare(`
62
124
  INSERT INTO projection_tells (
@@ -123,6 +185,31 @@ export function claimFencedTells(projectionDirectory, fence, executionId) {
123
185
  claimed.push(tellId);
124
186
  continue;
125
187
  }
188
+ if (row.state === "submitted" || row.state === "delivered") {
189
+ const submitted = submittedFromRow(row);
190
+ if (submitted.executionId === owner) {
191
+ throw new ProjectionStateError(`submitted tell belongs to current execution: ${tellId}`);
192
+ }
193
+ const replayedFrom = [
194
+ ...(submitted.replayedFrom ?? []),
195
+ { executionId: submitted.executionId, fence: submitted.fence },
196
+ ];
197
+ const result = database.prepare(`
198
+ UPDATE projection_tells
199
+ SET
200
+ state = 'inflight',
201
+ execution_id = NULL,
202
+ fence_json = NULL,
203
+ replayed_from_json = ?,
204
+ delivered_at = NULL
205
+ WHERE tell_id = ? AND state = ?
206
+ `).run(JSON.stringify(replayedFrom), tellId, row.state);
207
+ if (Number(result.changes) !== 1) {
208
+ throw new ProjectionStateError(`generation tell fence member changed while claiming: ${tellId}`);
209
+ }
210
+ claimed.push(tellId);
211
+ continue;
212
+ }
126
213
  if (row.state === "consumed" && row.execution_id === owner)
127
214
  continue;
128
215
  throw new ProjectionStateError(`generation tell fence member is not claimable: ${tellId}`);
@@ -152,7 +239,7 @@ export function markTellSubmitted(projectionDirectory, tellId, fields) {
152
239
  state = 'submitted',
153
240
  execution_id = ?,
154
241
  fence_json = ?,
155
- replayed_from_json = ?
242
+ replayed_from_json = COALESCE(?, replayed_from_json)
156
243
  WHERE tell_id = ? AND state = 'inflight'
157
244
  `).run(executionId, JSON.stringify(fence), replayedFrom.length > 0 ? JSON.stringify(replayedFrom) : null, tellId);
158
245
  });
@@ -190,12 +277,13 @@ export function readProjectionTellCounts(projectionDirectory) {
190
277
  }
191
278
  export function snapshotPendingTells(projectionDirectory) {
192
279
  return withRead(projectionDirectory, (database) => {
280
+ const pendingPlaceholders = PENDING_PROJECTION_TELL_STATES.map(() => "?").join(", ");
193
281
  const rows = database.prepare(`
194
282
  SELECT tell_id, effort
195
283
  FROM projection_tells
196
- WHERE state IN ('inbox', 'inflight', 'submitted', 'delivered')
284
+ WHERE state IN (${pendingPlaceholders})
197
285
  ORDER BY tell_id
198
- `).all();
286
+ `).all(...PENDING_PROJECTION_TELL_STATES);
199
287
  const effort = rows.reduce((selected, row) => row.effort ?? selected, undefined);
200
288
  return {
201
289
  tellIds: rows.map((row) => row.tell_id),
@@ -203,44 +291,3 @@ export function snapshotPendingTells(projectionDirectory) {
203
291
  };
204
292
  });
205
293
  }
206
- export function demoteSubmittedTellsForReplay(projectionDirectory, executionId) {
207
- const currentExecutionId = requireExecutionId(executionId);
208
- return withWrite(projectionDirectory, (database) => {
209
- const rows = database.prepare(`
210
- SELECT
211
- tell_id,
212
- text,
213
- created_at,
214
- effort,
215
- state,
216
- execution_id,
217
- fence_json,
218
- replayed_from_json,
219
- delivered_at
220
- FROM projection_tells
221
- WHERE state IN ('submitted', 'delivered')
222
- ORDER BY tell_id
223
- `).all();
224
- for (const row of rows) {
225
- const submitted = submittedFromRow(row);
226
- if (submitted.executionId === currentExecutionId) {
227
- throw new ProjectionStateError(`submitted tell belongs to current execution: ${row.tell_id}`);
228
- }
229
- const replayedFrom = [
230
- ...(submitted.replayedFrom ?? []),
231
- { executionId: submitted.executionId, fence: submitted.fence },
232
- ];
233
- database.prepare(`
234
- UPDATE projection_tells
235
- SET
236
- state = 'inflight',
237
- execution_id = NULL,
238
- fence_json = NULL,
239
- replayed_from_json = ?,
240
- delivered_at = NULL
241
- WHERE tell_id = ? AND state IN ('submitted', 'delivered')
242
- `).run(JSON.stringify(replayedFrom), row.tell_id);
243
- }
244
- return rows.map((row) => row.tell_id);
245
- });
246
- }
@@ -1,7 +1,7 @@
1
1
  import { FlowError } from "../flow-error.js";
2
2
  import { createGit, wrapGitError } from "../git/core.js";
3
3
  import { readLedger } from "./ledger.js";
4
- import { appendRefLogWithRetry } from "./ref-log.js";
4
+ import { appendRefLogWithRetry, readRefLog } from "./ref-log.js";
5
5
  import { patternsOverlap, scopePathEvidence } from "./scope.js";
6
6
  import { deriveContractState, isTerminalState } from "./status/lifecycle.js";
7
7
  export const REGISTRY_REF = "refs/keiyaku/registry";
@@ -39,11 +39,9 @@ async function activeRegistrations(cwd, entries) {
39
39
  }
40
40
  return active;
41
41
  }
42
- export async function registerScope(cwd, input) {
43
- let overlaps = [];
44
- let trackedPaths;
42
+ async function trackedRepositoryPaths(cwd) {
45
43
  try {
46
- trackedPaths = (await createGit(cwd).raw(["ls-files", "-z"]))
44
+ return (await createGit(cwd).raw(["ls-files", "-z"]))
47
45
  .split("\0")
48
46
  .filter(Boolean)
49
47
  .sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
@@ -51,76 +49,91 @@ export async function registerScope(cwd, input) {
51
49
  catch (error) {
52
50
  throw wrapGitError("ls-files -z", error, cwd);
53
51
  }
54
- const result = await appendRefLogWithRetry(cwd, REGISTRY_REF, registrySubject, parseRegistryEntry, async (entries) => {
55
- const nextOverlaps = [];
56
- const active = await activeRegistrations(cwd, entries);
57
- if (input.exclusive && active.length > 0) {
58
- const blocking = active.find((existing) => existing.contract !== input.contractId) ?? active[0];
59
- throw new FlowError("ACTIVE_KEIYAKU_EXISTS", `exclusive bind rejected: active contract ${blocking.contract} already exists`);
52
+ }
53
+ async function scopeRegistrationAdmission(cwd, input, entries, trackedPaths) {
54
+ const nextOverlaps = [];
55
+ const active = await activeRegistrations(cwd, entries);
56
+ if (input.exclusive && active.length > 0) {
57
+ const blocking = active.find((existing) => existing.contract !== input.contractId) ?? active[0];
58
+ throw new FlowError("ACTIVE_KEIYAKU_EXISTS", `exclusive bind rejected: active contract ${blocking.contract} already exists`);
59
+ }
60
+ for (const existing of active) {
61
+ if (existing.contract === input.contractId)
62
+ continue;
63
+ if (existing.tree === input.tree) {
64
+ throw new FlowError("ACTIVE_KEIYAKU_EXISTS", [
65
+ `tree is already occupied by active contract ${existing.contract}`,
66
+ `Run \`keiyaku forfeit @${existing.contract}\` to free this tree.`,
67
+ ].join(" "));
60
68
  }
61
- for (const existing of active) {
62
- if (existing.contract === input.contractId)
69
+ const [nextEvidence, existingEvidence] = await Promise.all([
70
+ scopePathEvidence(cwd, input.scope, trackedPaths),
71
+ scopePathEvidence(cwd, existing.scope, trackedPaths),
72
+ ]);
73
+ const existingByPath = new Map(existingEvidence.map((evidence) => [evidence.path, evidence]));
74
+ const concreteByPatterns = new Map();
75
+ for (const next of nextEvidence) {
76
+ const prior = existingByPath.get(next.path);
77
+ if (!next.matched || !prior?.matched || !next.decisivePattern || !prior.decisivePattern)
63
78
  continue;
64
- if (existing.tree === input.tree) {
65
- throw new FlowError("ACTIVE_KEIYAKU_EXISTS", [
66
- `tree is already occupied by active contract ${existing.contract}`,
67
- `Run \`keiyaku forfeit @${existing.contract}\` to free this tree.`,
68
- ].join(" "));
69
- }
70
- const [nextEvidence, existingEvidence] = await Promise.all([
71
- scopePathEvidence(cwd, input.scope, trackedPaths),
72
- scopePathEvidence(cwd, existing.scope, trackedPaths),
73
- ]);
74
- const existingByPath = new Map(existingEvidence.map((evidence) => [evidence.path, evidence]));
75
- const concreteByPatterns = new Map();
76
- for (const next of nextEvidence) {
77
- const prior = existingByPath.get(next.path);
78
- if (!next.matched || !prior?.matched || !next.decisivePattern || !prior.decisivePattern)
79
- continue;
80
- const key = `${next.decisivePattern.index}:${prior.decisivePattern.index}`;
81
- const group = concreteByPatterns.get(key) ?? {
82
- next: next.decisivePattern.pattern,
83
- existing: prior.decisivePattern.pattern,
84
- paths: [],
85
- };
86
- group.paths.push(next.path);
87
- concreteByPatterns.set(key, group);
88
- }
89
- for (const concrete of concreteByPatterns.values()) {
90
- const preview = concrete.paths.slice(0, SCOPE_OVERLAP_PATH_PREVIEW_LIMIT);
91
- nextOverlaps.push({
92
- contracts: [input.contractId, existing.contract],
93
- patterns: [concrete.next, concrete.existing],
94
- kind: "concrete",
95
- paths: {
96
- preview,
97
- count: concrete.paths.length,
98
- truncated: preview.length < concrete.paths.length,
99
- },
100
- });
101
- }
102
- for (const nextPattern of input.scope) {
103
- for (const existingPattern of existing.scope) {
104
- if (patternsOverlap(nextPattern, existingPattern)) {
105
- const [nextPatternEvidence, existingPatternEvidence] = await Promise.all([
106
- scopePathEvidence(cwd, [nextPattern], trackedPaths),
107
- scopePathEvidence(cwd, [existingPattern], trackedPaths),
108
- ]);
109
- const existingPatternByPath = new Map(existingPatternEvidence.map((evidence) => [evidence.path, evidence]));
110
- const hasCurrentWitness = nextPatternEvidence.some((evidence) => (evidence.matched && existingPatternByPath.get(evidence.path)?.matched));
111
- if (!hasCurrentWitness) {
112
- nextOverlaps.push({
113
- contracts: [input.contractId, existing.contract],
114
- patterns: [nextPattern, existingPattern],
115
- kind: "potential",
116
- paths: { preview: [], count: 0, truncated: false },
117
- });
118
- }
79
+ const key = `${next.decisivePattern.index}:${prior.decisivePattern.index}`;
80
+ const group = concreteByPatterns.get(key) ?? {
81
+ next: next.decisivePattern.pattern,
82
+ existing: prior.decisivePattern.pattern,
83
+ paths: [],
84
+ };
85
+ group.paths.push(next.path);
86
+ concreteByPatterns.set(key, group);
87
+ }
88
+ for (const concrete of concreteByPatterns.values()) {
89
+ const preview = concrete.paths.slice(0, SCOPE_OVERLAP_PATH_PREVIEW_LIMIT);
90
+ nextOverlaps.push({
91
+ contracts: [input.contractId, existing.contract],
92
+ patterns: [concrete.next, concrete.existing],
93
+ kind: "concrete",
94
+ paths: {
95
+ preview,
96
+ count: concrete.paths.length,
97
+ truncated: preview.length < concrete.paths.length,
98
+ },
99
+ });
100
+ }
101
+ for (const nextPattern of input.scope) {
102
+ for (const existingPattern of existing.scope) {
103
+ if (patternsOverlap(nextPattern, existingPattern)) {
104
+ const [nextPatternEvidence, existingPatternEvidence] = await Promise.all([
105
+ scopePathEvidence(cwd, [nextPattern], trackedPaths),
106
+ scopePathEvidence(cwd, [existingPattern], trackedPaths),
107
+ ]);
108
+ const existingPatternByPath = new Map(existingPatternEvidence.map((evidence) => [evidence.path, evidence]));
109
+ const hasCurrentWitness = nextPatternEvidence.some((evidence) => (evidence.matched && existingPatternByPath.get(evidence.path)?.matched));
110
+ if (!hasCurrentWitness) {
111
+ nextOverlaps.push({
112
+ contracts: [input.contractId, existing.contract],
113
+ patterns: [nextPattern, existingPattern],
114
+ kind: "potential",
115
+ paths: { preview: [], count: 0, truncated: false },
116
+ });
119
117
  }
120
118
  }
121
119
  }
122
120
  }
123
- overlaps = nextOverlaps;
121
+ }
122
+ return nextOverlaps;
123
+ }
124
+ /** Read-only bind admission: validate exclusivity and classify scope overlap. */
125
+ export async function previewScopeRegistration(cwd, input) {
126
+ const [entries, trackedPaths] = await Promise.all([
127
+ readRefLog(cwd, REGISTRY_REF, parseRegistryEntry),
128
+ trackedRepositoryPaths(cwd),
129
+ ]);
130
+ return await scopeRegistrationAdmission(cwd, input, entries, trackedPaths);
131
+ }
132
+ export async function registerScope(cwd, input) {
133
+ let overlaps = [];
134
+ const trackedPaths = await trackedRepositoryPaths(cwd);
135
+ const result = await appendRefLogWithRetry(cwd, REGISTRY_REF, registrySubject, parseRegistryEntry, async (entries) => {
136
+ overlaps = await scopeRegistrationAdmission(cwd, input, entries, trackedPaths);
124
137
  return {
125
138
  v: 1,
126
139
  kind: "register",
@@ -38,17 +38,17 @@ export function isBlankScopePatternLine(line) {
38
38
  }
39
39
  /** Parse Keiyaku's source-line Scope opener, not a normalized Markdown node. */
40
40
  export function parseScopeFenceOpening(line) {
41
- const match = line.match(/^(`{3,})$/);
42
- return match ? { length: match[1].length } : null;
41
+ const match = line.match(/^(`{3,}|~{3,})$/);
42
+ return match ? { delimiter: match[1][0], length: match[1].length } : null;
43
43
  }
44
- /** Keiyaku closer: column-zero backticks, at least opener length, then whitespace. */
44
+ /** Keiyaku closer: matching column-zero delimiter, at least opener length, then whitespace. */
45
45
  export function isScopeFenceClosing(line, fence) {
46
- const match = line.match(/^(`+)[ \t]*$/);
47
- return Boolean(match && match[1].length >= fence.length);
46
+ const match = line.match(/^(`+|~+)[ \t]*$/);
47
+ return Boolean(match && match[1][0] === fence.delimiter && match[1].length >= fence.length);
48
48
  }
49
49
  function fencedScopeError(heading, detail) {
50
50
  return new FlowError("INVALID_KEIYAKU_DRAFT", `${heading} must contain exactly one fenced gitignore-subset block; ${detail}. ` +
51
- `Paste-ready canonical example:\n\n${heading}\n\`\`\`\nfoo\nbar\n\`\`\``);
51
+ `Paste-ready canonical example:\n\n${heading}\n~~~\nfoo\nbar\n~~~`);
52
52
  }
53
53
  /** Validate and normalize the one fenced block that constitutes a Scope body. */
54
54
  export function validateScopeFragmentLines(lines, options) {
@@ -152,14 +152,14 @@ export function markdownSourceSections(markdown) {
152
152
  return { ...heading, end: next?.start ?? markdown.length };
153
153
  });
154
154
  }
155
- /** Render canonical Scope bytes with a fence that content cannot close. */
155
+ /** Render canonical Scope bytes with a tilde fence that content cannot close. */
156
156
  export function renderScopeFragment(patterns) {
157
157
  let longestRun = 0;
158
158
  for (const pattern of patterns) {
159
- for (const run of pattern.match(/`+/g) ?? [])
159
+ for (const run of pattern.match(/~+/g) ?? [])
160
160
  longestRun = Math.max(longestRun, run.length);
161
161
  }
162
- const fence = "`".repeat(Math.max(3, longestRun + 1));
162
+ const fence = "~".repeat(Math.max(3, longestRun + 1));
163
163
  return `${fence}\n${patterns.join("\n")}\n${fence}`;
164
164
  }
165
165
  /**
@@ -3,10 +3,10 @@
3
3
  * Only symbols required by current external consumers are re-exported.
4
4
  * Package-internal modules must import each other directly, not through this file.
5
5
  */
6
- export { reconcileTaskForfeit, settleTaskBoardForBind, settleTaskBoardForLanding, } from "./task-contract.js";
6
+ export { previewTaskBoardForBind, reconcileTaskForfeit, settleTaskBoardForBind, settleTaskBoardForLanding, } from "./task-contract.js";
7
7
  export { resolveTaskCreator } from "./task-git-actor.js";
8
8
  export { openTaskCommandRepository, readTaskDoctor, taskCommandContractId, } from "./task-git-runtime.js";
9
- export { openTaskStore } from "./task-git-store.js";
9
+ export { openTaskStore, readTaskHistory } from "./task-git-store.js";
10
10
  export { taskRepositoryFromStore } from "./task-store-repository.js";
11
11
  export { isTaskId } from "./validation.js";
12
12
  export { buildTaskTree, createTask, doneTask, dropTask, parseTaskDocument, readTaskBoard, resolveTaskReadId, serializeTaskDocument, startTask, stopTask, updateTask, deriveTaskDisplay, selectTaskList, taskUpdatedAt, advancedTaskUpdatedAt, } from "./task.js";
@@ -7,6 +7,24 @@ import { buildTaskSourceBoard } from "./source-board.js";
7
7
  import { composeLandingTree, containsCommit, createLandingCommit, createTaskBoardCommit, findAcceptedLandingCommit, publishTaskMutationCommit, inspectCurrentBranch, publishForwardTaskBoardCommit, reconcileTaskPathsetIndex, refHead, taskContractAtCommit, taskSettlementOwnershipAtCommit, taskSourcesAtCommit, taskWritesFromSources, trackedTaskPaths, updateBranch, updateTargetRef, } from "./settlement-git.js";
8
8
  import { contractPointerDocument, doneTaskReplacements, reopenedTaskDocument, requireHealthySettlementBoard, requireSettledTask, selectReadyTasksForBind, tasksForContract, } from "./settlement-policy.js";
9
9
  import { stableRepoRoot } from "../worktree-path.js";
10
+ /**
11
+ * Read the same healthy task board and run the same ready-task selector as bind
12
+ * under the task admission lock, without replacing sources or publishing.
13
+ */
14
+ export async function previewTaskBoardForBind(input) {
15
+ await requireGitRepository(input.cwd, "task bind");
16
+ return await withLockHeldTaskBoard(input.cwd, async (transaction) => {
17
+ if (transaction.location.mode !== "git") {
18
+ if (transaction.location.capability === "unavailable") {
19
+ assertGitObservation({ capability: "unavailable" }, "task bind");
20
+ }
21
+ throw new FlowError("NOT_GIT_REPO", "task bind requires Git task storage");
22
+ }
23
+ const repository = taskRepositoryFromLockedBoard(transaction);
24
+ const board = requireHealthySettlementBoard(await repository.read());
25
+ return { tasks: input.taskIds === undefined ? [] : selectReadyTasksForBind(board, input.taskIds) };
26
+ });
27
+ }
10
28
  function sourcePath(root, source) {
11
29
  return source.path.slice(root.length + 1).split("\\").join("/");
12
30
  }
@@ -105,6 +105,29 @@ export async function locateTaskBoard(cwd, runGitProbe = defaultRunGit) {
105
105
  function taskDirectory(board) {
106
106
  return path.join(board.root, ".keiyaku", "tasks");
107
107
  }
108
+ const TASK_HISTORY_FORMAT = "%aI%x00%an%x00%s";
109
+ function taskHistoryPath(taskId) {
110
+ return `.keiyaku/tasks/${taskId}.md`;
111
+ }
112
+ function parseTaskHistory(output) {
113
+ const fields = output.split("\0");
114
+ if (fields.at(-1) !== "") {
115
+ throw new FlowError("INTERNAL_STATE", "task history returned an incomplete record");
116
+ }
117
+ fields.pop();
118
+ if (fields.length % 3 !== 0) {
119
+ throw new FlowError("INTERNAL_STATE", "task history returned an incomplete record");
120
+ }
121
+ const entries = [];
122
+ for (let index = 0; index < fields.length; index += 3) {
123
+ entries.push({
124
+ timestamp: fields[index],
125
+ actor: fields[index + 1],
126
+ subject: fields[index + 2],
127
+ });
128
+ }
129
+ return entries;
130
+ }
108
131
  function assertKeiyakuDirectory(board) {
109
132
  const keiyakuDirectory = path.join(board.root, ".keiyaku");
110
133
  const stat = lstatIfPresent(keiyakuDirectory);
@@ -201,6 +224,33 @@ export async function inspectTaskLock(cwd) {
201
224
  closeQuietly(database);
202
225
  }
203
226
  }
227
+ /** Read the current hub branch's task-specific Git history without acquiring the task lock. */
228
+ export async function readTaskHistory(cwd, taskId, deps = {}) {
229
+ if (!isTaskId(taskId)) {
230
+ throw new FlowError("INVALID_TARGET", `task history requires a canonical task id: ${taskId}`);
231
+ }
232
+ const board = await locateTaskBoard(cwd, deps.runGit);
233
+ if (board.mode !== "git") {
234
+ throw new FlowError("NOT_GIT_REPO", "task history is available only in a Git repository");
235
+ }
236
+ const result = await (deps.runGit ?? defaultRunGit)(board.root, [
237
+ "log",
238
+ "-z",
239
+ "--topo-order",
240
+ "--reverse",
241
+ `--format=${TASK_HISTORY_FORMAT}`,
242
+ "--",
243
+ taskHistoryPath(taskId),
244
+ ]);
245
+ if (result.error) {
246
+ throw new FlowError("INTERNAL_STATE", `cannot read task history: ${result.error.message}`, { cause: result.error });
247
+ }
248
+ if (result.status !== 0) {
249
+ const detail = result.stderr.trim() || `exit ${String(result.status)}`;
250
+ throw new FlowError("INTERNAL_STATE", `cannot read task history: ${detail}`);
251
+ }
252
+ return parseTaskHistory(result.stdout);
253
+ }
204
254
  export function atomicReplaceTaskFile(filePath, text, _deps) {
205
255
  const directory = path.dirname(filePath);
206
256
  fs.mkdirSync(directory, { recursive: true });
@@ -1,3 +1,3 @@
1
1
  // Generated into build output by scripts/build.mjs
2
- export const VERSION = "2.9.6";
3
- export const GIT_HASH = "cb7f9ef";
2
+ export const VERSION = "2.9.7";
3
+ export const GIT_HASH = "e0169c3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/keiyaku",
3
- "version": "2.9.6",
3
+ "version": "2.9.7",
4
4
  "description": "CLI for running iterative keiyaku workflows with Codex subagents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,6 +30,8 @@ keiyaku call NAME --detach "do the thing" # fire and forget; note the projec
30
30
  keiyaku call NAME - < prompt.md # long prompt: literal `-` reads stdin
31
31
  keiyaku wait <proj-id> # join a running one; prints its activity
32
32
  keiyaku wait <proj-id> --timeout 5m # peek for 5m; timeout = snapshot, not death
33
+ keiyaku wait <proj-a> <proj-b> --any # return when the first member finishes
34
+ keiyaku wait <proj-a> <proj-b> --all # return when every member finishes
33
35
  keiyaku tell <proj-id> [--wait DURATION] <BODY|-> # optionally tell, then bounded-join
34
36
  keiyaku revive rsp_XXXX "continue..." # new run continuing from a record
35
37
  keiyaku kill <proj-id> # stop it; record survives, revive still works
@@ -50,10 +52,18 @@ Use `keiyaku akuma view NAME` to inspect the resolved source and configuration.
50
52
  - Omit tell's bracketed `--wait DURATION` option for silent success: exit 0,
51
53
  zero output. Supply it to perform the ordinary bounded join after acceptance.
52
54
  Silence **is** the receipt — do not re-send. Effects show up in `wait`/`status`.
55
+ - `tell --interrupt` is intentionally destructive. On an active runner, silent
56
+ success means the tell and interrupt intent are durable; provider stop and
57
+ successor wake continue asynchronously under the same projection id. Idle or
58
+ terminal state uses ordinary tell plus wake and reports its typed wake error.
53
59
  - Body is required for `call`/`tell`. Literal `-` selects stdin; unselected
54
60
  piped bytes are ignored and cannot change an argv body.
55
61
  - A `--wait`/`--timeout` expiry prints a live snapshot and exits 0 — the run continues; rejoin with `wait` anytime.
56
62
  - `wait` is repeatable: on a finished run it shows the final transcript again.
63
+ - Supervise multiple projections with one plural `wait`. Choose `--any` to
64
+ handle the first terminal member and refill its lane; choose `--all` only for
65
+ final batch convergence. Keep foreground waits blocking; if bounded, use one
66
+ operationally meaningful timeout instead of repeated short-timeout polling.
57
67
 
58
68
  ## Occasional flags
59
69