@astrosheep/keiyaku 2.9.5 → 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 (75) hide show
  1. package/build/.tsbuildinfo +1 -1
  2. package/build/agents/harness/outcome.js +20 -4
  3. package/build/agents/harness/projection.js +8 -7
  4. package/build/agents/harness/router.js +2 -1
  5. package/build/agents/providers/claude-agent-sdk/adapter.js +13 -1
  6. package/build/agents/providers/claude-agent-sdk/registration.js +2 -1
  7. package/build/agents/providers/codex-app-server/adapter.js +11 -10
  8. package/build/agents/providers/codex-app-server/registration.js +2 -1
  9. package/build/agents/providers/opencode-sdk/adapter.js +7 -0
  10. package/build/agents/providers/opencode-sdk/registration.js +2 -1
  11. package/build/agents/providers/pi/adapter.js +10 -0
  12. package/build/agents/providers/pi/registration.js +3 -1
  13. package/build/cli/commands/contract/bind/handler.js +10 -4
  14. package/build/cli/commands/contract/bind/meta.js +6 -6
  15. package/build/cli/commands/contract/petition/handler.js +16 -3
  16. package/build/cli/commands/contract/petition/meta.js +4 -4
  17. package/build/cli/commands/metadata.js +3 -2
  18. package/build/cli/commands/projection/tell/handler.js +10 -2
  19. package/build/cli/commands/projection/tell/meta.js +3 -3
  20. package/build/cli/commands/task/catalog.js +2 -0
  21. package/build/cli/commands/task/log/handler.js +12 -0
  22. package/build/cli/commands/task/log/meta.js +8 -0
  23. package/build/cli/completion.js +9 -2
  24. package/build/cli/flags.js +11 -2
  25. package/build/cli/index.js +10 -6
  26. package/build/cli/parse-flags.js +6 -0
  27. package/build/cli/parse-metadata.js +1 -1
  28. package/build/cli/render/errors.js +18 -1
  29. package/build/cli/render/format.js +5 -2
  30. package/build/cli/render/line-width.js +33 -0
  31. package/build/cli/render/path-prefix-compaction.js +88 -0
  32. package/build/cli/render/petition.js +4 -0
  33. package/build/cli/render/projection-activity.js +93 -20
  34. package/build/cli/render/shared.js +34 -12
  35. package/build/cli/render/success-response.js +2 -0
  36. package/build/cli/render/tool-presentation.js +3 -3
  37. package/build/cli/render/wait.js +68 -48
  38. package/build/cli/subagent-guard.js +3 -0
  39. package/build/cli/types.js +1 -1
  40. package/build/core/audit/candidate.js +48 -0
  41. package/build/core/audit/coordinates.js +180 -0
  42. package/build/core/audit/evidence.js +158 -0
  43. package/build/core/audit/facade.js +42 -0
  44. package/build/core/audit/readiness.js +4 -0
  45. package/build/core/audit/report.js +38 -0
  46. package/build/core/audit.js +1 -650
  47. package/build/core/bind.js +111 -6
  48. package/build/core/draft.js +1 -1
  49. package/build/core/projection/generation/database.js +21 -2
  50. package/build/core/projection/generation/model.js +24 -3
  51. package/build/core/projection/generation/projection-generation-continuation.js +75 -10
  52. package/build/core/projection/generation/projection-generation-execution.js +4 -3
  53. package/build/core/projection/generation/projection-generation-runner.js +96 -53
  54. package/build/core/projection/generation/projection-generation-runtime.js +19 -0
  55. package/build/core/projection/generation/store.js +9 -0
  56. package/build/core/projection/generation/transitions.js +105 -3
  57. package/build/core/projection/index.js +2 -2
  58. package/build/core/projection/projection-core.js +1 -1
  59. package/build/core/projection/projection-kill.js +31 -0
  60. package/build/core/projection/projection-life-protocol.js +10 -0
  61. package/build/core/projection/projection-wait.js +53 -15
  62. package/build/core/projection/projection-wake.js +35 -3
  63. package/build/core/projection/tell/database.js +18 -0
  64. package/build/core/projection/tell/model.js +1 -0
  65. package/build/core/projection/tell/store.js +102 -55
  66. package/build/core/registry.js +82 -69
  67. package/build/core/scope.js +9 -9
  68. package/build/core/task/index.js +2 -2
  69. package/build/core/task/query.js +65 -23
  70. package/build/core/task/task-contract.js +18 -0
  71. package/build/core/task/task-git-store.js +50 -0
  72. package/build/generated/version.js +2 -2
  73. package/package.json +1 -1
  74. package/skills/keiyaku-akuma/SKILL.md +10 -0
  75. package/skills/keiyaku-workflow/SKILL.md +82 -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";
