@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
@@ -6,6 +6,12 @@ export class TaskBudgetError extends ServiceError {
6
6
  this.session_id = session_id;
7
7
  }
8
8
  }
9
+ /** Tools a critic review child may hold and still be left out of the verification busy check. */
10
+ export const READ_ONLY_REVIEW_TOOLS = [
11
+ 'salaam_findings',
12
+ 'salaam_probe',
13
+ 'salaam_repository',
14
+ ];
9
15
  export class TaskBudget {
10
16
  store;
11
17
  tasks;
@@ -23,6 +29,8 @@ export class TaskBudget {
23
29
  store.taskChildFinished = (attempt) => this.releaseChild(attempt);
24
30
  store.taskCommandAdmission = (session) => this.command(session);
25
31
  store.taskRemainingRuntime = (session) => this.remaining(session);
32
+ // Only the task's own session: a child's attempt keeps the budget its reservation set.
33
+ store.taskAttemptRequestLimit = (session) => this.tasks.forSession(session)?.limits.requests;
26
34
  }
27
35
  task(session) {
28
36
  const direct = this.tasks.forSession(session);
@@ -39,14 +47,42 @@ export class TaskBudget {
39
47
  .n);
40
48
  return { ...runtime, invocations_spent: n, external_model_usage: n ? 'unknown' : 'none' };
41
49
  }
42
- active(task) {
50
+ /** The child attempt of each running critic review of this task revision whose recorded
51
+ * tools are all read-only (operator ruling 2026-10-30, option A). Only verification reads may
52
+ * leave these out of the busy count: the reviewer reads a digest-bound snapshot and cannot
53
+ * write the workspace, run a command or reach the station (staging card_019a647a169744ff). */
54
+ readOnlyReviews(task) {
55
+ if (!this.store.db
56
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_critics'")
57
+ .get())
58
+ return [];
59
+ const rows = this.store.db
60
+ .prepare("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=?")
61
+ .all(task.id, task.plan_revision, task.session_id);
62
+ return rows
63
+ .filter((r) => {
64
+ try {
65
+ const tools = JSON.parse(r.authority);
66
+ return (Array.isArray(tools) &&
67
+ tools.every((t) => READ_ONLY_REVIEW_TOOLS.includes(t)));
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ })
73
+ .map((r) => r.child_attempt);
74
+ }
75
+ /** Live work in the task's session tree. `verification` leaves out a running read-only review's
76
+ * child attempt, for verification reads only; admission and concurrency count everything. */
77
+ active(task, opts = {}) {
78
+ const excluded = JSON.stringify(opts.verification ? this.readOnlyReviews(task) : []);
43
79
  const row = this.store.db
44
80
  .prepare(`WITH tree(id) AS (SELECT ? UNION ALL SELECT child_session FROM child_tasks WHERE parent_session=?) SELECT
45
- (SELECT count(*) FROM attempts WHERE session_id IN (SELECT id FROM tree) AND state IN ('accepted','dispatching','running')) +
81
+ (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(?))) +
46
82
  (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')) +
47
83
  (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')) +
48
84
  (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`)
49
- .get(task.session_id, task.session_id, task.session_id);
85
+ .get(task.session_id, task.session_id, excluded, task.session_id);
50
86
  return Number(row.n);
51
87
  }
52
88
  deny(session, message) {
@@ -14,6 +14,10 @@ export class TaskContext {
14
14
  artifacts;
15
15
  work;
16
16
  admitted;
17
+ /** Interactive sessions only: AGENTS.md guidance loaded automatically (project-instructions.ts). */
18
+ instructions;
19
+ /** Interactive sessions only: a folder checkpoint before every turn (turn-rewind.ts). */
20
+ checkpoints;
17
21
  constructor(store, artifacts) {
18
22
  this.store = store;
19
23
  this.artifacts = artifacts;
@@ -65,11 +69,13 @@ export class TaskContext {
65
69
  if (Buffer.byteLength(frame) > TASK_FRAME_BYTES)
66
70
  throw new ServiceError('context_capacity', 'Mandatory task state exceeds the context budget');
67
71
  const admittedContext = this.admitted?.frame(id);
68
- return admittedContext
72
+ const projectInstructions = this.instructions?.frame(id);
73
+ return admittedContext || projectInstructions
69
74
  ? 'SALAAM_TASK_STATE ' +
70
75
  canonical({
71
76
  ...JSON.parse(frame.slice('SALAAM_TASK_STATE '.length)),
72
- admitted_context: admittedContext,
77
+ ...(admittedContext ? { admitted_context: admittedContext } : {}),
78
+ ...(projectInstructions ? { project_instructions: projectInstructions } : {}),
73
79
  })
74
80
  : frame;
75
81
  }
@@ -0,0 +1,115 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /**
3
+ * The completion critic (item 8). When the admitted plan declares a critic, the built-in reviewer
4
+ * reads an immutable snapshot of the verified deliverables against the plan's rubric before the
5
+ * task may complete. Its verdict is bound to the exact verified evidence (checks and tree): any
6
+ * later change makes it stale. A critical or high finding means changes are needed; a reviewer
7
+ * that reports nothing at all is a failure, never a pass.
8
+ */
9
+ export const CRITIC_BUDGET = 4;
10
+ export const CRITIC_REOPENS = 2;
11
+ export class TaskCritic {
12
+ store;
13
+ artifacts;
14
+ deps;
15
+ constructor(store, artifacts, deps) {
16
+ this.store = store;
17
+ this.artifacts = artifacts;
18
+ this.deps = deps;
19
+ store.db.exec(`CREATE TABLE IF NOT EXISTS task_critics(
20
+ id TEXT PRIMARY KEY NOT NULL,
21
+ task_id TEXT NOT NULL,
22
+ plan_revision INTEGER NOT NULL,
23
+ evidence_digest TEXT NOT NULL,
24
+ round INTEGER NOT NULL,
25
+ child_id TEXT NOT NULL,
26
+ state TEXT NOT NULL,
27
+ findings TEXT,
28
+ reason TEXT,
29
+ UNIQUE(task_id, plan_revision, evidence_digest))`);
30
+ }
31
+ /** Whether this exact verified evidence has a passing critic verdict. */
32
+ passed(task, evidenceDigest) {
33
+ return (this.store.db
34
+ .prepare("SELECT 1 FROM task_critics WHERE task_id=? AND plan_revision=? AND evidence_digest=? AND state='pass'")
35
+ .get(task.id, task.plan_revision, evidenceDigest) !== undefined);
36
+ }
37
+ /** How many times the critic has sent this plan revision back for changes. */
38
+ reopens(task) {
39
+ return Number(this.store.db
40
+ .prepare("SELECT count(*) AS n FROM task_critics WHERE task_id=? AND plan_revision=? AND state='changes_needed'")
41
+ .get(task.id, task.plan_revision).n);
42
+ }
43
+ /** The verdict for this verified evidence, starting the reviewer the first time it is asked. */
44
+ async gate(task, evidenceDigest, deliverables) {
45
+ const critic = task.completion_gate?.critic;
46
+ if (!critic)
47
+ return { state: 'pass' };
48
+ const row = this.store.db
49
+ .prepare('SELECT * FROM task_critics WHERE task_id=? AND plan_revision=? AND evidence_digest=?')
50
+ .get(task.id, task.plan_revision, evidenceDigest);
51
+ if (row)
52
+ return this.settle(row);
53
+ const last = this.store.db
54
+ .prepare('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')
55
+ .get(task.id);
56
+ if (!last)
57
+ return { state: 'failed', reason: 'No step attempt for the critic to follow' };
58
+ if (!deliverables.length)
59
+ return { state: 'failed', reason: 'No verified deliverables to review' };
60
+ const round = Number(this.store.db
61
+ .prepare('SELECT count(*) AS n FROM task_critics WHERE task_id=? AND plan_revision=?')
62
+ .get(task.id, task.plan_revision).n) + 1;
63
+ const snapshot = Object.fromEntries(deliverables.map((d) => [
64
+ d.path,
65
+ { content: Buffer.from(d.content, 'utf8').toString('base64'), executable: false },
66
+ ]));
67
+ const grant = await this.deps.admit(task, `critic:${task.id}:${task.plan_revision}`);
68
+ const child = this.deps.spawn(task, String(last.attempt_id), `critic-${task.plan_revision}-${round}`, [
69
+ 'Judge the delivered work against this rubric before it is accepted:',
70
+ critic.rubric,
71
+ '',
72
+ 'Objective: ' + task.objective,
73
+ 'Deliverables (the snapshot): ' + deliverables.map((d) => d.path).join(', '),
74
+ '',
75
+ 'Report findings with salaam_findings, citing the file and line. Use critical or high only',
76
+ 'for what must change before the work is accepted; medium or low for suggestions. If the',
77
+ 'work meets the rubric, report an empty list.',
78
+ ].join('\n'), CRITIC_BUDGET, grant, snapshot);
79
+ this.store.db
80
+ .prepare("INSERT INTO task_critics VALUES (?,?,?,?,?,?,'running',NULL,NULL)")
81
+ .run(randomUUID(), task.id, task.plan_revision, evidenceDigest, round, child.id);
82
+ return { state: 'pending' };
83
+ }
84
+ settle(row) {
85
+ if (row.state === 'pass')
86
+ return { state: 'pass' };
87
+ if (row.state === 'changes_needed')
88
+ return { state: 'changes_needed', findings: JSON.parse(String(row.findings)) };
89
+ if (row.state === 'failed')
90
+ return { state: 'failed', reason: String(row.reason) };
91
+ const child = this.store.db
92
+ .prepare('SELECT state FROM child_tasks WHERE id=?')
93
+ .get(String(row.child_id));
94
+ if (!child || child.state === 'accepted')
95
+ return { state: 'pending' };
96
+ const report = this.store.db
97
+ .prepare('SELECT artifact_id FROM specialist_reports WHERE worker_id=?')
98
+ .get(this.deps.workerId(String(row.child_id)));
99
+ const set = (state, findings, reason) => this.store.db
100
+ .prepare('UPDATE task_critics SET state=?,findings=?,reason=? WHERE id=?')
101
+ .run(state, findings ? JSON.stringify(findings) : null, reason, String(row.id));
102
+ if (!report) {
103
+ const reason = 'The critic returned no findings report';
104
+ set('failed', null, reason);
105
+ return { state: 'failed', reason };
106
+ }
107
+ const findings = JSON.parse(this.artifacts.read(String(report.artifact_id))).findings.map(({ path, line, severity, message }) => ({ path, line, severity, message }));
108
+ if (findings.some((f) => f.severity === 'critical' || f.severity === 'high')) {
109
+ set('changes_needed', findings, null);
110
+ return { state: 'changes_needed', findings };
111
+ }
112
+ set('pass', findings, null);
113
+ return { state: 'pass' };
114
+ }
115
+ }
@@ -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 { commandTerminal } from './coding.js';
3
6
  import { canonical, ServiceError } from './protocol.js';
@@ -14,6 +17,7 @@ export class TaskDriver {
14
17
  awaitingAuthorityRenewal;
15
18
  active;
16
19
  stopped = true;
20
+ rateLimits;
17
21
  constructor(store, tasks, budget, verification, supervisor, children, coding) {
18
22
  this.store = store;
19
23
  this.tasks = tasks;
@@ -22,6 +26,7 @@ export class TaskDriver {
22
26
  this.supervisor = supervisor;
23
27
  this.children = children;
24
28
  this.coding = coding;
29
+ this.rateLimits = new RateLimitRetries(store);
25
30
  tasks.assertProgressionBinding = (task, binding) => {
26
31
  if (canonical(this.verification.binding(task)) !== binding)
27
32
  throw new ServiceError('task_progression_stale', 'Workspace or execution environment changed since the bounded grant');
@@ -295,6 +300,22 @@ export class TaskDriver {
295
300
  return;
296
301
  }
297
302
  if (task.steps.some((s) => ['interrupted', 'failed', 'cancelled'].includes(s.state))) {
303
+ // A provider 429 is retried as a fresh attempt of the step after a backoff; past the limit,
304
+ // or with no progression step left, the task stops with a block recovery can settle.
305
+ const retry = this.rateLimits.decide(task);
306
+ if (retry.action === 'wait')
307
+ return;
308
+ if (retry.action === 'retry' && Number(r.remaining_steps) >= 1) {
309
+ if (!(await this.worker(task)) || this.stopped)
310
+ return;
311
+ this.tasks.retryStep(task.id, retry.step, retry.n, Number(r.auto_epoch));
312
+ return;
313
+ }
314
+ if (retry.action !== 'none') {
315
+ this.budget.block(task.session_id, RATE_LIMIT_STOP);
316
+ this.stop(task, RATE_LIMIT_STOP);
317
+ return;
318
+ }
298
319
  this.stop(task, 'An interrupted or failed step requires explicit operator review');
299
320
  return;
300
321
  }
@@ -334,14 +355,37 @@ export class TaskDriver {
334
355
  // A passed receipt cannot be re-observed while another final check owns
335
356
  // the native workspace. Wait for the whole set before testing freshness;
336
357
  // finishAsync still performs the final transactional evidence validation.
337
- if (task.acceptance.some((check) => {
358
+ if (requiredChecks(task).some((check) => {
338
359
  const receipt = this.verification.latest(task, check.key, null);
339
360
  return receipt?.state === 'accepted' || receipt?.state === 'running';
340
361
  }))
341
362
  return;
342
- for (const check of task.acceptance)
363
+ for (const check of requiredChecks(task))
343
364
  if (!(await this.check(task, check.key, null)))
344
365
  return;
366
+ // Item 8: a declared critic reviews the verified deliverables before completion.
367
+ if (task.completion_gate?.critic) {
368
+ const verdict = await this.verification.criticGateAsync(task);
369
+ if (verdict.state === 'pending')
370
+ return;
371
+ if (verdict.state === 'failed') {
372
+ this.stop(task, 'Critic review failed: ' + verdict.reason);
373
+ return;
374
+ }
375
+ if (verdict.state === 'changes_needed') {
376
+ const round = this.verification.critic.reopens(task);
377
+ if (round > CRITIC_REOPENS || Number(r.remaining_steps) < 1) {
378
+ this.stop(task, 'Critic requested changes');
379
+ return;
380
+ }
381
+ if (!(await this.worker(task)) || this.stopped)
382
+ return;
383
+ this.tasks.reopenForCritic(task.id, task.steps.at(-1).id, round, Number(r.auto_epoch), verdict.findings
384
+ .map((f) => `- ${f.path}:${f.line} (${f.severity}) ${f.message}`)
385
+ .join('\n'));
386
+ return;
387
+ }
388
+ }
345
389
  await this.verification.finishAsync(task.id, 'auto-finish:' + task.id + ':' + task.plan_revision, task.revision, null, () => {
346
390
  if (!this.budget.state(task.id).auto_enabled)
347
391
  throw new ServiceError('task_authority', 'Progression paused before completion');
@@ -1,5 +1,23 @@
1
1
  import { integer, object, ServiceError, string } from './protocol.js';
2
2
  import { filePath } from './workspaces.js';
3
+ /** Every check completion requires: the acceptance checks, then each hook as hook-<key>. */
4
+ export function requiredChecks(policy) {
5
+ return [
6
+ ...policy.acceptance,
7
+ ...(policy.completion_gate?.hooks ?? []).map((h) => ({ ...h, key: 'hook-' + h.key })),
8
+ ];
9
+ }
10
+ const check = (v, keys, what) => {
11
+ const c = object(v), key = string(c.key, what + ' key', 64);
12
+ if (!/^[a-zA-Z0-9_-]+$/.test(key) || keys.has(key))
13
+ throw new ServiceError('invalid_plan', what + ' keys must be unique identifiers', 400);
14
+ keys.add(key);
15
+ return {
16
+ key,
17
+ command: string(c.command, what + ' command', 4096),
18
+ cwd: c.cwd === undefined || c.cwd === '.' ? '.' : filePath(c.cwd),
19
+ };
20
+ };
3
21
  export const DEFAULT_TASK_LIMITS = {
4
22
  requests: 64,
5
23
  runtime_ms: 900000,
@@ -14,17 +32,7 @@ export function taskPolicy(value) {
14
32
  if (!Array.isArray(raw) || raw.length > 8)
15
33
  throw new ServiceError('invalid_plan', 'Supply at most eight acceptance checks', 400);
16
34
  const keys = new Set();
17
- const acceptance = raw.map((v) => {
18
- const c = object(v), key = string(c.key, 'check key', 64);
19
- if (!/^[a-zA-Z0-9_-]+$/.test(key) || keys.has(key))
20
- throw new ServiceError('invalid_plan', 'Acceptance keys must be unique identifiers', 400);
21
- keys.add(key);
22
- return {
23
- key,
24
- command: string(c.command, 'check command', 4096),
25
- cwd: c.cwd === undefined || c.cwd === '.' ? '.' : filePath(c.cwd),
26
- };
27
- });
35
+ const acceptance = raw.map((v) => check(v, keys, 'Acceptance'));
28
36
  const paths = value.deliverables ?? [];
29
37
  if (!Array.isArray(paths) || paths.length > 32)
30
38
  throw new ServiceError('invalid_plan', 'Supply at most 32 deliverable paths', 400);
@@ -66,5 +74,29 @@ export function taskPolicy(value) {
66
74
  throw new ServiceError('invalid_budget', 'Invocation limit must be 1–64', 400);
67
75
  limits.invocations = n;
68
76
  }
69
- return { acceptance, deliverables, limits };
77
+ let completion_gate;
78
+ if (value.completion_gate !== undefined) {
79
+ const g = object(value.completion_gate);
80
+ const hooks = g.hooks;
81
+ const c = g.critic;
82
+ const critic = c === null
83
+ ? null
84
+ : (() => {
85
+ const v = object(c);
86
+ if (Object.keys(v).join(',') !== 'rubric')
87
+ throw new ServiceError('invalid_plan', 'A critic names only its rubric', 400);
88
+ return { rubric: string(v.rubric, 'critic rubric', 4096) };
89
+ })();
90
+ if (Object.keys(g).sort().join(',') !== 'critic,hooks' ||
91
+ !Array.isArray(hooks) ||
92
+ hooks.length > 8 ||
93
+ (!critic && hooks.length < 1))
94
+ throw new ServiceError('invalid_plan', 'A completion gate has a critic or 1–8 hooks, and at most 8 hooks', 400);
95
+ const hookKeys = new Set();
96
+ completion_gate = { critic, hooks: hooks.map((v) => check(v, hookKeys, 'Hook')) };
97
+ if (requiredChecks({ acceptance, completion_gate }).length !==
98
+ new Set(requiredChecks({ acceptance, completion_gate }).map((c) => c.key)).size)
99
+ throw new ServiceError('invalid_plan', 'A hook collides with an acceptance check', 400);
100
+ }
101
+ return { acceptance, deliverables, limits, ...(completion_gate ? { completion_gate } : {}) };
70
102
  }
@@ -1,3 +1,4 @@
1
+ import { requiredChecks } from './task-policy.js';
1
2
  import { StationTaskVerification } from './station-task-verification.js';
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import { commandTerminal } from './coding.js';
@@ -39,7 +40,7 @@ export class TaskVerification {
39
40
  };
40
41
  }
41
42
  assertIdle(task, stationInspection) {
42
- if (this.budget.active(task) > 0 ||
43
+ if (this.budget.active(task, { verification: true }) > 0 ||
43
44
  this.store.db
44
45
  .prepare("SELECT 1 FROM compactions WHERE session_id=? AND state='running'")
45
46
  .get(task.session_id))
@@ -105,7 +106,7 @@ export class TaskVerification {
105
106
  this.assertIdle(task);
106
107
  if (!this.station)
107
108
  this.coding.assertSafe(task.session_id);
108
- if (!task.acceptance.some((c) => c.key === key))
109
+ if (!requiredChecks(task).some((c) => c.key === key))
109
110
  throw new ServiceError('invalid_check', 'Check is not in this task plan');
110
111
  if (step &&
111
112
  !task.steps.some((s) => s.id === step && s.state === 'awaiting_review' && s.checks?.includes(key)))
@@ -147,7 +148,7 @@ export class TaskVerification {
147
148
  this.station.dispatch(id);
148
149
  return;
149
150
  }
150
- const task = this.tasks.get(receipt.task_id), check = task.acceptance.find((c) => c.key === receipt.check_key);
151
+ const task = this.tasks.get(receipt.task_id), check = requiredChecks(task).find((c) => c.key === receipt.check_key);
151
152
  try {
152
153
  const existing = this.store.db
153
154
  .prepare('SELECT id FROM command_runs WHERE session_id=? AND step_key=?')
@@ -383,6 +384,26 @@ export class TaskVerification {
383
384
  throw new ServiceError('verification_required', 'Protected station completion requires awaited verification');
384
385
  return this.finishRecorded(id, request, expected, exception, authorize);
385
386
  }
387
+ /** Item 8: the completion critic, when the service has one. */
388
+ critic;
389
+ /** The critic's verdict on the current fresh verified evidence, starting it the first time.
390
+ * Reads the evidence outside the finishing transaction, so starting the reviewer can never be
391
+ * rolled back with a refused finish; the finish itself re-checks the verdict against its own
392
+ * evidence. */
393
+ async criticGateAsync(task) {
394
+ if (!task.completion_gate?.critic)
395
+ return { state: 'pass' };
396
+ if (!this.critic || !this.station)
397
+ return { state: 'failed', reason: 'A critic needs the station verification boundary' };
398
+ const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
399
+ if (checks.some((c) => !c || c.state !== 'passed'))
400
+ return { state: 'pending' };
401
+ const fresh = await this.station.withFreshMany(checks.map((c) => c.id), task.deliverables, (evidence, deliverables) => ({ evidence, deliverables }));
402
+ return this.critic.gate(task, digest(fresh.evidence), fresh.deliverables.map(({ path, content }) => ({
403
+ path,
404
+ content,
405
+ })));
406
+ }
386
407
  async finishAsync(id, request, expected, exception, authorize) {
387
408
  if (!this.station)
388
409
  return this.finish(id, request, expected, exception, authorize);
@@ -401,7 +422,7 @@ export class TaskVerification {
401
422
  throw new ServiceError('verification_required', 'Strict station work requires verified completion without exceptions');
402
423
  if (!task.acceptance.length || !task.deliverables.length)
403
424
  throw new ServiceError('verification_required', 'Configured checks and deliverables required');
404
- const checks = task.acceptance.map((c) => this.latest(task, c.key, null));
425
+ const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
405
426
  if (checks.some((c) => !c || c.state !== 'passed'))
406
427
  throw new ServiceError('verification_required', 'Passed configured checks required');
407
428
  return this.station.withFreshMany(checks.map((c) => c.id), task.deliverables, (evidence, deliverables) => this.finishRecorded(id, request, expected, null, authorize, { evidence, deliverables }));
@@ -415,7 +436,7 @@ export class TaskVerification {
415
436
  if (view.completion?.state === 'succeeded') {
416
437
  try {
417
438
  const evidence = JSON.parse(this.artifacts.read(String(view.completion.artifact_id)));
418
- const checks = task.acceptance.map((c) => this.latest(task, c.key, null));
439
+ const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
419
440
  if (checks.some((c) => !c || c.state !== 'passed' || !evidence.checks.includes(c.id)))
420
441
  throw Error('Changed checks');
421
442
  view.completion.fresh = await this.station.withFreshMany(checks.map((c) => c.id), task.deliverables, (fresh) => view.completion.plan_revision === this.tasks.get(task.id).plan_revision &&
@@ -441,7 +462,7 @@ export class TaskVerification {
441
462
  this.assertIdle(task);
442
463
  if (task.steps.some((s) => s.state !== 'completed'))
443
464
  throw new ServiceError('verification_required', 'Every step must be reviewed or verified');
444
- const checks = task.acceptance.map((c) => this.latest(task, c.key, null));
465
+ const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
445
466
  const missing = [];
446
467
  if (!task.acceptance.length)
447
468
  missing.push('No configured acceptance checks');
@@ -452,13 +473,21 @@ export class TaskVerification {
452
473
  (protectedResult
453
474
  ? !protectedResult.evidence.some((e) => e.check_id === checks[n].id)
454
475
  : !this.fresh(checks[n])))
455
- missing.push('Fresh check required: ' + task.acceptance[n].key);
476
+ missing.push('Fresh check required: ' + requiredChecks(task)[n].key);
456
477
  let workspace = null, deliverables = [], diff = null;
457
478
  try {
458
479
  if (protectedResult) {
459
480
  const e = protectedResult.evidence[0];
460
481
  workspace = { id: 'station:' + e.root_digest, revision: 0, digest: e.tree_digest };
461
- deliverables = protectedResult.deliverables;
482
+ // Keep each deliverable's verified bytes as a session artifact, committed with the
483
+ // completion, so publication reads the verified revision and never the live workspace.
484
+ deliverables = protectedResult.deliverables.map(({ path, sha256, content }) => {
485
+ if (typeof content !== 'string')
486
+ throw new ServiceError('verification_required', 'Deliverable bytes unavailable');
487
+ const artifact = this.artifacts.prepare(task.session_id, content);
488
+ artifact.commit();
489
+ return { path, sha256, artifact_id: artifact.id };
490
+ });
462
491
  diff = {
463
492
  backend: 'linux-systemd-station-v1',
464
493
  root_digest: e.root_digest,
@@ -481,6 +510,10 @@ export class TaskVerification {
481
510
  catch (error) {
482
511
  missing.push('Workspace verification unavailable: ' + error.message);
483
512
  }
513
+ // Item 8: a declared critic must have passed this exact verified evidence.
514
+ if (task.completion_gate?.critic &&
515
+ !(protectedResult && this.critic?.passed(task, digest(protectedResult.evidence))))
516
+ missing.push('Critic review required');
484
517
  if (missing.length && !exception)
485
518
  throw new ServiceError('verification_required', missing.join('; '));
486
519
  if (exception) {
@@ -544,7 +577,7 @@ export class TaskVerification {
544
577
  if (completion.state === 'succeeded')
545
578
  fresh =
546
579
  fresh &&
547
- task.acceptance.every((check) => {
580
+ requiredChecks(task).every((check) => {
548
581
  const latest = this.latest(task, check.key, null);
549
582
  return !!latest && evidence.checks.includes(latest.id) && this.fresh(latest);
550
583
  });
@@ -34,6 +34,52 @@ export class TaskWork {
34
34
  .prepare('UPDATE task_runtime SET remaining_steps=remaining_steps-1 WHERE task_id=?')
35
35
  .run(task.id);
36
36
  }
37
+ /** Item 8: re-run the last completed step with the critic's findings added to its instruction,
38
+ * under its own re-prepared progression grant and a request of its own. */
39
+ reopenForCritic(taskId, stepId, round, epoch, findings) {
40
+ const task = this.get(taskId);
41
+ const step = task.steps.find((s) => s.id === stepId);
42
+ const grant = this.store.db
43
+ .prepare("SELECT id FROM task_grants WHERE task_id=? AND step_id=? AND plan_revision=? AND epoch=? AND state='accepted'")
44
+ .get(task.id, stepId, task.plan_revision, epoch);
45
+ if (!grant || !step || step.state !== 'completed' || task.steps.at(-1)?.id !== stepId)
46
+ throw new ServiceError('task_authority', 'Only the completed last step can be reopened');
47
+ this.store.transaction(() => {
48
+ this.store.db.prepare("UPDATE task_steps SET state='pending' WHERE id=?").run(stepId);
49
+ this.store.db.prepare("UPDATE task_grants SET state='prepared' WHERE id=?").run(grant.id);
50
+ this.store.event(task.session_id, 'work_task.step_reopened', {
51
+ task_id: task.id,
52
+ step_id: stepId,
53
+ grant_id: grant.id,
54
+ round,
55
+ reason: 'Critic requested changes',
56
+ });
57
+ });
58
+ return this.run(task.id, stepId, 'task-grant:' + grant.id + ':critic-reopen:' + round, this.get(task.id).revision, step.instruction +
59
+ '\n\nBefore this work is accepted, a reviewer asked for these changes:\n' +
60
+ findings, null, null, grant.id);
61
+ }
62
+ /** Re-run a step the provider rate-limited, as a fresh attempt under its own progression grant
63
+ * (re-prepared, with a request of its own, so it is never a replay of the refused attempt). */
64
+ retryStep(taskId, stepId, n, epoch) {
65
+ const task = this.get(taskId);
66
+ const grant = this.store.db
67
+ .prepare("SELECT id FROM task_grants WHERE task_id=? AND step_id=? AND plan_revision=? AND epoch=? AND state='accepted'")
68
+ .get(task.id, stepId, task.plan_revision, epoch);
69
+ if (!grant || task.steps.find((s) => s.id === stepId)?.state !== 'failed')
70
+ throw new ServiceError('task_authority', 'No settled grant for this rate-limited step');
71
+ this.store.transaction(() => {
72
+ this.store.db.prepare("UPDATE task_grants SET state='prepared' WHERE id=?").run(grant.id);
73
+ this.store.event(task.session_id, 'work_task.step_retried', {
74
+ task_id: task.id,
75
+ step_id: stepId,
76
+ grant_id: grant.id,
77
+ retry: n,
78
+ reason: 'Provider rate limit',
79
+ });
80
+ });
81
+ return this.run(task.id, stepId, 'task-grant:' + grant.id + ':rate-limit-retry:' + n, task.revision, null, null, null, grant.id);
82
+ }
37
83
  advance(grantId) {
38
84
  const grant = this.store.db.prepare('SELECT * FROM task_grants WHERE id=?').get(grantId);
39
85
  if (!grant)
@@ -439,6 +485,7 @@ export class TaskWork {
439
485
  objective: task.objective,
440
486
  constraints: task.constraints,
441
487
  acceptance: task.acceptance,
488
+ ...(task.completion_gate ? { completion_gate: task.completion_gate } : {}),
442
489
  deliverables: task.deliverables,
443
490
  limits: task.limits,
444
491
  active_step_id: task.steps.find((s) => s.state === 'running')?.id ?? null,
@@ -0,0 +1,88 @@
1
+ import { execFile } from 'node:child_process';
2
+ export function terminalState(snapshot) {
3
+ if (!snapshot)
4
+ return 'idle';
5
+ if ((snapshot.approvals ?? []).some((a) => a.state === 'pending'))
6
+ return 'blocked';
7
+ if ((snapshot.attempts ?? []).some((a) => ['accepted', 'dispatching', 'running'].includes(a.state)))
8
+ return 'working';
9
+ return 'idle';
10
+ }
11
+ export function titleFor(state, name) {
12
+ // eslint-disable-next-line no-control-regex
13
+ const clean = name
14
+ ?.replace(/[\x00-\x1f\x7f-\x9f]/g, '')
15
+ .trim()
16
+ .slice(0, 32);
17
+ if (!clean)
18
+ return 'Salaam';
19
+ const words = state === 'blocked' ? 'needs you' : state === 'working' ? 'working' : 'idle';
20
+ return `Salaam · ${clean} · ${words}`;
21
+ }
22
+ export function statusEnabled(env = process.env) {
23
+ return env.SALAAM_TERMINAL_STATUS !== 'off';
24
+ }
25
+ const run = (bin, args, done) => {
26
+ // Short timeout, failures ignored: Herdr must never slow Salaam down.
27
+ execFile(bin, args, { timeout: 2000, windowsHide: true }, () => done());
28
+ };
29
+ /** Reports to Herdr only inside a Herdr pane; one report in flight, newest state wins. */
30
+ export class HerdrReporter {
31
+ env;
32
+ exec;
33
+ now;
34
+ enabled;
35
+ seq = 0;
36
+ busy = false;
37
+ queued;
38
+ last;
39
+ constructor(env = process.env, exec = run, now = Date.now) {
40
+ this.env = env;
41
+ this.exec = exec;
42
+ this.now = now;
43
+ this.enabled = !!(env.HERDR_ENV === '1' && env.HERDR_PANE_ID && env.HERDR_BIN_PATH);
44
+ }
45
+ nextSeq() {
46
+ this.seq = Math.max(this.seq + 1, this.now());
47
+ return String(this.seq);
48
+ }
49
+ head(verb) {
50
+ return ['pane', verb, this.env.HERDR_PANE_ID, '--source', 'salaam', '--agent', 'salaam'];
51
+ }
52
+ report(state, session) {
53
+ if (!this.enabled)
54
+ return;
55
+ const key = state + ' ' + (session ?? '');
56
+ if (key === this.last)
57
+ return;
58
+ this.last = key;
59
+ this.queued = [
60
+ ...this.head('report-agent'),
61
+ '--state',
62
+ state,
63
+ '--seq',
64
+ '',
65
+ ...(state === 'blocked' ? ['--message', 'Waiting for your approval'] : []),
66
+ ...(session ? ['--agent-session-id', session, '--', 'salaam', '--session', session] : []),
67
+ ];
68
+ this.flush();
69
+ }
70
+ release() {
71
+ if (!this.enabled)
72
+ return;
73
+ this.queued = undefined;
74
+ this.exec(this.env.HERDR_BIN_PATH, [...this.head('release-agent'), '--seq', this.nextSeq()], () => { });
75
+ }
76
+ flush() {
77
+ if (this.busy || !this.queued)
78
+ return;
79
+ const args = this.queued;
80
+ this.queued = undefined;
81
+ args[args.indexOf('--seq') + 1] = this.nextSeq();
82
+ this.busy = true;
83
+ this.exec(this.env.HERDR_BIN_PATH, args, () => {
84
+ this.busy = false;
85
+ this.flush();
86
+ });
87
+ }
88
+ }