@superjackfruitlabs/salaam 0.0.3 → 0.0.5

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 (172) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-desk.js +140 -0
  4. package/dist/src/acp-outcome.js +182 -18
  5. package/dist/src/acp.js +296 -15
  6. package/dist/src/agent-profiles.js +45 -0
  7. package/dist/src/agentpod-authority.js +169 -11
  8. package/dist/src/agentpod-default-config.js +12 -0
  9. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  10. package/dist/src/agentpod-work-decisions.js +108 -4
  11. package/dist/src/agentpod-work.js +1214 -127
  12. package/dist/src/approval-rules.js +287 -0
  13. package/dist/src/attach.js +3 -1
  14. package/dist/src/autonomy.js +80 -9
  15. package/dist/src/board-reads.js +106 -0
  16. package/dist/src/children.js +83 -0
  17. package/dist/src/cli-help.js +61 -6
  18. package/dist/src/cli.js +140 -4
  19. package/dist/src/command-danger.js +797 -0
  20. package/dist/src/command-network.js +200 -0
  21. package/dist/src/command-sandbox.js +327 -0
  22. package/dist/src/custom-commands.js +159 -0
  23. package/dist/src/danger-classifier.js +286 -0
  24. package/dist/src/execution-evidence.js +33 -1
  25. package/dist/src/git-helpers.js +160 -0
  26. package/dist/src/hooks.js +351 -0
  27. package/dist/src/host-bind.js +111 -0
  28. package/dist/src/host-breaker.js +88 -0
  29. package/dist/src/host-start.js +41 -1
  30. package/dist/src/host.js +284 -6
  31. package/dist/src/house-extension.js +292 -61
  32. package/dist/src/local-coding.js +726 -35
  33. package/dist/src/local-edit-helper.js +25 -0
  34. package/dist/src/local-edits.js +168 -6
  35. package/dist/src/local-undo.js +25 -16
  36. package/dist/src/managed.js +1 -1
  37. package/dist/src/mentions.js +277 -0
  38. package/dist/src/operator-mcp-cli.js +107 -0
  39. package/dist/src/operator-mcp.js +342 -0
  40. package/dist/src/persona.js +31 -0
  41. package/dist/src/pi-sessions.js +5 -1
  42. package/dist/src/pi.js +22 -1
  43. package/dist/src/project-instructions.js +195 -0
  44. package/dist/src/protocol.js +9 -3
  45. package/dist/src/rate-limit-retries.js +62 -0
  46. package/dist/src/recurring-work.js +1 -0
  47. package/dist/src/repository-snapshots.js +5 -2
  48. package/dist/src/run-grants.js +62 -0
  49. package/dist/src/service.js +482 -10
  50. package/dist/src/session-pick.js +77 -0
  51. package/dist/src/shell-passthrough.js +39 -0
  52. package/dist/src/station-checkpoints.js +139 -0
  53. package/dist/src/station-config.js +8 -1
  54. package/dist/src/station-files.js +20 -1
  55. package/dist/src/station-task-verification.js +6 -3
  56. package/dist/src/station-work.js +315 -22
  57. package/dist/src/store.js +70 -4
  58. package/dist/src/supervisor.js +408 -25
  59. package/dist/src/task-budget.js +39 -3
  60. package/dist/src/task-context.js +8 -2
  61. package/dist/src/task-critic.js +115 -0
  62. package/dist/src/task-driver.js +46 -2
  63. package/dist/src/task-policy.js +44 -12
  64. package/dist/src/task-verification.js +42 -9
  65. package/dist/src/task-work.js +47 -0
  66. package/dist/src/terminal-status.js +88 -0
  67. package/dist/src/tool-identity.js +69 -0
  68. package/dist/src/tui-activity.js +99 -0
  69. package/dist/src/tui-classifier.js +25 -0
  70. package/dist/src/tui-client.js +186 -5
  71. package/dist/src/tui-commands.js +23 -5
  72. package/dist/src/tui-composer.js +113 -0
  73. package/dist/src/tui-keymap.js +116 -0
  74. package/dist/src/tui-mentions.js +38 -0
  75. package/dist/src/tui-modals.js +157 -34
  76. package/dist/src/tui-notify.js +62 -0
  77. package/dist/src/tui-review.js +55 -0
  78. package/dist/src/tui-turn-keys.js +52 -0
  79. package/dist/src/tui-view.js +243 -69
  80. package/dist/src/tui.js +875 -118
  81. package/dist/src/turn-checkpoints.js +221 -0
  82. package/dist/src/turn-rewind.js +168 -0
  83. package/dist/src/usage.js +1 -1
  84. package/dist/src/web-fetch.js +114 -0
  85. package/dist/src/web-guard.js +163 -0
  86. package/dist/src/web-search.js +302 -0
  87. package/npm-shrinkwrap.json +2 -2
  88. package/package.json +1 -1
  89. package/src/acp-desk.ts +155 -0
  90. package/src/acp-outcome.ts +174 -20
  91. package/src/acp.ts +386 -30
  92. package/src/agent-profiles.ts +48 -0
  93. package/src/agentpod-authority.ts +234 -15
  94. package/src/agentpod-default-config.ts +16 -0
  95. package/src/agentpod-work-decision-protocol.ts +69 -8
  96. package/src/agentpod-work-decisions.ts +123 -5
  97. package/src/agentpod-work.ts +1455 -152
  98. package/src/approval-rules.ts +360 -0
  99. package/src/attach.ts +4 -1
  100. package/src/autonomy.ts +111 -10
  101. package/src/board-reads.ts +148 -0
  102. package/src/children.ts +112 -0
  103. package/src/cli-help.ts +60 -6
  104. package/src/cli.ts +174 -4
  105. package/src/command-danger.ts +886 -0
  106. package/src/command-network.ts +215 -0
  107. package/src/command-sandbox.ts +393 -0
  108. package/src/custom-commands.ts +175 -0
  109. package/src/danger-classifier.ts +321 -0
  110. package/src/execution-evidence.ts +31 -1
  111. package/src/git-helpers.ts +177 -0
  112. package/src/hooks.ts +425 -0
  113. package/src/host-bind.ts +156 -0
  114. package/src/host-breaker.ts +89 -0
  115. package/src/host-start.ts +51 -1
  116. package/src/host.ts +365 -6
  117. package/src/house-extension.ts +313 -60
  118. package/src/local-coding.ts +894 -43
  119. package/src/local-edit-helper.ts +26 -0
  120. package/src/local-edits.ts +191 -8
  121. package/src/local-undo.ts +29 -14
  122. package/src/managed.ts +2 -2
  123. package/src/mentions.ts +304 -0
  124. package/src/operator-mcp-cli.ts +108 -0
  125. package/src/operator-mcp.ts +420 -0
  126. package/src/persona.ts +37 -0
  127. package/src/pi-sessions.ts +9 -1
  128. package/src/pi.ts +23 -1
  129. package/src/project-instructions.ts +229 -0
  130. package/src/protocol.ts +25 -3
  131. package/src/rate-limit-retries.ts +69 -0
  132. package/src/recurring-work.ts +1 -0
  133. package/src/repository-snapshots.ts +5 -2
  134. package/src/run-grants.ts +107 -0
  135. package/src/service.ts +709 -10
  136. package/src/session-pick.ts +102 -0
  137. package/src/shell-passthrough.ts +38 -0
  138. package/src/station-checkpoints.ts +183 -0
  139. package/src/station-config.ts +10 -1
  140. package/src/station-files.ts +20 -1
  141. package/src/station-task-verification.ts +6 -3
  142. package/src/station-work.ts +369 -20
  143. package/src/store.ts +70 -3
  144. package/src/supervisor.ts +463 -26
  145. package/src/task-budget.ts +48 -3
  146. package/src/task-context.ts +9 -2
  147. package/src/task-critic.ts +171 -0
  148. package/src/task-driver.ts +49 -2
  149. package/src/task-policy.ts +65 -13
  150. package/src/task-verification.ts +53 -9
  151. package/src/task-work.ts +75 -0
  152. package/src/terminal-status.ts +103 -0
  153. package/src/tool-identity.ts +110 -0
  154. package/src/tui-activity.ts +133 -0
  155. package/src/tui-classifier.ts +38 -0
  156. package/src/tui-client.ts +239 -8
  157. package/src/tui-commands.ts +49 -10
  158. package/src/tui-composer.ts +124 -0
  159. package/src/tui-keymap.ts +152 -0
  160. package/src/tui-mentions.ts +43 -0
  161. package/src/tui-modals.ts +173 -43
  162. package/src/tui-notify.ts +77 -0
  163. package/src/tui-review.ts +76 -0
  164. package/src/tui-turn-keys.ts +77 -0
  165. package/src/tui-view.ts +341 -88
  166. package/src/tui.ts +1047 -137
  167. package/src/turn-checkpoints.ts +253 -0
  168. package/src/turn-rewind.ts +258 -0
  169. package/src/usage.ts +1 -1
  170. package/src/web-fetch.ts +140 -0
  171. package/src/web-guard.ts +195 -0
  172. package/src/web-search.ts +402 -0
