@jwilger/pi-development-system 0.87.0 → 0.88.0

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.
package/README.md CHANGED
@@ -27,6 +27,10 @@ terminology, journeys). `devsys_lens_review` plans a review of the brief by five
27
27
  writes the packets to `docs/product/reviews/`; `devsys_adr_new` creates the next numbered ADR, and
28
28
  a commit that shapes the architecture without one is stopped by the soft gate `adr.missing`.
29
29
 
30
+ A reviewer subagent hands over its result with `devsys_submit_review` (typed arguments; a result that
31
+ contradicts itself or is for the wrong round is refused with an error id), and `devsys_review_record`
32
+ reads it. A markdown packet is still accepted (ADR 0006).
33
+
30
34
  Event modelling has a skill (`event-modelling`: three slice patterns, Given/When/Then, completeness).
31
35
  `devsys_event_model_check` validates a directory of slice files (schema v1) and renders the swimlane
32
36
  Markdown or a Mermaid diagram; each profile's `gwt-tests` reference turns scenarios into failing tests.
@@ -18,6 +18,7 @@ tools:
18
18
  - find
19
19
  - ls
20
20
  - agent_update
21
+ - devsys_submit_review
21
22
  - agent_pause
22
23
  ---
23
24
 
@@ -41,7 +42,7 @@ Do not modify files. bash is not a sandbox: use it only for read-only inspection
41
42
 
42
43
  ## Output
43
44
 
44
- Use exactly this packet; the coordinator parses it.
45
+ Submit your result by calling `devsys_submit_review` (slice and round exactly as the task gives them): it checks the call and refuses a self-contradicting one with an error id, which you correct and resubmit. After it accepts, end with one short line. Only when that tool is not available, return exactly this packet instead; the coordinator parses it.
45
46
 