@@ -2,10 +2,16 @@ import { FlowError } from "../../flow-error.js";
2
2
  import { isCommissionId } from "../ids.js";
3
3
  import { taskUpdatedAt } from "./model.js";
4
4
  import { isTaskId } from "./validation.js";
5
- const FILTER_FIELDS = new Set([
6
- "state", "priority", "parent", "needs", "blocks", "contract", "created", "updated",
7
- ]);
5
+ export const TASK_FILTER_FIELDS = [
6
+ "title", "state", "priority", "parent", "needs", "blocks", "contract", "created", "updated",
7
+ ];
8
+ export const TASK_FILTER_OPERATORS = ["=", "!=", "~", "<", "<=", ">", ">="];
9
+ export const TASK_FILTER_GRAMMAR = "FIELD OP VALUE[,VALUE...] [| FIELD OP VALUE[,VALUE...]]...";
10
+ export const TASK_FILTER_COMPLETION = "FIELD OP VALUE[,VALUE...]|FIELD OP VALUE[,VALUE...]";
11
+ const FILTER_FIELDS = new Set(TASK_FILTER_FIELDS);
8
12
  const ORDERED_FIELDS = new Set(["priority", "created", "updated"]);
13
+ const TITLE_OPERATORS = new Set(["=", "!=", "~"]);
14
+ const ORDERED_OPERATORS = new Set(["=", "!=", "<", "<=", ">", ">="]);
9
15
  const DISPLAY_VALUES = new Set(["ready", "blocked", "in_progress", "done", "dropped", "bound"]);
10
16
  const SORT_KEYS = new Set(["priority", "modified", "created"]);
11
17
  const RELATIVE_TIME = /^now-([1-9][0-9]*)([smhdw])$/;
@@ -21,57 +27,87 @@ export function deriveTaskDisplay(task) {
21
27
  return "in_progress";
22
28
  return task.blocked ? "blocked" : "ready";
23
29
  }