@@ -11,6 +11,13 @@ export class TaskBudgetError extends ServiceError {
11
11
  }
12
12
  }
13
13
 
14
+ /** Tools a critic review child may hold and still be left out of the verification busy check. */
15
+ export const READ_ONLY_REVIEW_TOOLS = [
16
+ 'salaam_findings',
17
+ 'salaam_probe',
18
+ 'salaam_repository',
19
+ ] as const;
20
+
14
21
  export class TaskBudget {
15
22
  constructor(
16
23
  readonly store: Store,
@@ -29,6 +36,8 @@ export class TaskBudget {
29
36
  store.taskChildFinished = (attempt) => this.releaseChild(attempt);
30
37
  store.taskCommandAdmission = (session) => this.command(session);
31
38
  store.taskRemainingRuntime = (session) => this.remaining(session);
39
+ // Only the task's own session: a child's attempt keeps the budget its reservation set.
40
+ store.taskAttemptRequestLimit = (session) => this.tasks.forSession(session)?.limits.requests;
32
41
  }
33
42
  task(session: string): WorkTask | null {
34
43
  const direct = this.tasks.forSession(session);
@@ -46,16 +55,52 @@ export class TaskBudget {
46
55
  );
47
56
  return { ...runtime, invocations_spent: n, external_model_usage: n ? 'unknown' : 'none' };
48
57
  }
49
- active(task: WorkTask): number {
58
+ /** The child attempt of each running critic review of this task revision whose recorded
59
+ * tools are all read-only (operator ruling 2026-10-30, option A). Only verification reads may
60
+ * leave these out of the busy count: the reviewer reads a digest-bound snapshot and cannot
61
+ * write the workspace, run a command or reach the station (staging card_019a647a169744ff). */
62
+ private readOnlyReviews(task: WorkTask): string[] {
63
+ if (
64
+ !this.store.db
65
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_critics'")
66
+ .get()
67
+ )
68
+ return [];
69
+ const rows = this.store.db
70
+ .prepare(
71
+ "SELECT c.child_attempt, c.authority FROM task_critics r JOIN child_tasks c ON c.id=r.child_id WHERE r.task_id=? AND r.plan_revision=? AND r.state='running' AND c.parent_session=?",
72
+ )
73
+ .all(task.id, task.plan_revision, task.session_id) as {
74
+ child_attempt: string;
75
+ authority: string;
76
+ }[];
77
+ return rows
78
+ .filter((r) => {
79
+ try {
80
+ const tools = JSON.parse(r.authority) as unknown;
81
+ return (
82
+ Array.isArray(tools) &&
83
+ tools.every((t) => (READ_ONLY_REVIEW_TOOLS as readonly unknown[]).includes(t))
84
+ );
85
+ } catch {
86
+ return false;
87
+ }
88
+ })
89
+ .map((r) => r.child_attempt);
90
+ }
91
+ /** Live work in the task's session tree. `verification` leaves out a running read-only review's
92
+ * child attempt, for verification reads only; admission and concurrency count everything. */
93
+ active(task: WorkTask, opts: { verification?: boolean } = {}): number {
94
+ const excluded = JSON.stringify(opts.verification ? this.readOnlyReviews(task) : []);
50
95
  const row = this.store.db
51
96
  .prepare(
52
97
  `WITH tree(id) AS (SELECT ? UNION ALL SELECT child_session FROM child_tasks WHERE parent_session=?) SELECT
53
- (SELECT count(*) FROM attempts WHERE session_id IN (SELECT id FROM tree) AND state IN ('accepted','dispatching','running')) +
98
+ (SELECT count(*) FROM attempts WHERE session_id IN (SELECT id FROM tree) AND state IN ('accepted','dispatching','running') AND id NOT IN (SELECT value FROM json_each(?))) +
54
99
  (SELECT count(*) FROM station_calls WHERE session_id IN (SELECT id FROM tree) AND kind='check' AND attempt_id IS NULL AND state IN ('prepared','dispatching')) +
55
100
  (SELECT count(*) FROM command_runs WHERE session_id IN (SELECT id FROM tree) AND state NOT IN ('succeeded','failed','cancelled','timed_out','output_limited','interrupted')) +
56
101
  (SELECT count(*) FROM external_runs e JOIN child_tasks c ON c.id=e.child_id JOIN attempts a ON a.id=c.child_attempt WHERE c.parent_session=? AND e.stopped=0 AND a.state NOT IN ('accepted','dispatching','running')) AS n`,
57
102
  )
58
- .get(task.session_id, task.session_id, task.session_id)!;
103
+ .get(task.session_id, task.session_id, excluded, task.session_id)!;
59
104
  return Number(row.n);
60
105
  }
61
106
  private deny(session: string, message: string): never {
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import type { TaskWork } from './task-work.js';
3
3
  import type { ContextAdmission } from './context-admission.js';
4
+ import type { ProjectInstructions } from './project-instructions.js';
4
5
  import type { Store } from './store.js';
5
6
  import type { Artifacts } from './artifacts.js';
6
7
  import { canonical, ServiceError, string, integer, object } from './protocol.js';
@@ -41,6 +42,10 @@ export interface Compaction {
41
42
  export class TaskContext {
42
43
  work?: TaskWork;
43
44
  admitted?: ContextAdmission;
45
+ /** Interactive sessions only: AGENTS.md guidance loaded automatically (project-instructions.ts). */
46
+ instructions?: ProjectInstructions;
47
+ /** Interactive sessions only: a folder checkpoint before every turn (turn-rewind.ts). */
48
+ checkpoints?: import('./turn-rewind.js').TurnCheckpoints;
44
49
  constructor(
45
50
  readonly store: Store,
46
51
  readonly artifacts: Artifacts,
@@ -99,11 +104,13 @@ export class TaskContext {
99
104
  if (Buffer.byteLength(frame) > TASK_FRAME_BYTES)
100
105
  throw new ServiceError('context_capacity', 'Mandatory task state exceeds the context budget');
101
106
  const admittedContext = this.admitted?.frame(id);
102
- return admittedContext
107
+ const projectInstructions = this.instructions?.frame(id);
108
+ return admittedContext || projectInstructions
103
109
  ? 'SALAAM_TASK_STATE ' +
104
110
  canonical({
105
111
  ...JSON.parse(frame.slice('SALAAM_TASK_STATE '.length)),
106
- admitted_context: admittedContext,
112
+ ...(admittedContext ? { admitted_context: admittedContext } : {}),
113
+ ...(projectInstructions ? { project_instructions: projectInstructions } : {}),
107
114
  })
108
115
  : frame;
109
116
  }
@@ -0,0 +1,171 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { Store } from './store.js';
3
+ import type { Artifacts } from './artifacts.js';
4
+ import type { AgentGrant } from './agent-profiles.js';
5
+ import type { WorkTask } from './task-work.js';
6
+ import type { WorkspaceTree } from './workspaces.js';
7
+ import type { ChildTask } from './protocol.js';
8
+
9
+ /**
10
+ * The completion critic (item 8). When the admitted plan declares a critic, the built-in reviewer
11
+ * reads an immutable snapshot of the verified deliverables against the plan's rubric before the
12
+ * task may complete. Its verdict is bound to the exact verified evidence (checks and tree): any
13
+ * later change makes it stale. A critical or high finding means changes are needed; a reviewer
14
+ * that reports nothing at all is a failure, never a pass.
15
+ */
16
+ export const CRITIC_BUDGET = 4;
17
+ export const CRITIC_REOPENS = 2;
18
+ export type CriticVerdict =
19
+ | { state: 'pass' }
20
+ | { state: 'pending' }
21
+ | { state: 'changes_needed'; findings: CriticFinding[] }
22
+ | { state: 'failed'; reason: string };
23
+ export interface CriticFinding {
24
+ path: string;
25
+ line: number;
26
+ severity: string;
27
+ message: string;
28
+ }
29
+ export interface CriticDeps {
30
+ admit(task: WorkTask, request: string): Promise<AgentGrant>;
31
+ spawn(
32
+ task: WorkTask,
33
+ parentAttempt: string,
34
+ step: string,
35
+ prompt: string,
36
+ budget: number,
37
+ grant: AgentGrant,
38
+ snapshot: WorkspaceTree,
39
+ ): ChildTask;
40
+ workerId(childId: string): string;
41
+ }
42
+
43
+ export class TaskCritic {
44
+ constructor(
45
+ private readonly store: Store,
46
+ private readonly artifacts: Artifacts,
47
+ private readonly deps: CriticDeps,
48
+ ) {
49
+ store.db.exec(`CREATE TABLE IF NOT EXISTS task_critics(
50
+ id TEXT PRIMARY KEY NOT NULL,
51
+ task_id TEXT NOT NULL,
52
+ plan_revision INTEGER NOT NULL,
53
+ evidence_digest TEXT NOT NULL,
54
+ round INTEGER NOT NULL,
55
+ child_id TEXT NOT NULL,
56
+ state TEXT NOT NULL,
57
+ findings TEXT,
58
+ reason TEXT,
59
+ UNIQUE(task_id, plan_revision, evidence_digest))`);
60
+ }
61
+ /** Whether this exact verified evidence has a passing critic verdict. */
62
+ passed(task: WorkTask, evidenceDigest: string): boolean {
63
+ return (
64
+ this.store.db
65
+ .prepare(
66
+ "SELECT 1 FROM task_critics WHERE task_id=? AND plan_revision=? AND evidence_digest=? AND state='pass'",
67
+ )
68
+ .get(task.id, task.plan_revision, evidenceDigest) !== undefined
69
+ );
70
+ }
71
+ /** How many times the critic has sent this plan revision back for changes. */
72
+ reopens(task: WorkTask): number {
73
+ return Number(
74
+ this.store.db
75
+ .prepare(
76
+ "SELECT count(*) AS n FROM task_critics WHERE task_id=? AND plan_revision=? AND state='changes_needed'",
77
+ )
78
+ .get(task.id, task.plan_revision)!.n,
79
+ );
80
+ }
81
+ /** The verdict for this verified evidence, starting the reviewer the first time it is asked. */
82
+ async gate(
83
+ task: WorkTask,
84
+ evidenceDigest: string,
85
+ deliverables: { path: string; content: string }[],
86
+ ): Promise<CriticVerdict> {
87
+ const critic = task.completion_gate?.critic;
88
+ if (!critic) return { state: 'pass' };
89
+ const row = this.store.db
90
+ .prepare(
91
+ 'SELECT * FROM task_critics WHERE task_id=? AND plan_revision=? AND evidence_digest=?',
92
+ )
93
+ .get(task.id, task.plan_revision, evidenceDigest);
94
+ if (row) return this.settle(row);
95
+ const last = this.store.db
96
+ .prepare(
97
+ 'SELECT b.attempt_id FROM task_step_attempts b JOIN task_steps s ON s.id=b.step_id WHERE s.task_id=? ORDER BY b.rowid DESC LIMIT 1',
98
+ )
99
+ .get(task.id);
100
+ if (!last) return { state: 'failed', reason: 'No step attempt for the critic to follow' };
101
+ if (!deliverables.length)
102
+ return { state: 'failed', reason: 'No verified deliverables to review' };
103
+ const round =
104
+ Number(
105
+ this.store.db
106
+ .prepare('SELECT count(*) AS n FROM task_critics WHERE task_id=? AND plan_revision=?')
107
+ .get(task.id, task.plan_revision)!.n,
108
+ ) + 1;
109
+ const snapshot: WorkspaceTree = Object.fromEntries(
110
+ deliverables.map((d) => [
111
+ d.path,
112
+ { content: Buffer.from(d.content, 'utf8').toString('base64'), executable: false },
113
+ ]),
114
+ );
115
+ const grant = await this.deps.admit(task, `critic:${task.id}:${task.plan_revision}`);
116
+ const child = this.deps.spawn(
117
+ task,
118
+ String(last.attempt_id),
119
+ `critic-${task.plan_revision}-${round}`,
120
+ [
121
+ 'Judge the delivered work against this rubric before it is accepted:',
122
+ critic.rubric,
123
+ '',
124
+ 'Objective: ' + task.objective,
125
+ 'Deliverables (the snapshot): ' + deliverables.map((d) => d.path).join(', '),
126
+ '',
127
+ 'Report findings with salaam_findings, citing the file and line. Use critical or high only',
128
+ 'for what must change before the work is accepted; medium or low for suggestions. If the',
129
+ 'work meets the rubric, report an empty list.',
130
+ ].join('\n'),
131
+ CRITIC_BUDGET,
132
+ grant,
133
+ snapshot,
134
+ );
135
+ this.store.db
136
+ .prepare("INSERT INTO task_critics VALUES (?,?,?,?,?,?,'running',NULL,NULL)")
137
+ .run(randomUUID(), task.id, task.plan_revision, evidenceDigest, round, child.id);
138
+ return { state: 'pending' };
139
+ }
140
+ private settle(row: Record<string, unknown>): CriticVerdict {
141
+ if (row.state === 'pass') return { state: 'pass' };
142
+ if (row.state === 'changes_needed')
143
+ return { state: 'changes_needed', findings: JSON.parse(String(row.findings)) };
144
+ if (row.state === 'failed') return { state: 'failed', reason: String(row.reason) };
145
+ const child = this.store.db
146
+ .prepare('SELECT state FROM child_tasks WHERE id=?')
147
+ .get(String(row.child_id));
148
+ if (!child || child.state === 'accepted') return { state: 'pending' };
149
+ const report = this.store.db
150
+ .prepare('SELECT artifact_id FROM specialist_reports WHERE worker_id=?')
151
+ .get(this.deps.workerId(String(row.child_id)));
152
+ const set = (state: string, findings: CriticFinding[] | null, reason: string | null) =>
153
+ this.store.db
154
+ .prepare('UPDATE task_critics SET state=?,findings=?,reason=? WHERE id=?')
155
+ .run(state, findings ? JSON.stringify(findings) : null, reason, String(row.id));
156
+ if (!report) {
157
+ const reason = 'The critic returned no findings report';
158
+ set('failed', null, reason);
159
+ return { state: 'failed', reason };
160
+ }
161
+ const findings = (
162
+ JSON.parse(this.artifacts.read(String(report.artifact_id))).findings as CriticFinding[]
163
+ ).map(({ path, line, severity, message }) => ({ path, line, severity, message }));
164
+ if (findings.some((f) => f.severity === 'critical' || f.severity === 'high')) {
165
+ set('changes_needed', findings, null);
166
+ return { state: 'changes_needed', findings };
167
+ }
168
+ set('pass', findings, null);
169
+ return { state: 'pass' };
170
+ }
171
+ }
@@ -1,3 +1,6 @@
1
+ import { CRITIC_REOPENS } from './task-critic.js';
2
+ import { requiredChecks } from './task-policy.js';
3
+ import { RATE_LIMIT_STOP, RateLimitRetries } from './rate-limit-retries.js';
1
4
  import { randomUUID } from 'node:crypto';
2
5
  import type { Store } from './store.js';
3
6
  import type { TaskWork, WorkTask } from './task-work.js';
@@ -15,6 +18,7 @@ export class TaskDriver {
15
18
  awaitingAuthorityRenewal?: (session: string) => boolean;
16
19
  private active?: Promise<void>;
17
20
  private stopped = true;
21
+ readonly rateLimits: RateLimitRetries;
18
22
  constructor(
19
23
  readonly store: Store,
20
24
  readonly tasks: TaskWork,
@@ -24,6 +28,7 @@ export class TaskDriver {
24
28
  readonly children: ChildWork,
25
29
  readonly coding: CodingWork,
26
30
  ) {
31
+ this.rateLimits = new RateLimitRetries(store);
27
32
  tasks.assertProgressionBinding = (task, binding) => {
28
33
  if (canonical(this.verification.binding(task)) !== binding)
29
34
  throw new ServiceError(
@@ -374,6 +379,20 @@ export class TaskDriver {
374
379
  return;
375
380
  }
376
381
  if (task.steps.some((s) => ['interrupted', 'failed', 'cancelled'].includes(s.state))) {
382
+ // A provider 429 is retried as a fresh attempt of the step after a backoff; past the limit,
383
+ // or with no progression step left, the task stops with a block recovery can settle.
384
+ const retry = this.rateLimits.decide(task);
385
+ if (retry.action === 'wait') return;
386
+ if (retry.action === 'retry' && Number(r.remaining_steps) >= 1) {
387
+ if (!(await this.worker(task)) || this.stopped) return;
388
+ this.tasks.retryStep(task.id, retry.step, retry.n, Number(r.auto_epoch));
389
+ return;
390
+ }
391
+ if (retry.action !== 'none') {
392
+ this.budget.block(task.session_id, RATE_LIMIT_STOP);
393
+ this.stop(task, RATE_LIMIT_STOP);
394
+ return;
395
+ }
377
396
  this.stop(task, 'An interrupted or failed step requires explicit operator review');
378
397
  return;
379
398
  }
@@ -420,13 +439,41 @@ export class TaskDriver {
420
439
  // the native workspace. Wait for the whole set before testing freshness;
421
440
  // finishAsync still performs the final transactional evidence validation.
422
441
  if (
423
- task.acceptance.some((check) => {
442
+ requiredChecks(task).some((check) => {
424
443
  const receipt = this.verification.latest(task, check.key, null);
425
444
  return receipt?.state === 'accepted' || receipt?.state === 'running';
426
445
  })
427
446
  )
428
447
  return;
429
- for (const check of task.acceptance) if (!(await this.check(task, check.key, null))) return;
448
+ for (const check of requiredChecks(task))
449
+ if (!(await this.check(task, check.key, null))) return;
450
+ // Item 8: a declared critic reviews the verified deliverables before completion.
451
+ if (task.completion_gate?.critic) {
452
+ const verdict = await this.verification.criticGateAsync(task);
453
+ if (verdict.state === 'pending') return;
454
+ if (verdict.state === 'failed') {
455
+ this.stop(task, 'Critic review failed: ' + verdict.reason);
456
+ return;
457
+ }
458
+ if (verdict.state === 'changes_needed') {
459
+ const round = this.verification.critic!.reopens(task);
460
+ if (round > CRITIC_REOPENS || Number(r.remaining_steps) < 1) {
461
+ this.stop(task, 'Critic requested changes');
462
+ return;
463
+ }
464
+ if (!(await this.worker(task)) || this.stopped) return;
465
+ this.tasks.reopenForCritic(
466
+ task.id,
467
+ task.steps.at(-1)!.id,
468
+ round,
469
+ Number(r.auto_epoch),
470
+ verdict.findings
471
+ .map((f) => `- ${f.path}:${f.line} (${f.severity}) ${f.message}`)
472
+ .join('\n'),
473
+ );
474
+ return;
475
+ }
476
+ }
430
477
  await this.verification.finishAsync(
431
478
  task.id,
432
479
  'auto-finish:' + task.id + ':' + task.plan_revision,
@@ -17,11 +17,41 @@ export interface TaskLimits {
17
17
  output_token_bound: number | null;
18
18
  verification_runs: number;
19
19
  }
20
+ /** Item 8: checks required at completion, next to the acceptance checks. The critic is not yet
21
+ * admitted, so it is always null. */
22
+ export interface CompletionGate {
23
+ /** The built-in reviewer judges the verified deliverables against this rubric. */
24
+ critic: { rubric: string } | null;
25
+ hooks: TaskCheck[];
26
+ }
20
27
  export interface TaskPolicy {
21
28
  acceptance: TaskCheck[];
22
29
  deliverables: string[];
23
30
  limits: TaskLimits;
31
+ /** Present only when the plan declares one, so a plan without it keeps its exact shape. */
32
+ completion_gate?: CompletionGate;
33
+ }
34
+ /** Every check completion requires: the acceptance checks, then each hook as hook-<key>. */
35
+ export function requiredChecks(
36
+ policy: Pick<TaskPolicy, 'acceptance' | 'completion_gate'>,
37
+ ): TaskCheck[] {
38
+ return [
39
+ ...policy.acceptance,
40
+ ...(policy.completion_gate?.hooks ?? []).map((h) => ({ ...h, key: 'hook-' + h.key })),
41
+ ];
24
42
  }
43
+ const check = (v: unknown, keys: Set<string>, what: string): TaskCheck => {
44
+ const c = object(v),
45
+ key = string(c.key, what + ' key', 64);
46
+ if (!/^[a-zA-Z0-9_-]+$/.test(key) || keys.has(key))
47
+ throw new ServiceError('invalid_plan', what + ' keys must be unique identifiers', 400);
48
+ keys.add(key);
49
+ return {
50
+ key,
51
+ command: string(c.command, what + ' command', 4096),
52
+ cwd: c.cwd === undefined || c.cwd === '.' ? '.' : filePath(c.cwd),
53
+ };
54
+ };
25
55
  export const DEFAULT_TASK_LIMITS: TaskLimits = {
26
56
  requests: 64,
27
57
  runtime_ms: 900000,
@@ -36,18 +66,7 @@ export function taskPolicy(value: Record<string, unknown>): TaskPolicy {
36
66
  if (!Array.isArray(raw) || raw.length > 8)
37
67
  throw new ServiceError('invalid_plan', 'Supply at most eight acceptance checks', 400);
38
68
  const keys = new Set<string>();
39
- const acceptance = raw.map((v) => {
40
- const c = object(v),
41
- key = string(c.key, 'check key', 64);
42
- if (!/^[a-zA-Z0-9_-]+$/.test(key) || keys.has(key))
43
- throw new ServiceError('invalid_plan', 'Acceptance keys must be unique identifiers', 400);
44
- keys.add(key);
45
- return {
46
- key,
47
- command: string(c.command, 'check command', 4096),
48
- cwd: c.cwd === undefined || c.cwd === '.' ? '.' : filePath(c.cwd),
49
- };
50
- });
69
+ const acceptance = raw.map((v) => check(v, keys, 'Acceptance'));
51
70
  const paths = value.deliverables ?? [];
52
71
  if (!Array.isArray(paths) || paths.length > 32)
53
72
  throw new ServiceError('invalid_plan', 'Supply at most 32 deliverable paths', 400);
@@ -88,5 +107,38 @@ export function taskPolicy(value: Record<string, unknown>): TaskPolicy {
88
107
  throw new ServiceError('invalid_budget', 'Invocation limit must be 1–64', 400);
89
108
  limits.invocations = n;
90
109
  }
91
- return { acceptance, deliverables, limits };
110
+ let completion_gate: CompletionGate | undefined;
111
+ if (value.completion_gate !== undefined) {
112
+ const g = object(value.completion_gate);
113
+ const hooks = g.hooks;
114
+ const c = g.critic;
115
+ const critic =
116
+ c === null
117
+ ? null
118
+ : (() => {
119
+ const v = object(c);
120
+ if (Object.keys(v).join(',') !== 'rubric')
121
+ throw new ServiceError('invalid_plan', 'A critic names only its rubric', 400);
122
+ return { rubric: string(v.rubric, 'critic rubric', 4096) };
123
+ })();
124
+ if (
125
+ Object.keys(g).sort().join(',') !== 'critic,hooks' ||
126
+ !Array.isArray(hooks) ||
127
+ hooks.length > 8 ||
128
+ (!critic && hooks.length < 1)
129
+ )
130
+ throw new ServiceError(
131
+ 'invalid_plan',
132
+ 'A completion gate has a critic or 1–8 hooks, and at most 8 hooks',
133
+ 400,
134
+ );
135
+ const hookKeys = new Set<string>();
136
+ completion_gate = { critic, hooks: hooks.map((v) => check(v, hookKeys, 'Hook')) };
137
+ if (
138
+ requiredChecks({ acceptance, completion_gate }).length !==
139
+ new Set(requiredChecks({ acceptance, completion_gate }).map((c) => c.key)).size
140
+ )
141
+ throw new ServiceError('invalid_plan', 'A hook collides with an acceptance check', 400);
142
+ }
143
+ return { acceptance, deliverables, limits, ...(completion_gate ? { completion_gate } : {}) };
92
144
  }
@@ -1,3 +1,5 @@
1
+ import type { TaskCritic, CriticVerdict } from './task-critic.js';
2
+ import { requiredChecks } from './task-policy.js';
1
3
  import { StationTaskVerification, type StationCheckEvidence } from './station-task-verification.js';
2
4
  import type { StationWork } from './station-work.js';
3
5
  import { randomUUID } from 'node:crypto';
@@ -63,7 +65,7 @@ export class TaskVerification {
63
65
  }
64
66
  assertIdle(task: WorkTask, stationInspection?: string): void {
65
67
  if (
66
- this.budget.active(task) > 0 ||
68
+ this.budget.active(task, { verification: true }) > 0 ||
67
69
  this.store.db
68
70
  .prepare("SELECT 1 FROM compactions WHERE session_id=? AND state='running'")
69
71
  .get(task.session_id)
@@ -156,7 +158,7 @@ export class TaskVerification {
156
158
  throw new ServiceError('task_settled', 'Revise a cancelled task before completing it');
157
159
  this.assertIdle(task);
158
160
  if (!this.station) this.coding.assertSafe(task.session_id);
159
- if (!task.acceptance.some((c) => c.key === key))
161
+ if (!requiredChecks(task).some((c) => c.key === key))
160
162
  throw new ServiceError('invalid_check', 'Check is not in this task plan');
161
163
  if (
162
164
  step &&
@@ -215,7 +217,7 @@ export class TaskVerification {
215
217
  return;
216
218
  }
217
219
  const task = this.tasks.get(receipt.task_id),
218
- check = task.acceptance.find((c) => c.key === receipt.check_key);
220
+ check = requiredChecks(task).find((c) => c.key === receipt.check_key);
219
221
  try {
220
222
  const existing = this.store.db
221
223
  .prepare('SELECT id FROM command_runs WHERE session_id=? AND step_key=?')
@@ -485,6 +487,32 @@ export class TaskVerification {
485
487
  );
486
488
  return this.finishRecorded(id, request, expected, exception, authorize);
487
489
  }
490
+ /** Item 8: the completion critic, when the service has one. */
491
+ critic?: TaskCritic;
492
+ /** The critic's verdict on the current fresh verified evidence, starting it the first time.
493
+ * Reads the evidence outside the finishing transaction, so starting the reviewer can never be
494
+ * rolled back with a refused finish; the finish itself re-checks the verdict against its own
495
+ * evidence. */
496
+ async criticGateAsync(task: WorkTask): Promise<CriticVerdict> {
497
+ if (!task.completion_gate?.critic) return { state: 'pass' };
498
+ if (!this.critic || !this.station)
499
+ return { state: 'failed', reason: 'A critic needs the station verification boundary' };
500
+ const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
501
+ if (checks.some((c) => !c || c.state !== 'passed')) return { state: 'pending' };
502
+ const fresh = await this.station.withFreshMany(
503
+ checks.map((c) => c!.id),
504
+ task.deliverables,
505
+ (evidence, deliverables) => ({ evidence, deliverables }),
506
+ );
507
+ return this.critic.gate(
508
+ task,
509
+ digest(fresh.evidence),
510
+ (fresh.deliverables as { path: string; content: string }[]).map(({ path, content }) => ({
511
+ path,
512
+ content,
513
+ })),
514
+ );
515
+ }
488
516
  async finishAsync(
489
517
  id: string,
490
518
  request: string,
@@ -521,7 +549,7 @@ export class TaskVerification {
521
549
  'verification_required',
522
550
  'Configured checks and deliverables required',
523
551
  );
524
- const checks = task.acceptance.map((c) => this.latest(task, c.key, null));
552
+ const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
525
553
  if (checks.some((c) => !c || c.state !== 'passed'))
526
554
  throw new ServiceError('verification_required', 'Passed configured checks required');
527
555
  return this.station.withFreshMany(
@@ -541,7 +569,7 @@ export class TaskVerification {
541
569
  if (view.completion?.state === 'succeeded') {
542
570
  try {
543
571
  const evidence = JSON.parse(this.artifacts.read(String(view.completion.artifact_id)));
544
- const checks = task.acceptance.map((c) => this.latest(task, c.key, null));
572
+ const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
545
573
  if (checks.some((c) => !c || c.state !== 'passed' || !evidence.checks.includes(c.id)))
546
574
  throw Error('Changed checks');
547
575
  view.completion.fresh = await this.station.withFreshMany(
@@ -578,7 +606,7 @@ export class TaskVerification {
578
606
  this.assertIdle(task);
579
607
  if (task.steps.some((s) => s.state !== 'completed'))
580
608
  throw new ServiceError('verification_required', 'Every step must be reviewed or verified');
581
- const checks = task.acceptance.map((c) => this.latest(task, c.key, null));
609
+ const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
582
610
  const missing: string[] = [];
583
611
  if (!task.acceptance.length) missing.push('No configured acceptance checks');
584
612
  if (!task.deliverables.length) missing.push('No configured deliverable paths');
@@ -589,7 +617,7 @@ export class TaskVerification {
589
617
  ? !protectedResult.evidence.some((e) => e.check_id === checks[n]!.id)
590
618
  : !this.fresh(checks[n]!))
591
619
  )
592
- missing.push('Fresh check required: ' + task.acceptance[n]!.key);
620
+ missing.push('Fresh check required: ' + requiredChecks(task)[n]!.key);
593
621
  let workspace: { id: string; revision: number; digest: string } | null = null,
594
622
  deliverables: unknown[] = [],
595
623
  diff: unknown = null;
@@ -597,7 +625,17 @@ export class TaskVerification {
597
625
  if (protectedResult) {
598
626
  const e = protectedResult.evidence[0]!;
599
627
  workspace = { id: 'station:' + e.root_digest, revision: 0, digest: e.tree_digest };
600
- deliverables = protectedResult.deliverables;
628
+ // Keep each deliverable's verified bytes as a session artifact, committed with the
629
+ // completion, so publication reads the verified revision and never the live workspace.
630
+ deliverables = (
631
+ protectedResult.deliverables as { path: string; sha256: string; content: string }[]
632
+ ).map(({ path, sha256, content }) => {
633
+ if (typeof content !== 'string')
634
+ throw new ServiceError('verification_required', 'Deliverable bytes unavailable');
635
+ const artifact = this.artifacts.prepare(task.session_id, content);
636
+ artifact.commit();
637
+ return { path, sha256, artifact_id: artifact.id };
638
+ });
601
639
  diff = {
602
640
  backend: 'linux-systemd-station-v1',
603
641
  root_digest: e.root_digest,
@@ -617,6 +655,12 @@ export class TaskVerification {
617
655
  } catch (error) {
618
656
  missing.push('Workspace verification unavailable: ' + (error as Error).message);
619
657
  }
658
+ // Item 8: a declared critic must have passed this exact verified evidence.
659
+ if (
660
+ task.completion_gate?.critic &&
661
+ !(protectedResult && this.critic?.passed(task, digest(protectedResult.evidence)))
662
+ )
663
+ missing.push('Critic review required');
620
664
  if (missing.length && !exception)
621
665
  throw new ServiceError('verification_required', missing.join('; '));
622
666
  if (exception) {
@@ -702,7 +746,7 @@ export class TaskVerification {
702
746
  if (completion.state === 'succeeded')
703
747
  fresh =
704
748
  fresh &&
705
- task.acceptance.every((check) => {
749
+ requiredChecks(task).every((check) => {
706
750
  const latest = this.latest(task, check.key, null);
707
751
  return !!latest && evidence.checks.includes(latest.id) && this.fresh(latest);
708
752
  });