46
47
  ```markdown
47
48
  ## Review — <slice> — round <n> — lenses: <a, b>
@@ -35,6 +35,8 @@ import { createFinishSliceTool, registerSliceClose } from "../src/planning/slice
35
35
  import { createTaskCheckTool } from "../src/planning/task-check-tool.ts";
36
36
  import { createLensReviewTool } from "../src/review/lens-review-tool.ts";
37
37
  import { createReviewRecordTool, createReviewStartTool } from "../src/review/review-tools.ts";
38
+ import { createSubmissionStore } from "../src/review/submissions.ts";
39
+ import { createSubmitReviewTool } from "../src/review/submit-tool.ts";
38
40
  import { registerCiCommand } from "../src/state/ci-command.ts";
39
41
  import { loadConfig } from "../src/state/config.ts";
40
42
  import { createModelsTool, registerModelsCommand } from "../src/state/models-command.ts";
@@ -186,13 +188,16 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
186
188
  createLensReviewTool({ state, jev: (ctx) => jevHolder.forContext(ctx), now: () => new Date() }),
187
189
  );
188
190
  pi.registerTool(createIntakeTool({ pi, state, jev: (ctx) => jevHolder.forContext(ctx) }));
191
+ const submissions = createSubmissionStore();
189
192
  const reviewDeps = {
190
193
  state,
194
+ submissions,
191
195
  jev: (ctx: ExtensionContext) => jevHolder.forContext(ctx),
192
196
  exec,
193
197
  } satisfies Parameters<typeof createReviewStartTool>[0];
194
198
  pi.registerTool(createReviewStartTool(reviewDeps));
195
199
  pi.registerTool(createReviewRecordTool(reviewDeps));
200
+ pi.registerTool(createSubmitReviewTool({ state, submissions }));
196
201
  registerModelsCommand(pi);
197
202
  piSubagent(pi);
198
203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.87.0",
3
+ "version": "0.88.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -6,5 +6,5 @@ Review the work in progress with the code-review skill.
6
6
 
7
7
  1. Call `devsys_review_start` (slice and diff range: $ARGUMENTS; omit either to use the active slice and `HEAD`; only the `HEAD` range clears the commit gate).
8
8
  2. Run the `agent_spawn` payload it returns, unchanged.
9
- 3. Pass the reviewer's packet verbatim to `devsys_review_record` with the same `slice`, `diffDigest` and `diffRange` the start reply gave.
9
+ 3. The reviewer submits its result with `devsys_submit_review`; call `devsys_review_record` with the same `slice`, `diffDigest` and `diffRange` the start reply gave (no packets needed). Only if the reviewer returned a markdown packet instead, pass it verbatim in `packets`.
10
10
  4. Report `review: N/R clean` and the next action. If the next action is `fix-findings`, list each blocking and should-fix finding with its `path:line`.
@@ -17,11 +17,13 @@ Review is a soft gate (`review.unsatisfied`): skipping it needs a recorded depar
17
17
  diff digest, lets Jev choose lenses, and returns an exact `agent_spawn` payload.
18
18
  2. Run that `agent_spawn` unchanged. The reviewer is a top-level agent, not a
19
19
  child of this conversation, so it does not inherit your context.
20
- 3. Pass the reviewer's packet, verbatim, to `devsys_review_record` with the
21
- `slice` and `diffDigest` the start reply gave, and the same `diffRange` if you gave one
22
- (`packets: [...]`; all lens packets of one
23
- round in one call). A packet is recorded once, in the round its header names;
24
- if the diff changed since the start, the round is refused: start again.
20
+ 3. The reviewer submits its result with `devsys_submit_review` (typed arguments; a
21
+ self-contradicting or wrong-round result is refused to the reviewer with an error id).
22
+ Call `devsys_review_record` with the `slice` and `diffDigest` the start reply gave, and
23
+ the same `diffRange` if you gave one; it reads the submitted results of the round. If a
24
+ reviewer returned a markdown packet instead, pass it verbatim in `packets: [...]` (all
25
+ lens packets of one round in one call). A result is recorded once, in the round it
26
+ names; if the diff changed since the start, the round is refused: start again.
25
27
  4. Read the reply: `review: N/R clean` and `next:`.
26
28
  - `fix-findings`: fix every blocking and should-fix finding, then start a new round
27
29
  (starting again on an unchanged diff is refused).
@@ -29,7 +31,9 @@ Review is a soft gate (`review.unsatisfied`): skipping it needs a recorded depar
29
31
  - `stale-diff`: the diff changed after a satisfied review; one more round.
30
32
  - `done`: commit.
31
33
 
32
- ## The packet
34
+ ## The result
35
+
36
+ The fields of `devsys_submit_review` mirror this packet; the markdown form is the fallback (ADR 0006).
33
37
 
34
38
  ```markdown
35
39
  ## Review — <slice> — round <n> — lenses: <a, b>