24
- function invalidFilter(detail) {
25
- throw new FlowError("INVALID_TARGET", `invalid task filter: ${detail}`);
30
+ function invalidFilter(detail, recoveredTerms = []) {
31
+ throw new FlowError("INVALID_TARGET", `invalid task filter: ${detail}`, {
32
+ facts: {
33
+ kind: "invalid_task_filter",
34
+ detail,
35
+ grammar: TASK_FILTER_GRAMMAR,
36
+ fields: [...TASK_FILTER_FIELDS],
37
+ operators: [...TASK_FILTER_OPERATORS],
38
+ recoveredTerms: recoveredTerms.map((term) => ({
39
+ field: term.field,
40
+ operator: term.operator,
41
+ values: [...term.values],
42
+ })),
43
+ },
44
+ });
26
45
  }
27
- export function parseTaskFilter(expression) {
28
- const match = /^([a-z][a-z0-9_-]*)(!=|<=|>=|=|<|>)(.*)$/.exec(expression);
46
+ function parseTaskFilterTerm(term, expression, recoveredTerms) {
47
+ const match = /^([a-z][a-z0-9_-]*)(!=|<=|>=|~|=|<|>)(.*)$/.exec(term);
29
48
  if (!match)
30
- invalidFilter(`'${expression}' must use FIELD OP VALUE`);
49
+ invalidFilter(`'${expression}' must use ${TASK_FILTER_GRAMMAR}`, recoveredTerms);
31
50
  const field = match[1];
32
51
  const operator = match[2];
33
52
  if (!FILTER_FIELDS.has(field))
34
- invalidFilter(`unknown field '${field}'`);
35
- if (!ORDERED_FIELDS.has(field) && operator !== "=" && operator !== "!=") {
36
- invalidFilter(`field '${field}' does not support operator '${operator}'`);
53
+ invalidFilter(`unknown field '${field}'`, recoveredTerms);
54
+ if (field === "title" && !TITLE_OPERATORS.has(operator)) {
55
+ invalidFilter(`field '${field}' does not support operator '${operator}'`, recoveredTerms);
56
+ }
57
+ if (field !== "title" && (ORDERED_FIELDS.has(field)
58
+ ? !ORDERED_OPERATORS.has(operator)
59
+ : operator !== "=" && operator !== "!=")) {
60
+ invalidFilter(`field '${field}' does not support operator '${operator}'`, recoveredTerms);
37
61
  }
38
62
  const values = match[3].split(",");
39
63
  if (values.some((value) => value.length === 0))
40
- invalidFilter(`field '${field}' has an empty value`);
41
- validateFilterValues(field, values);
64
+ invalidFilter(`field '${field}' has an empty value`, recoveredTerms);
65
+ validateFilterValues(field, values, recoveredTerms);
42
66
  return { field, operator, values };
43
67
  }
44
- function parseTimestamp(value, nowMs) {
68
+ export function parseTaskFilter(expression) {
69
+ const terms = expression.split("|");
70
+ const parsed = [];
71
+ for (const term of terms) {
72
+ if (term.length === 0)
73
+ invalidFilter(`'${expression}' must use ${TASK_FILTER_GRAMMAR}`, parsed);
74
+ parsed.push(parseTaskFilterTerm(term, expression, parsed));
75
+ }
76
+ return parsed;
77
+ }
78
+ function parseTimestamp(value, nowMs, recoveredTerms = []) {
45
79
  const relative = RELATIVE_TIME.exec(value);
46
80
  if (relative) {
47
81
  const amount = Number(relative[1]);
48
82
  const offset = amount * UNIT_MS[relative[2]];
49
83
  if (!Number.isSafeInteger(offset))
50
- invalidFilter(`timestamp '${value}' is outside the supported range`);
84
+ invalidFilter(`timestamp '${value}' is outside the supported range`, recoveredTerms);
51
85
  return nowMs - offset;
52
86
  }
53
87
  const timestamp = Date.parse(value);
54
88
  if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString() !== value) {
55
- invalidFilter(`timestamp '${value}' must be canonical ISO-8601 or now-<duration>`);
89
+ invalidFilter(`timestamp '${value}' must be canonical ISO-8601 or now-<duration>`, recoveredTerms);
56
90
  }
57
91
  return timestamp;
58
92
  }
59
- function validateFilterValues(field, values) {
93
+ function validateFilterValues(field, values, recoveredTerms) {
94
+ if (field === "title")
95
+ return;
60
96
  if (field === "state") {
61
97
  for (const value of values)
62
98
  if (!DISPLAY_VALUES.has(value))
63
- invalidFilter(`unknown state '${value}'`);
99
+ invalidFilter(`unknown state '${value}'`, recoveredTerms);
64
100
  return;
65
101
  }
66
102
  if (field === "priority") {
67
103
  for (const value of values)
68
104
  if (!/^[0-3]$/.test(value))
69
- invalidFilter(`priority '${value}' must be 0 through 3`);
105
+ invalidFilter(`priority '${value}' must be 0 through 3`, recoveredTerms);
70
106
  return;
71
107
  }
72
108
  if (field === "created" || field === "updated") {
73
109
  for (const value of values)
74
- parseTimestamp(value, 0);
110
+ parseTimestamp(value, 0, recoveredTerms);
75
111
  return;
76
112
  }
77
113
  if (field === "parent" || field === "needs" || field === "blocks") {
@@ -79,14 +115,14 @@ function validateFilterValues(field, values) {
79
115
  if (field === "parent" && value === "none")
80
116
  continue;
81
117
  if (!isTaskId(value))
82
- invalidFilter(`value '${value}' must be a canonical full task id`);
118
+ invalidFilter(`value '${value}' must be a canonical full task id`, recoveredTerms);
83
119
  }
84
120
  return;
85
121
  }
86
122
  if (field === "contract") {
87
123
  for (const value of values) {
88
124
  if (value !== "none" && !isCommissionId(value))
89
- invalidFilter(`value '${value}' must be a full commission id or none`);
125
+ invalidFilter(`value '${value}' must be a full commission id or none`, recoveredTerms);
90
126
  }
91
127
  }
92
128
  }
@@ -114,10 +150,16 @@ function compareValue(left, right, operator) {
114
150
  case "<=": return left <= right;
115
151
  case ">": return left > right;
116
152
  case ">=": return left >= right;
153
+ case "~": return false;
117
154
  }
118
155
  }
119
156
  function matchesFilter(task, filter, nowMs) {
120
157
  return filter.values.some((raw) => {
158
+ if (filter.field === "title") {
159
+ if (filter.operator === "~")
160
+ return task.title.toLowerCase().includes(raw.toLowerCase());
161
+ return compareValue(task.title, raw, filter.operator);
162
+ }
121
163
  if (filter.field === "state")
122
164
  return compareValue(deriveTaskDisplay(task), raw, filter.operator);
123
165
  if (filter.field === "priority")
@@ -167,6 +209,6 @@ export function selectTaskList(tasks, options = {}) {
167
209
  const nowMs = options.nowMs ?? Date.now();
168
210
  return tasks
169
211
  .filter((task) => selected(task, options.selection ?? "default"))
170
- .filter((task) => filters.every((filter) => matchesFilter(task, filter, nowMs)))
212
+ .filter((task) => filters.every((filter) => filter.some((term) => matchesFilter(task, term, nowMs))))
171
213
  .sort(sort === undefined ? defaultCompare : (left, right) => explicitCompare(sort, left, right));
172
214
  }
@@ -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
  }