@@ -12,7 +12,7 @@ export function phaseGuide(phase: Phase): string {
12
12
  case "implementing":
13
13
  return "Implementing one slice. Write a failing test first, make it pass with the least code, run the tests and read the result before claiming anything. Push when the slice is complete and reviewed: a push of a clean tree closes it (devsys_finish_slice closes it explicitly).";
14
14
  case "reviewing":
15
- return "Reviewing. Call devsys_review_start for a fresh-context review, pass its packet and diffDigest to devsys_review_record, fix every blocking and should-fix finding (red-first still applies), and repeat until the review is satisfied; then the slice moves to delivering.";
15
+ return "Reviewing. Call devsys_review_start for a fresh-context review, give its diffDigest to devsys_review_record (the reviewer submits through devsys_submit_review), fix every blocking and should-fix finding (red-first still applies), and repeat until the review is satisfied; then the slice moves to delivering.";
16
16
  case "delivering":
17
17
  return "Delivering. The review is satisfied. Commit with a rationale, push, and release; a push of a clean tree closes the slice (devsys_finish_slice closes it explicitly, or abandons it with a reason). Editing source reopens implementing. A red trunk (CI) is repaired first.";
18
18
  default:
@@ -176,7 +176,8 @@ export function reviewerTask(input: {
176
176
  `Review slice ${input.slice}, round ${input.round}, through these lenses: ${lenses}.`,
177
177
  `See the change with \`git diff ${input.diffRange}\` (and \`git diff --stat ${input.diffRange}\`), plus any untracked files listed by \`git ls-files --others --exclude-standard\` (git diff does not show them; read them directly). Read the callers, callees and tests it depends on, no more.`,
178
178
  "Do not modify files; report demonstrable defects with a realistic trigger and the smallest local repair.",
179
- "Return exactly the review packet from your instructions, with exactly this header (slice and round must not change):",
179
+ `Report your result by calling devsys_submit_review with slice "${input.slice}", round ${input.round}, lenses ${JSON.stringify(input.lenses)}, your sources, your findings (lens, severity, path, line, summary) and the verdict. If it refuses, for example with verdict-contradicts-findings or wrong-round, correct the call and submit again; when it accepts, end with one short line.`,
180
+ "Only if devsys_submit_review is not available, return the review packet from your instructions instead, with exactly this header (slice and round must not change):",
180
181
  "",
181
182
  `## Review — ${input.slice} — round ${input.round} — lenses: ${lenses}`,
182
183
  "### Sources inspected",
@@ -0,0 +1,60 @@
1
+ import type { Finding, Severity } from "./review.ts";
2
+ import type { ReviewPacket } from "./review-packet.ts";
3
+
4
+ /** A reviewer's result as it arrives from the typed submit tool (see ADR 0006). */
5
+ export type Submission = {
6
+ readonly slice: string;
7
+ readonly round: number;
8
+ readonly lenses: ReadonlyArray<string>;
9
+ readonly sources: ReadonlyArray<string>;
10
+ readonly findings: ReadonlyArray<{
11
+ readonly lens: string;
12
+ readonly severity: Severity;
13
+ readonly path?: string | undefined;
14
+ readonly line?: number | undefined;
15
+ readonly summary: string;
16
+ }>;
17
+ readonly verdict: "no-blocking" | "blocking";
18
+ };
19
+
20
+ /** Why a submission was refused: a stable kebab-case id and a message the reviewer can act on. */
21
+ export type SubmitError = {
22
+ readonly id: "verdict-contradicts-findings" | "finding-lens-not-listed";
23
+ readonly message: string;
24
+ };
25
+
26
+ const counts = (f: { severity: Severity }): boolean =>
27
+ f.severity === "blocking" || f.severity === "should-fix";
28
+
29
+ /** The packet a submission stands for, or the refusal. The schema has already checked its shape. */
30
+ export function packetFromSubmission(input: Submission): ReviewPacket | SubmitError {
31
+ const unlisted = input.findings.find((f) => !input.lenses.includes(f.lens));
32
+ if (unlisted !== undefined) {
33
+ return {
34
+ id: "finding-lens-not-listed",
35
+ message: `a finding names lens "${unlisted.lens}" but the packet's lenses are ${input.lenses.join(", ")}; list every lens you used in \`lenses\``,
36
+ };
37
+ }
38
+ if (input.findings.some(counts) !== (input.verdict === "blocking")) {
39
+ return {
40
+ id: "verdict-contradicts-findings",
41
+ message: `verdict "${input.verdict}" contradicts the findings: blocking/should-fix findings mean "blocking", otherwise "no-blocking"`,
42
+ };
43
+ }
44
+ const findings: Finding[] = input.findings.map((f, i) => ({
45
+ id: `${f.lens}-${i + 1}`,
46
+ severity: f.severity,
47
+ ...(f.path === undefined ? {} : { path: f.path }),
48
+ ...(f.line === undefined ? {} : { line: f.line }),
49
+ summary: f.summary,
50
+ lens: f.lens,
51
+ }));
52
+ return {
53
+ slice: input.slice,
54
+ round: input.round,
55
+ lenses: input.lenses,
56
+ sources: input.sources,
57
+ findings,
58
+ verdict: input.verdict,
59
+ };
60
+ }
@@ -30,11 +30,14 @@ import { CONFIG_FILE, loadConfig } from "../state/config.ts";
30
30
  import { availableModels } from "../state/models-command.ts";
31
31
  import type { SessionState } from "../state/session-state.ts";
32
32
  import { snapshotDiff } from "./digest.ts";
33
+ import type { SubmissionStore } from "./submissions.ts";
33
34
 
34
35
  export type ReviewToolDeps = {
35
36
  state: SessionState;
36
37
  jev: (ctx: ExtensionContext) => Jev;
37
38
  exec: Exec;
39
+ /** Results reviewers submitted with `devsys_submit_review`, read by `devsys_review_record`. */
40
+ submissions: SubmissionStore;
38
41
  now?: () => Date;
39
42
  };
40
43
 
@@ -55,6 +58,9 @@ const sliceOf = (given: string | undefined, state: SessionState): SliceRef | und
55
58
  const NO_SLICE =
56
59
  "no slice: pass `slice`, or start work on a slice first (there is no active slice)";
57
60
 
61
+ const NO_RESULT = (slice: string, round: number): string =>
62
+ `no result for round ${round} of "${slice}": the reviewer submits it with devsys_submit_review before you record, or you pass its packet markdown in \`packets\``;
63
+
58
64
  const StartParameters = Type.Object({
59
65
  slice: Type.Optional(
60
66
  Type.String({ description: "Slice being reviewed; defaults to the active slice." }),
@@ -120,6 +126,7 @@ function spawnPayload(input: {
120
126
  }
121
127
 
122
128
  type Reply = ReturnType<typeof reply>;
129
+ const replyText = (r: Reply): string => r.content.map((c) => c.text).join("\n");
123
130
  type Config = Extract<Awaited<ReturnType<typeof loadConfig>>, { ok: true }>["value"];
124
131
  type Snapshot = Extract<Awaited<ReturnType<typeof snapshotDiff>>, { ok: true }>["value"];
125
132
  type Prepared = { slice: SliceRef; config: Config; range: string; snap: Snapshot };
@@ -181,6 +188,8 @@ async function startRound(
181
188
  const required = requiredRounds(p.config);
182
189
  const existing = reviewOf(deps.state.get(), p.slice) ?? startReview(p.slice, required);
183
190
  const review: ReviewState = { ...existing, required };
191
+ // A new round only sees what reviewers submit after it began.
192
+ deps.submissions.clear(p.slice);
184
193
  deps.state.update((s) => upsertReview(s, review));
185
194
  deps.state.update((s) => afterReviewRound(s, nextAction(review, p.snap.digest), p.slice));
186
195
  const refusal = startRefusal(review, p);
@@ -200,7 +209,7 @@ async function startRound(
200
209
  [
201
210
  `${reviewLabel(review)}; round ${round}; diff ${p.snap.digest}. ${basis}`,
202
211
  `lenses: ${lenses.join(", ")}`,
203
- `Spawn the reviewer with agent_spawn using exactly this payload, then pass its packet to devsys_review_record with slice "${p.slice}", diffDigest "${p.snap.digest}"${rangeArg}:`,
212
+ `Spawn the reviewer with agent_spawn using exactly this payload, then call devsys_review_record with slice "${p.slice}", diffDigest "${p.snap.digest}"${rangeArg}. The reviewer submits its result with devsys_submit_review, so pass no packets (only a markdown packet the reviewer returned instead goes in packets):`,
204
213
  JSON.stringify(spawn),
205
214
  ].join("\n"),
206
215
  );
@@ -214,7 +223,7 @@ export function createReviewStartTool(
214
223
  name: "devsys_review_start",
215
224
  label: "Start review round",
216
225
  description:
217
- "Begin a review round for a slice: computes the diff digest, chooses review lenses, and returns the agent_spawn payload for a fresh-context reviewer. Run that spawn, then pass the reviewer's packet to devsys_review_record.",
226
+ "Begin a review round for a slice: computes the diff digest, chooses review lenses, and returns the agent_spawn payload for a fresh-context reviewer. Run that spawn, then call devsys_review_record (the reviewer submits its result with devsys_submit_review).",
218
227
  promptSnippet: "Start a fresh-context review round for a slice",
219
228
  parameters: StartParameters,
220
229
  exposure: "model-only",
@@ -243,9 +252,12 @@ const RecordParameters = Type.Object({
243
252
  slice: Type.Optional(
244
253
  Type.String({ description: "Slice reviewed; defaults to the active slice." }),
245
254
  ),
246
- packets: Type.Array(Type.String(), {
247
- description: "Reviewer packets, verbatim markdown: one per lens reviewer, all for one round.",
248
- }),
255
+ packets: Type.Optional(
256
+ Type.Array(Type.String(), {
257
+ description:
258
+ "Reviewer packets, verbatim markdown, for a reviewer that could not call devsys_submit_review. Results submitted through that tool for this round are read without being passed.",
259
+ }),
260
+ ),
249
261
  diffRange: Type.Optional(
250
262
  Type.String({ description: "The range that was reviewed; defaults to HEAD." }),
251
263
  ),
@@ -275,15 +287,22 @@ async function adjusted(
275
287
  return { findings: out, notes };
276
288
  }
277
289
 
290
+ const lensKey = (p: ReviewPacket): string => [...p.lenses].sort().join("\u0000");
291
+
292
+ /** Submitted results win over a markdown packet for the same lenses (a reviewer that did both). */
293
+ function mergeResults(
294
+ markdown: readonly ReviewPacket[],
295
+ submitted: readonly ReviewPacket[],
296
+ ): { packets: ReviewPacket[]; dropped: number } {
297
+ const taken = new Set(submitted.map(lensKey));
298
+ const kept = markdown.filter((m) => !taken.has(lensKey(m)));
299
+ return { packets: [...kept, ...submitted], dropped: markdown.length - kept.length };
300
+ }
301
+
278
302
  /** Parses every packet, or the reply naming the first malformed one. */
279
303
  function parsePackets(
280
304
  raw: readonly string[],
281
305
  ): { ok: true; value: ReviewPacket[] } | { ok: false; reply: Reply } {
282
- if (raw.length === 0)
283
- return {
284
- ok: false,
285
- reply: reply("no packets: pass the reviewer's packet markdown in `packets`", true),
286
- };
287
306
  const packets: ReviewPacket[] = [];
288
307
  for (const [i, text] of raw.entries()) {
289
308
  const parsed = parseReviewPacket(text);
@@ -359,7 +378,7 @@ export function createReviewRecordTool(
359
378
  name: "devsys_review_record",
360
379
  label: "Record review round",
361
380
  description:
362
- "Record one review round from the reviewer's packet(s): parses findings, lets Jev adjust severity when confident, and returns the clean-round count and the next action.",
381
+ "Record one review round from the reviewer's submitted result (or markdown packets): reads findings, lets Jev adjust severity when confident, and returns the clean-round count and the next action.",
363
382
  promptSnippet: "Record a reviewer packet as a review round",
364
383
  parameters: RecordParameters,
365
384
  exposure: "direct",
@@ -370,9 +389,23 @@ export function createReviewRecordTool(
370
389
  _onUpdate,
371
390
  ctx: ExtensionContext,
372
391
  ) {
373
- if (sliceOf(params.slice, deps.state) === undefined) return reply(NO_SLICE, true);
374
- const packets = parsePackets(params.packets);
375
- if (!packets.ok) return packets.reply;
392
+ const slice = sliceOf(params.slice, deps.state);
393
+ if (slice === undefined) return reply(NO_SLICE, true);
394
+ const round = (reviewOf(deps.state.get(), slice)?.rounds.length ?? 0) + 1;
395
+ const markdown = parsePackets(params.packets ?? []);
396
+ if (!markdown.ok) {
397
+ return deps.submissions.forRound(slice, round).length === 0
398
+ ? markdown.reply
399
+ : reply(
400
+ `${replyText(markdown.reply)}. A submitted result for round ${round} is waiting: omit \`packets\` to record it.`,
401
+ true,
402
+ );
403
+ }
404
+ const { packets, dropped } = mergeResults(
405
+ markdown.value,
406
+ deps.submissions.forRound(slice, round),
407
+ );
408
+ if (packets.length === 0) return reply(NO_RESULT(slice, round), true);
376
409
  const prepared = await prepare(
377
410
  deps,
378
411
  ctx,
@@ -386,7 +419,14 @@ export function createReviewRecordTool(
386
419
  true,
387
420
  );
388
421
  }
389
- return recordRound(deps, ctx, prepared.value, packets.value);
422
+ const recorded = await recordRound(deps, ctx, prepared.value, packets);
423
+ if (recorded.isError === true) return recorded;
424
+ deps.submissions.clear(slice);
425
+ return dropped === 0
426
+ ? recorded
427
+ : reply(
428
+ `${replyText(recorded)}\nNote: ${dropped} markdown packet(s) for lenses already submitted were dropped; the submitted result is the one recorded.`,
429
+ );
390
430
  },
391
431
  };
392
432
  }
@@ -0,0 +1,38 @@
1
+ import type { ReviewPacket } from "../core/review-packet.ts";
2
+
3
+ /** Reviewer results submitted through `devsys_submit_review`, waiting for `devsys_review_record`. */
4
+ export type SubmissionStore = {
5
+ /**
6
+ * Keep a packet. One for the same slice and round that shares a lens replaces the earlier one
7
+ * (a corrected resubmission); packets for disjoint lenses (parallel lens reviewers) are all kept.
8
+ */
9
+ put(packet: ReviewPacket): void;
10
+ forRound(slice: string, round: number): ReviewPacket[];
11
+ /** Drop every packet of a slice, when a new round starts or one is recorded. */
12
+ clear(slice: string): void;
13
+ };
14
+
15
+ const sameRound = (a: ReviewPacket, b: ReviewPacket): boolean =>
16
+ a.slice === b.slice && a.round === b.round;
17
+
18
+ const sharesLens = (a: ReviewPacket, b: ReviewPacket): boolean =>
19
+ a.lenses.some((lens) => b.lenses.includes(lens));
20
+
21
+ /** In memory on purpose (ADR 0006): a round takes minutes and a lost submission is visible. */
22
+ export function createSubmissionStore(): SubmissionStore {
23
+ let packets: ReviewPacket[] = [];
24
+ return {
25
+ put(packet) {
26
+ packets = [
27
+ ...packets.filter((p) => !(sameRound(p, packet) && sharesLens(p, packet))),
28
+ packet,
29
+ ];
30
+ },
31
+ forRound(slice, round) {
32
+ return packets.filter((p) => p.slice === slice && p.round === round);
33
+ },
34
+ clear(slice) {
35
+ packets = packets.filter((p) => p.slice !== slice);
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,85 @@
1
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { type Static, Type } from "typebox";
3
+ import { SEVERITIES } from "../core/review.ts";
4
+ import { reviewOf } from "../core/review-flow.ts";
5
+ import { packetFromSubmission } from "../core/review-submission.ts";
6
+ import type { SliceRef } from "../core/types.ts";
7
+ import type { SessionState } from "../state/session-state.ts";
8
+ import type { SubmissionStore } from "./submissions.ts";
9
+
10
+ export const SubmitParameters = Type.Object({
11
+ slice: Type.String({ description: "The slice you were asked to review, exactly as given." }),
12
+ round: Type.Integer({ minimum: 1, description: "The round number you were asked to review." }),
13
+ lenses: Type.Array(Type.String({ minLength: 1 }), {
14
+ minItems: 1,
15
+ description: "Every lens you reviewed through.",
16
+ }),
17
+ sources: Type.Array(Type.String(), {
18
+ description: "What you inspected (path:line ranges), and what you checked and refuted.",
19
+ }),
20
+ findings: Type.Array(
21
+ Type.Object({
22
+ lens: Type.String({ minLength: 1, description: "One of `lenses`." }),
23
+ severity: Type.Union(SEVERITIES.map((s) => Type.Literal(s))),
24
+ path: Type.Optional(Type.String({ description: "File the finding is about." })),
25
+ line: Type.Optional(Type.Integer({ minimum: 1 })),
26
+ summary: Type.String({
27
+ minLength: 1,
28
+ description: "One sentence: the defect, why it matters, and the smallest repair.",
29
+ }),
30
+ }),
31
+ { description: "Empty when there is nothing to report." },
32
+ ),
33
+ verdict: Type.Union([Type.Literal("no-blocking"), Type.Literal("blocking")], {
34
+ description: "`blocking` when any finding is blocking or should-fix, otherwise `no-blocking`.",
35
+ }),
36
+ });
37
+
38
+ export type SubmitDeps = { state: SessionState; submissions: SubmissionStore };
39
+
40
+ const reply = (text: string, isError = false) => ({
41
+ content: [{ type: "text" as const, text }],
42
+ details: undefined,
43
+ isError,
44
+ });
45
+
46
+ function submit(deps: SubmitDeps, params: Static<typeof SubmitParameters>) {
47
+ const state = deps.state.get();
48
+ const slice = params.slice as SliceRef;
49
+ const review = reviewOf(state, slice);
50
+ if (review === undefined && state.activeSlice !== slice) {
51
+ return reply(
52
+ `unknown-slice: "${params.slice}" is neither the active slice nor under review; use the slice name exactly as given in your task`,
53
+ true,
54
+ );
55
+ }
56
+ const expected = (review?.rounds.length ?? 0) + 1;
57
+ if (params.round !== expected) {
58
+ return reply(
59
+ `wrong-round: this is for round ${params.round}, but round ${expected} of "${params.slice}" is the one being reviewed; use the round number from your task`,
60
+ true,
61
+ );
62
+ }
63
+ const packet = packetFromSubmission(params);
64
+ if ("id" in packet) return reply(`${packet.id}: ${packet.message}`, true);
65
+ deps.submissions.put(packet);
66
+ return reply(
67
+ `submitted: ${params.slice} round ${params.round}, lenses ${params.lenses.join(", ")}, verdict ${params.verdict}, ${params.findings.length} finding(s). End with one short line.`,
68
+ );
69
+ }
70
+
71
+ /** `devsys_submit_review`: a reviewer hands over its packet as validated arguments (ADR 0006). */
72
+ export function createSubmitReviewTool(deps: SubmitDeps): ToolDefinition<typeof SubmitParameters> {
73
+ return {
74
+ name: "devsys_submit_review",
75
+ label: "Submit review result",
76
+ description:
77
+ "For reviewer subagents only: submit your review result as structured arguments. A result that contradicts itself or is for another round is refused with an error id; fix it and submit again. Then end with one short line.",
78
+ promptSnippet: "Submit a review result (reviewer subagents)",
79
+ parameters: SubmitParameters,
80
+ exposure: "direct",
81
+ execute(_id, params: Static<typeof SubmitParameters>) {
82
+ return Promise.resolve(submit(deps, params));
83
+ },
84
+ };
85
+ }