opencode-plugin-flow 6.1.0 → 6.3.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/dist/index.js CHANGED
@@ -7,10 +7,12 @@ description: Manage a Flow goal from planning through implementation, validation
7
7
  # Flow
8
8
 
9
9
  Flow is a small state ledger around ordinary coding work. The root manager owns
10
- the session, every state-changing \`flow_*\` call, integration, validation,
11
- review dispatch, reset, and closure. Bounded \`flow-worker\` instances may
12
- contribute disjoint work inside the active feature. The reserved \`flow-reviewer\`
13
- independently reviews; it never edits or mutates Flow state.
10
+ the session, integration, validation, review dispatch, reset, closure, and every
11
+ lifecycle mutation except review submission. Bounded \`flow-worker\`
12
+ instances may contribute disjoint work inside the active feature. The reserved
13
+ \`flow-reviewer\` independently reviews and submits its own result through
14
+ \`flow_feature_complete\`; it cannot edit the workspace or make any other
15
+ lifecycle mutation.
14
16
 
15
17
  ## Route from status
16
18
 
@@ -37,9 +39,10 @@ report that the plugin is not fully loaded instead of simulating state changes.
37
39
  - Only one durable feature run is active at a time. Conversation-local worker
38
40
  waves do not create additional runs or Flow state.
39
41
  - Work stays inside the active feature and preserves unrelated user changes.
40
- - A passing feature needs successful current-source validation and one
41
- independent reviewer assignment. The final feature uses broad validation and
42
- a final review; it does not add a second review pass.
42
+ - A passing feature needs successful current-source validation and one result
43
+ submitted directly by the assigned independent reviewer. The final feature
44
+ uses broad validation and a final review; it does not add a second review
45
+ pass.
43
46
  - A failed review is recorded honestly. Any retry is a fresh run with full
44
47
  validation and review.
45
48
  - Use runtime revisions and operation ids for ordering and idempotency. Supply
@@ -53,7 +56,13 @@ report that the plugin is not fully loaded instead of simulating state changes.
53
56
 
54
57
  On confusion or interruption, read compact status and follow its next action.
55
58
  Use execution status for the active feature and reviewer status for a returned
56
- assignment id. If status is closed with \`archiveRetry\`, call
59
+ assignment id. Redispatch a pending assignment after interruption or an
60
+ unconfirmed reviewer return; the manager never invents or submits a verdict. If
61
+ completion reports \`Workspace content changed after review started\`, call
62
+ \`flow_feature_reset\`; that source-stale assignment must not be redispatched.
63
+ Start a fresh run and repeat full validation and review. After the reviewer
64
+ returns, read compact status to learn the durable outcome. If status is closed
65
+ with \`archiveRetry\`, call
57
66
  \`flow_session_close\` with that projected request byte-for-byte; do not create a
58
67
  new operation id or revision. Never infer completion, retry count, or closure
59
68
  from prose.
@@ -126,26 +135,30 @@ document in the repository unless the user explicitly requests one.
126
135
  // skills/flow-review/SKILL.md
127
136
  var SKILL_default3 = `---
128
137
  name: flow-review
129
- description: Independently review one runtime-owned Flow assignment. Reserved for the read-only flow-reviewer; managers dispatch assignments but do not perform this review themselves.
138
+ description: Independently review one runtime-owned Flow assignment. Reserved for the workspace-read-only flow-reviewer; managers dispatch assignments but do not perform this review themselves.
130
139
  ---
131
140
 
132
141
  # Flow Review
133
142
 
134
143
  You are the independent \`flow-reviewer\`. Review the assigned work; do not fix
135
144
  it. You may read relevant files and supplied evidence, but must not edit files,
136
- read outside the workspace, run commands, launch workers, or call any
137
- state-changing \`flow_*\` tool. Only the root manager records the result.
145
+ read outside the workspace, run commands, or launch workers. You may call only
146
+ \`flow_status\` to read this assignment and \`flow_feature_complete\` to submit its
147
+ exact result. The latter is your sole lifecycle mutation.
138
148
 
139
149
  ## Recover the assignment
140
150
 
141
151
  When given an assignment id, call only
142
152
  \`flow_status { request: { view: "reviewer", assignmentId: "..." } }\`. Use its
143
- bounded packet and approved-plan context instead of reconstructing feature,
144
- source, revision, validation, or lifecycle data from conversation memory.
153
+ bounded packet, assignment-linked validations, approved-plan context, and
154
+ completed feature IDs instead of reconstructing feature, source,
155
+ revision, validation, or lifecycle data from conversation memory.
145
156
 
146
- If the assignment or evidence required to justify a verdict is unavailable,
147
- return a failed result with a blocking evidence-gap finding. Never invent
148
- validation, identity, or time.
157
+ If the reviewer projection is available but evidence required to justify a
158
+ verdict is missing, submit a failed result with a blocking evidence-gap finding.
159
+ If the assignment itself is unavailable, report that failure without another
160
+ state change so the manager can inspect compact status. Never invent validation,
161
+ identity, revision, or time.
149
162
 
150
163
  ## Review
151
164
 
@@ -160,6 +173,12 @@ the manager's summary. Check that:
160
173
  - persistence, concurrency, security, migration, package, UI, and recovery
161
174
  risks were examined when relevant.
162
175
 
176
+ Validation scope is a claim. Treat \`broad\` as adequate only when the durable
177
+ command is the repository's canonical applicable gate or a justified equivalent
178
+ for the delivered state. Flow deliberately projects no raw command output; use
179
+ the durable command, exit code, completeness, digest, source binding, and your
180
+ workspace inspection. A weak or unclear coverage claim is an evidence gap.
181
+
163
182
  For a final assignment, also trace every approved requirement and feature to
164
183
  the delivered result, inspect broad validation, and confirm docs, commands,
165
184
  package surfaces, and remaining gaps are consistent with completion. The final
@@ -170,25 +189,40 @@ approved outcome; otherwise use \`advisory\`. Every blocker needs a precise
170
189
  summary. Every blocker must cite a changed artifact and location, or identify
171
190
  the exact missing evidence or unmet approved requirement in \`evidence\`.
172
191
 
173
- ## Return one result
192
+ ## Submit one result
174
193
 
175
- Return exactly one assignment result:
194
+ Call \`flow_feature_complete\` directly with the assignment id, current reviewer
195
+ projection revision and feature id, a fresh operation id, a concise summary,
196
+ and exactly one assignment result:
176
197
 
177
198
  \`\`\`json
178
199
  {
179
- "assignmentId": "review-assignment:runtime-id",
180
- "verdict": "passed",
181
- "findings": [],
182
- "terminalDisposition": "submitted"
200
+ "request": {
201
+ "operationId": "review-submit:fresh-id",
202
+ "expectedRevision": 12,
203
+ "featureId": "approved-feature-id",
204
+ "assignmentId": "review-assignment:runtime-id",
205
+ "summary": "Concise reviewed outcome.",
206
+ "result": {
207
+ "verdict": "passed",
208
+ "findings": [],
209
+ "terminalDisposition": "submitted"
210
+ }
211
+ }
183
212
  }
184
213
  \`\`\`
185
214
 
186
215
  Each finding contains \`severity\`, \`summary\`, and optional \`evidence\`. Use
187
- \`verdict: "failed"\` whenever any blocking finding remains. The manager copies
188
- \`verdict\`, \`findings\`, and \`terminalDisposition\` under
189
- \`flow_feature_complete.request.result\` and supplies \`assignmentId\` beside that
190
- result. Do not return or invent run ids, revisions, source hashes, validation
191
- records, timestamps, review modes, or attempt fields.
216
+ \`verdict: "failed"\` whenever any blocking finding remains. Do not return or
217
+ invent run ids, source hashes, validation records, timestamps, review modes, or
218
+ attempt fields. Never ask the manager to copy or submit your verdict.
219
+
220
+ After the tool returns, report its durable outcome concisely. If submission
221
+ fails, report the exact failure. For \`Workspace content changed after review
222
+ started\`, tell the manager to reset the feature and do not recommend redispatch;
223
+ the source-stale assignment cannot complete. After an interruption with no
224
+ accepted result, the manager may recover the pending assignment. Never
225
+ downgrade, fabricate, or hand off a result for manager submission.
192
226
 
193
227
  Approve only what you actually inspected. Missing coverage is a finding, never
194
228
  a reason to lower the bar.
@@ -202,10 +236,11 @@ description: Implement, validate, independently review, and record one approved
202
236
 
203
237
  # Flow Run
204
238
 
205
- Work on exactly one approved feature. The root manager owns the session, every
206
- state-changing \`flow_*\` call, integration, validation, review dispatch, reset,
207
- and closure. Bounded \`flow-worker\` instances may contribute disjoint work; the
208
- reserved \`flow-reviewer\` owns the independent review.
239
+ Work on exactly one approved feature. The root manager owns the session,
240
+ integration, validation, review dispatch, reset, closure, and every
241
+ lifecycle mutation except review submission. Bounded \`flow-worker\`
242
+ instances may contribute disjoint work; the reserved \`flow-reviewer\` owns the
243
+ independent review and submits its own result.
209
244
 
210
245
  ## Start and scope
211
246
 
@@ -235,26 +270,29 @@ that separate action.
235
270
 
236
271
  Work serially by default. After manager orientation, fan out only when at least
237
272
  two genuinely independent slices can be named. Run one cohort of two or three
238
- \`flow-worker\` instances at a time. Launch the cohort concurrently through
239
- OpenCode's native task/subagent facility; do not wait for one slice before
240
- launching the next. Each prompt must name a stable slice id, the exact outcome
241
- and read or write scope, expected coverage and checks, dependencies, and a stop
242
- condition. Edit scopes must be exact and non-overlapping. Shared contracts,
243
- lockfiles, and generated outputs remain manager-owned unless one worker
244
- receives the whole relevant scope.
273
+ \`flow-worker\` instances at a time. Issue every cohort Task call in the same
274
+ assistant tool-use turn before consuming any result. If the host or model
275
+ serializes those calls, treat and report that execution as serial instead of
276
+ claiming parallelism. Each prompt must name a stable slice id, the exact outcome
277
+ and read or write scope, expected coverage, recommended manager checks,
278
+ dependencies, and a stop condition. Edit scopes must be exact and
279
+ non-overlapping. Shared contracts, lockfiles, and generated outputs remain
280
+ manager-owned unless one worker receives the whole relevant scope.
245
281
 
246
282
  Workers cannot call Flow tools or spawn children. Each returns one concise
247
283
  handoff containing status, scope and coverage, evidence or changed paths,
248
- checks, gaps and risks, and integration notes. Missing, partial, or blocked
249
- output remains an explicit coverage gap; worker checks are advisory.
284
+ recommended manager checks, gaps and risks, and integration notes. Workers do
285
+ not run Bash; all executable checks remain manager-owned. Missing, partial, or
286
+ blocked output remains an explicit coverage gap.
250
287
 
251
- After all workers stop, inspect the combined diff and evidence and reconcile
288
+ After all workers stop, compare actual changed paths with every assigned scope,
289
+ then inspect the combined diff and evidence and reconcile unexpected paths or
252
290
  conflicts before validation. At most one targeted follow-up wave may address a
253
- failed slice, newly unlocked dependency, or material claim verification. Do not
254
- start an automatic third wave. Coordination stays in the conversation: create
255
- no manifest, sidecar, Session field, durable handoff, or recovery ledger. After
256
- an interruption, inspect Flow status and the worktree and treat partial worker
257
- edits as untrusted.
291
+ failed slice, newly unlocked dependency, or material claim verification. Do
292
+ not start an automatic third wave. Coordination stays in the conversation:
293
+ create no manifest, sidecar, Session field, durable handoff, or recovery ledger.
294
+ After an interruption, inspect Flow status and the worktree and treat partial
295
+ worker edits as untrusted.
258
296
 
259
297
  ## Validate
260
298
 
@@ -268,17 +306,25 @@ checks from the changed behavior and risk:
268
306
  sufficient alone only for genuinely mechanical or documentation-only work.
269
307
  - UI claims need visual inspection when a runnable target is available.
270
308
  - Cleanup claims need behavior-preservation evidence, not formatting alone.
309
+ - \`scope: "broad"\` is a claim about coverage, not a stronger label. Use it only
310
+ for the repository's canonical applicable gate or a justified equivalent
311
+ that covers the delivered repository state.
271
312
 
272
313
  Immediately before each Bash command used as evidence, call
273
314
  \`flow_validation_start\` with the current revision, feature id, the exact
274
315
  command, and \`scope\` (\`focused\` or \`broad\`). Run that byte-for-byte command next
275
316
  and inspect its complete outcome. Flow records the host-observed result directly
276
- in the session; do not copy host-observed fields into a later request.
317
+ in the session; do not copy host-observed fields into a later request. The exact
318
+ command is durable, so never inline tokens, passwords, credentials, or other
319
+ secrets. Raw output is deliberately neither persisted nor projected: the
320
+ durable evidence is the command, exit code, output completeness, and output
321
+ digest, while the manager must inspect the live output.
277
322
 
278
323
  Use focused validation for ordinary features. For the final feature, run the
279
324
  repository's broad applicable gate after the last relevant edit. A source edit
280
325
  invalidates earlier applicability. Failed or unavailable checks are blockers,
281
- not passing evidence.
326
+ not passing evidence. If the canonical gate cannot run, explain why the chosen
327
+ equivalent is broad enough; otherwise record the narrower evidence as focused.
282
328
 
283
329
  ## Review and record
284
330
 
@@ -292,9 +338,19 @@ automatically and derives \`feature\` versus \`final\` review from plan progress
292
338
  callers do not supply the review kind.
293
339
 
294
340
  Dispatch the returned assignment only to the reserved \`flow-reviewer\`. Do not
295
- perform the independent review in manager context. Submit its exact assignment
296
- result through \`flow_feature_complete\` with fresh guards. A pass completes the
297
- feature; a blocking finding records a blocked outcome.
341
+ perform the independent review in manager context and never copy or submit its
342
+ verdict. The reviewer reads its assignment, inspects the workspace, and calls
343
+ \`flow_feature_complete\` directly; the runtime verifies the calling agent. The
344
+ reviewer remains workspace-read-only and may make only this exact result
345
+ submission as its sole lifecycle mutation.
346
+
347
+ After the reviewer returns, read compact status rather than treating prose as
348
+ the outcome. Redispatch the same pending assignment after interruption or an
349
+ unconfirmed reviewer return. If submission reports \`Workspace content changed
350
+ after review started\`, call \`flow_feature_reset\` and do not redispatch that
351
+ source-stale assignment; start a fresh run and repeat full validation and
352
+ review. Never fabricate a verdict. A submitted pass completes the feature; a
353
+ submitted blocking finding records a blocked outcome.
298
354
 
299
355
  If repair is authorized after a failed review, reset the feature, fix it, and
300
356
  repeat full validation and full review in a fresh run.
@@ -348,9 +404,11 @@ You may run concurrently with sibling workers. Do not enter their scopes, assume
348
404
  - Use the manager assignment as your only source of Flow lifecycle context. Do not call any \`flow_*\` tool, including \`flow_status\`.
349
405
  - Do not delegate, spawn subtasks, or load skills.
350
406
  - Do not stage, commit, push, publish, or create a release.
407
+ - Do not run Bash commands. The manager owns every executable check.
408
+ - Never edit .flow or .git metadata paths; the host denies those paths.
351
409
  - A read-only evidence slice must not edit files.
352
410
  - An implementation slice may edit only the exact, non-overlapping write paths explicitly assigned by the manager. If required work would escape those paths, stop and return a partial or blocked handoff instead of expanding scope.
353
- - Run only checks relevant to the assigned slice. These checks are advisory: the manager owns integration and performs authoritative combined validation after all workers have stopped.
411
+ - Use only non-shell inspection relevant to the assigned slice. The manager owns integration, focused checks, and authoritative combined validation after all workers have stopped.
354
412
 
355
413
  ## Handoff
356
414
 
@@ -365,8 +423,8 @@ success | partial | blocked
365
423
  ## Findings / changed paths
366
424
  - Evidence found or exact paths changed
367
425
 
368
- ## Checks
369
- - Commands run and outcomes, or not run with reason
426
+ ## Recommended manager checks
427
+ - Exact checks the manager should run, or none
370
428
 
371
429
  ## Gaps & risks
372
430
  - Missing coverage, blockers, conflicts, or none
@@ -410,7 +468,7 @@ function compileFlowPromptSurface(surface) {
410
468
  return [
411
469
  "# Flow review command",
412
470
  "",
413
- "Run this assignment only as the reserved `flow-reviewer`. The reviewer is independent and read-only; it must not edit files or mutate Flow state.",
471
+ "Run this assignment only as the reserved `flow-reviewer`. The reviewer is independent and workspace-read-only; it may read reviewer status and submit only its own result through `flow_feature_complete`.",
414
472
  "",
415
473
  "Assignment: $ARGUMENTS"
416
474
  ].join(`
@@ -431,7 +489,7 @@ var FLOW_CORE_AGENTS = {
431
489
  "flow-reviewer": {
432
490
  mode: "subagent",
433
491
  hidden: true,
434
- description: "Independent read-only reviewer for one Flow assignment.",
492
+ description: "Independent workspace-read-only reviewer that submits one Flow result.",
435
493
  prompt: compileFlowPromptSurface("flow-reviewer"),
436
494
  permission: {
437
495
  edit: "deny",
@@ -440,7 +498,8 @@ var FLOW_CORE_AGENTS = {
440
498
  skill: "deny",
441
499
  task: { "*": "deny" },
442
500
  "flow_*": "deny",
443
- flow_status: "allow"
501
+ flow_status: "allow",
502
+ flow_feature_complete: "allow"
444
503
  }
445
504
  },
446
505
  "flow-worker": {
@@ -449,8 +508,14 @@ var FLOW_CORE_AGENTS = {
449
508
  description: "Bounded worker for one read-only evidence or exact-scope implementation slice.",
450
509
  prompt: compileFlowPromptSurface("flow-worker"),
451
510
  permission: {
452
- edit: "ask",
453
- bash: "ask",
511
+ edit: {
512
+ "*": "allow",
513
+ ".flow": "deny",
514
+ ".flow/**": "deny",
515
+ ".git": "deny",
516
+ ".git/**": "deny"
517
+ },
518
+ bash: "deny",
454
519
  external_directory: "deny",
455
520
  skill: "deny",
456
521
  task: { "*": "deny" },
@@ -475,7 +540,7 @@ var FLOW_CORE_COMMANDS = {
475
540
  template: compileFlowPromptSurface("flow-run")
476
541
  },
477
542
  "flow-review": {
478
- description: "Run one independent read-only Flow review",
543
+ description: "Run one independent workspace-read-only Flow review",
479
544
  agent: "flow-reviewer",
480
545
  subtask: true,
481
546
  template: compileFlowPromptSurface("flow-review")
@@ -695,6 +760,39 @@ function planIssue(plan) {
695
760
  return visited === plan.features.length ? null : "The plan dependency graph is cyclic.";
696
761
  }
697
762
 
763
+ // src/domain/session.ts
764
+ function reviewResultSemanticIssues(result) {
765
+ const issues = [];
766
+ const blocking = result.findings.some((finding) => finding.severity === "blocking");
767
+ for (const [index, finding] of result.findings.entries()) {
768
+ if (finding.severity === "blocking" && !finding.evidence?.trim()) {
769
+ issues.push({
770
+ path: ["findings", index, "evidence"],
771
+ message: "A blocking finding requires concrete evidence."
772
+ });
773
+ }
774
+ }
775
+ if (result.verdict === "failed" && !blocking) {
776
+ issues.push({
777
+ path: ["findings"],
778
+ message: "A failed review requires a blocking finding."
779
+ });
780
+ }
781
+ if (result.verdict === "passed" && blocking) {
782
+ issues.push({
783
+ path: ["findings"],
784
+ message: "A passed review cannot contain blocking findings."
785
+ });
786
+ }
787
+ if (result.terminalDisposition === "observed_unsubmitted" && result.verdict !== "failed") {
788
+ issues.push({
789
+ path: ["terminalDisposition"],
790
+ message: "Observed-but-unsubmitted review work must fail closed."
791
+ });
792
+ }
793
+ return issues;
794
+ }
795
+
698
796
  // src/domain/transitions.ts
699
797
  class FlowTransitionError extends Error {
700
798
  code = "FLOW_TRANSITION_REJECTED";
@@ -1030,19 +1128,9 @@ function assertReviewResult(result) {
1030
1128
  if (result.findings.length > MAX_REVIEW_FINDINGS) {
1031
1129
  fail(`A review may contain at most ${MAX_REVIEW_FINDINGS} findings.`);
1032
1130
  }
1033
- const blocking = result.findings.some((finding) => finding.severity === "blocking");
1034
- const unsupported = result.findings.some((finding) => finding.severity === "blocking" && !finding.evidence?.trim());
1035
- if (unsupported)
1036
- fail("A blocking finding requires concrete evidence.");
1037
- if (result.verdict === "failed" && !blocking) {
1038
- fail("A failed review requires a blocking finding.");
1039
- }
1040
- if (result.verdict === "passed" && blocking) {
1041
- fail("A passed review cannot contain a blocking finding.");
1042
- }
1043
- if (result.terminalDisposition === "observed_unsubmitted" && result.verdict !== "failed") {
1044
- fail("Observed-but-unsubmitted review work must fail closed.");
1045
- }
1131
+ const issue = reviewResultSemanticIssues(result)[0];
1132
+ if (issue)
1133
+ fail(issue.message);
1046
1134
  }
1047
1135
  function completeFeature(session, input) {
1048
1136
  assertReviewResult(input.result);
@@ -1419,6 +1507,7 @@ function boundedText(label, options) {
1419
1507
  }
1420
1508
  var FeatureIdSchema = z.string().max(MAX_SESSION_ID_LENGTH).regex(FEATURE_ID_PATTERN, FEATURE_ID_MESSAGE);
1421
1509
  var OperationIdSchema = z.string().min(1).max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/);
1510
+ var ReviewAssignmentIdSchema = z.string().min(1).max(256);
1422
1511
  var RevisionSchema = z.number().int().safe().nonnegative();
1423
1512
  var SourceDigestSchema = z.custom((value) => typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value), "Expected a sha256: content digest.");
1424
1513
  var PlanFeatureSchema = z.object({
@@ -1453,36 +1542,8 @@ var PublicReviewResultSchema = z.object({
1453
1542
  findings: z.array(ReviewFindingSchema).max(MAX_REVIEW_FINDINGS).default([]),
1454
1543
  terminalDisposition: z.enum(["submitted", "observed_unsubmitted"])
1455
1544
  }).strict().superRefine((result, context) => {
1456
- const blocking = result.findings.some((finding) => finding.severity === "blocking");
1457
- for (const [index, finding] of result.findings.entries()) {
1458
- if (finding.severity === "blocking" && !finding.evidence) {
1459
- context.addIssue({
1460
- code: "custom",
1461
- path: ["findings", index, "evidence"],
1462
- message: "A blocking finding requires concrete evidence."
1463
- });
1464
- }
1465
- }
1466
- if (result.verdict === "failed" && !blocking) {
1467
- context.addIssue({
1468
- code: "custom",
1469
- path: ["findings"],
1470
- message: "A failed review requires a blocking finding."
1471
- });
1472
- }
1473
- if (result.verdict === "passed" && blocking) {
1474
- context.addIssue({
1475
- code: "custom",
1476
- path: ["findings"],
1477
- message: "A passed review cannot contain blocking findings."
1478
- });
1479
- }
1480
- if (result.terminalDisposition === "observed_unsubmitted" && result.verdict !== "failed") {
1481
- context.addIssue({
1482
- code: "custom",
1483
- path: ["terminalDisposition"],
1484
- message: "Observed-but-unsubmitted review work must fail closed."
1485
- });
1545
+ for (const issue of reviewResultSemanticIssues(result)) {
1546
+ context.addIssue({ code: "custom", ...issue });
1486
1547
  }
1487
1548
  });
1488
1549
  var ValidationObservationSchema = z.object({
@@ -1502,7 +1563,7 @@ var ArtifactSchema = z.object({
1502
1563
  path: boundedText("Artifact path", { maxBytes: MAX_PATH_BYTES }).refine(isArtifactPath, ARTIFACT_PATH_MESSAGE)
1503
1564
  }).strict();
1504
1565
  var ReviewAssignmentSchema = z.object({
1505
- id: z.string().min(1).max(256),
1566
+ id: ReviewAssignmentIdSchema,
1506
1567
  operationId: OperationIdSchema,
1507
1568
  featureId: FeatureIdSchema,
1508
1569
  runId: z.string().min(1).max(256),
@@ -1600,7 +1661,7 @@ var FeatureCompleteInputSchema = z.object({
1600
1661
  request: z.object({
1601
1662
  ...guarded,
1602
1663
  featureId: FeatureIdSchema,
1603
- assignmentId: z.string().min(1).max(256),
1664
+ assignmentId: ReviewAssignmentIdSchema,
1604
1665
  summary: boundedText("Feature result summary"),
1605
1666
  result: PublicReviewResultSchema
1606
1667
  }).strict()
@@ -1631,7 +1692,7 @@ var StatusInputSchema = z.object({
1631
1692
  z.object({ view: z.literal("execution") }).strict(),
1632
1693
  z.object({
1633
1694
  view: z.literal("reviewer"),
1634
- assignmentId: z.string().min(1).max(256)
1695
+ assignmentId: ReviewAssignmentIdSchema
1635
1696
  }).strict()
1636
1697
  ])
1637
1698
  }).strict();
@@ -2301,7 +2362,7 @@ function validIdentity(value) {
2301
2362
  }
2302
2363
  function canonicalProjectId(scopeId) {
2303
2364
  if (!boundedText2(scopeId))
2304
- throw new TypeError("Flow leadership scope ID must be a non-empty path.");
2365
+ throw new TypeError("Flow runtime scope must be a non-empty path.");
2305
2366
  const projectId = resolve2(scopeId);
2306
2367
  try {
2307
2368
  return realpathSync2(projectId);
@@ -2349,17 +2410,11 @@ function acquireRegistry() {
2349
2410
  function snapshot(identity) {
2350
2411
  return Object.freeze({ ...identity });
2351
2412
  }
2352
- function makeStatus(identity, reason, registrations = [], registered = false) {
2413
+ function makeStatus(reason) {
2353
2414
  const operational = reason === "sole-instance";
2354
2415
  return Object.freeze({
2355
- instanceId: identity.instanceId,
2356
- registered,
2357
2416
  operational,
2358
- role: operational ? "leader" : registered || reason === "incompatible-registry" ? "indeterminate" : "unregistered",
2359
2417
  reason,
2360
- registeredCount: reason === "incompatible-registry" && !registered ? null : registrations.length,
2361
- diagnosticLeader: operational ? identity : null,
2362
- registrations: Object.freeze([...registrations]),
2363
2418
  message: operational ? "Flow is active for this project." : `Flow is not operational (${reason}).`
2364
2419
  });
2365
2420
  }
@@ -2370,7 +2425,7 @@ var createFlowPluginInstanceId = () => globalThis.crypto.randomUUID();
2370
2425
  function registerFlowPluginInstance(scopeId, input) {
2371
2426
  const projectId = canonicalProjectId(scopeId);
2372
2427
  if (!validIdentity(input)) {
2373
- throw new TypeError("Flow leadership identity is invalid.");
2428
+ throw new TypeError("Flow runtime identity is invalid.");
2374
2429
  }
2375
2430
  const identity = snapshot(input);
2376
2431
  const registry = acquireRegistry();
@@ -2383,7 +2438,7 @@ function registerFlowPluginInstance(scopeId, input) {
2383
2438
  }
2384
2439
  const existing = project.get(identity.instanceId);
2385
2440
  if (existing && !sameIdentity(existing, identity)) {
2386
- throw new Error(`Flow leadership instance ID '${identity.instanceId}' is already registered with different identity data.`);
2441
+ throw new Error(`Flow runtime instance ID '${identity.instanceId}' is already registered with different identity data.`);
2387
2442
  }
2388
2443
  record = existing ?? identity;
2389
2444
  project.set(identity.instanceId, record);
@@ -2391,32 +2446,28 @@ function registerFlowPluginInstance(scopeId, input) {
2391
2446
  let released = false;
2392
2447
  const query = () => {
2393
2448
  if (released)
2394
- return makeStatus(identity, "released");
2449
+ return makeStatus("released");
2395
2450
  const currentRegistry = readRegistry();
2396
2451
  if (!record)
2397
- return makeStatus(identity, "incompatible-registry");
2452
+ return makeStatus("incompatible-registry");
2398
2453
  if (currentRegistry === undefined)
2399
- return makeStatus(identity, "not-registered");
2454
+ return makeStatus("not-registered");
2400
2455
  if (!compatibleRegistry(currentRegistry))
2401
- return makeStatus(identity, "incompatible-registry");
2456
+ return makeStatus("incompatible-registry");
2402
2457
  const project = currentRegistry.projects.get(projectId);
2403
- const registrations = project ? [...project.values()].map(snapshot) : [];
2404
- if (project?.get(identity.instanceId) !== record) {
2405
- return makeStatus(identity, "not-registered", registrations);
2458
+ if (!project || project.get(identity.instanceId) !== record) {
2459
+ return makeStatus("not-registered");
2406
2460
  }
2407
2461
  if (identity.protocolVersion !== FLOW_LEADERSHIP_PROTOCOL_VERSION) {
2408
- return makeStatus(identity, "incompatible-registry", registrations, true);
2462
+ return makeStatus("incompatible-registry");
2409
2463
  }
2410
- if (registrations.length !== 1) {
2411
- return makeStatus(identity, "duplicate-instances", registrations, true);
2464
+ if (project.size !== 1) {
2465
+ return makeStatus("duplicate-instances");
2412
2466
  }
2413
- return makeStatus(identity, "sole-instance", registrations, true);
2467
+ return makeStatus("sole-instance");
2414
2468
  };
2415
2469
  return Object.freeze({
2416
- identity,
2417
- scopeId: projectId,
2418
2470
  query,
2419
- isOperational: () => query().operational,
2420
2471
  assertOperational(action) {
2421
2472
  const current = query();
2422
2473
  if (!current.operational) {
@@ -2476,7 +2527,10 @@ function featureProgress(session) {
2476
2527
  const completed = session.plan?.features.filter((feature) => isFeatureComplete(session, feature.id)).length ?? 0;
2477
2528
  return { completed, total, remaining: total - completed };
2478
2529
  }
2479
- function nextAction(session) {
2530
+ function activePendingReview(session) {
2531
+ return activeRun(session)?.reviews.find((review) => review.result === null) ?? null;
2532
+ }
2533
+ function nextAction(session, pendingReviewSourceStale = false) {
2480
2534
  const status = sessionStatus(session);
2481
2535
  if (status === "planning") {
2482
2536
  return session.plan ? "flow_plan_approve" : "flow_plan_save";
@@ -2492,16 +2546,16 @@ function nextAction(session) {
2492
2546
  const run = activeRun(session);
2493
2547
  if (!run)
2494
2548
  return "flow_status";
2495
- const pending = run.reviews.find((review) => review.result === null);
2496
- if (pending)
2497
- return "dispatch-flow-reviewer";
2549
+ if (activePendingReview(session)) {
2550
+ return pendingReviewSourceStale ? "flow_feature_reset" : "dispatch-flow-reviewer";
2551
+ }
2498
2552
  const finalRun = session.plan?.features.every((feature) => feature.id === run.featureId || isFeatureComplete(session, feature.id)) ?? false;
2499
2553
  const hasPassingValidation = run.validations.some((validation) => validation.exitCode === 0 && validation.outputComplete && (!finalRun || validation.scope === "broad"));
2500
2554
  if (!hasPassingValidation)
2501
2555
  return "flow_validation_start";
2502
2556
  return "flow_review_start";
2503
2557
  }
2504
- function compactProjection(session) {
2558
+ function compactProjection(session, pendingReviewSourceStale = false) {
2505
2559
  const run = activeRun(session);
2506
2560
  const retryRequest = closureRetryRequest(session);
2507
2561
  if (session.closure && !retryRequest) {
@@ -2516,7 +2570,7 @@ function compactProjection(session) {
2516
2570
  activeFeatureId: run?.featureId ?? null,
2517
2571
  activeRunId: run?.id ?? null,
2518
2572
  progress: featureProgress(session),
2519
- nextAction: nextAction(session),
2573
+ nextAction: nextAction(session, pendingReviewSourceStale),
2520
2574
  archiveRetry: retryRequest ? { request: retryRequest } : null
2521
2575
  };
2522
2576
  }
@@ -2528,11 +2582,11 @@ function archivedProjection(session) {
2528
2582
  archived: true
2529
2583
  };
2530
2584
  }
2531
- function executionProjection(session) {
2585
+ function executionProjection(session, pendingReviewSourceStale = false) {
2532
2586
  const run = activeRun(session);
2533
2587
  const feature = session.plan?.features.find((item) => item.id === run?.featureId);
2534
2588
  return {
2535
- ...compactProjection(session),
2589
+ ...compactProjection(session, pendingReviewSourceStale),
2536
2590
  view: "execution",
2537
2591
  goal: session.goal,
2538
2592
  feature: feature ?? null,
@@ -2557,6 +2611,7 @@ function reviewerProjection(session, assignmentId) {
2557
2611
  }
2558
2612
  const feature = session.plan?.features.find((item) => item.id === assignment?.featureId);
2559
2613
  const plan = session.plan;
2614
+ const assignedValidationIds = new Set(assignment.validationIds);
2560
2615
  return {
2561
2616
  view: "reviewer",
2562
2617
  sessionId: session.id,
@@ -2571,26 +2626,30 @@ function reviewerProjection(session, assignmentId) {
2571
2626
  id: candidate.id,
2572
2627
  title: candidate.title,
2573
2628
  summary: candidate.summary,
2629
+ targets: [...candidate.targets],
2630
+ validation: [...candidate.validation],
2574
2631
  dependsOn: [...candidate.dependsOn]
2575
2632
  }))
2576
2633
  } : null,
2577
2634
  feature: feature ?? null,
2578
2635
  assignment,
2579
2636
  artifactsChanged: run.artifactsChanged,
2580
- validations: run.validations,
2637
+ validations: run.validations.filter((validation) => assignedValidationIds.has(validation.id)),
2581
2638
  completedFeatureIds: plan?.features.filter((candidate) => isFeatureComplete(session, candidate.id)).map((candidate) => candidate.id) ?? []
2582
2639
  };
2583
2640
  }
2584
- function project(session, request) {
2585
- if (request.view === "compact")
2586
- return compactProjection(session);
2587
- if (request.view === "execution")
2588
- return executionProjection(session);
2641
+ function project(session, request, pendingReviewSourceStale = false) {
2642
+ if (request.view === "compact") {
2643
+ return compactProjection(session, pendingReviewSourceStale);
2644
+ }
2645
+ if (request.view === "execution") {
2646
+ return executionProjection(session, pendingReviewSourceStale);
2647
+ }
2589
2648
  if (request.view === "reviewer") {
2590
2649
  return reviewerProjection(session, request.assignmentId);
2591
2650
  }
2592
2651
  return {
2593
- ...compactProjection(session),
2652
+ ...compactProjection(session, pendingReviewSourceStale),
2594
2653
  view: "detail",
2595
2654
  goal: session.goal,
2596
2655
  plan: session.plan,
@@ -2607,6 +2666,12 @@ function operationResult(session, operationId, replayed, entity) {
2607
2666
  ...entity === undefined ? {} : { entity }
2608
2667
  };
2609
2668
  }
2669
+ function featureCompleteResponse(session, request, run, replayed) {
2670
+ return ok(request.result.verdict === "passed" ? "Feature completed." : "Feature blocked by review.", {
2671
+ operation: operationResult(session, request.operationId, replayed, run),
2672
+ projection: compactProjection(session)
2673
+ });
2674
+ }
2610
2675
  async function loadExactArchivedClose(transaction, request) {
2611
2676
  const archived = await transaction.loadArchive(request.sessionId);
2612
2677
  const operation = archived?.operations.find((item) => item.id === request.operationId);
@@ -2664,6 +2729,33 @@ function createFlowService(repository, environment) {
2664
2729
  }
2665
2730
  });
2666
2731
  }
2732
+ if (request.view !== "reviewer" && activePendingReview(session)) {
2733
+ return await repository.transact(async (transaction) => {
2734
+ const current = await transaction.load();
2735
+ if (!current) {
2736
+ return ok("No active Flow session.", {
2737
+ projection: {
2738
+ view: request.view,
2739
+ status: "idle",
2740
+ revision: 0,
2741
+ nextAction: "flow_plan_save"
2742
+ }
2743
+ });
2744
+ }
2745
+ const pending = activePendingReview(current);
2746
+ let pendingReviewSourceStale = false;
2747
+ if (pending) {
2748
+ try {
2749
+ pendingReviewSourceStale = await transaction.computeSourceDigest() !== pending.sourceDigest;
2750
+ } catch (error) {
2751
+ return errorResponse(error, "Repair workspace fingerprinting before recovering this pending review. Do not redispatch the assignment until its source can be checked.");
2752
+ }
2753
+ }
2754
+ return ok("Flow status loaded.", {
2755
+ projection: project(current, request, pendingReviewSourceStale)
2756
+ });
2757
+ });
2758
+ }
2667
2759
  return ok("Flow status loaded.", {
2668
2760
  projection: project(session, request)
2669
2761
  });
@@ -2774,20 +2866,36 @@ function createFlowService(repository, environment) {
2774
2866
  if (!assignment)
2775
2867
  throw new Error("Unknown review assignment.");
2776
2868
  if (await transaction.computeSourceDigest() !== assignment.sourceDigest) {
2777
- throw new Error("Workspace content changed after review started; reset and rerun validation and review.");
2869
+ return errorResponse(new Error("Workspace content changed after review started."), "Call flow_feature_reset, start a fresh run, and repeat full validation and review. Do not redispatch this source-stale assignment.");
2778
2870
  }
2779
2871
  }
2780
2872
  const result = completeFeature(session, request);
2781
2873
  await transaction.save(result.session);
2782
- return ok(request.result.verdict === "passed" ? "Feature completed." : "Feature blocked by review.", {
2783
- operation: operationResult(result.session, request.operationId, result.replayed, result.value),
2784
- projection: compactProjection(result.session)
2785
- });
2874
+ return featureCompleteResponse(result.session, request, result.value, result.replayed);
2786
2875
  });
2787
2876
  } catch (error) {
2788
2877
  return errorResponse(error);
2789
2878
  }
2790
2879
  },
2880
+ async featureCompleteReplay(input) {
2881
+ try {
2882
+ const request = FeatureCompleteInputSchema.parse(input).request;
2883
+ const session = await repository.read();
2884
+ if (!session)
2885
+ throw new Error("No active Flow session exists.");
2886
+ const priorOperation = session.operations.find((operation) => operation.id === request.operationId);
2887
+ if (priorOperation?.kind !== "feature-complete" || priorOperation.inputDigest !== operationInputDigest(request)) {
2888
+ throw new Error("Only the Flow reviewer may submit a new feature completion; other agents may replay only an exact previously accepted request.");
2889
+ }
2890
+ const result = completeFeature(session, request);
2891
+ if (!result.replayed) {
2892
+ throw new Error("Expected an exact feature-completion replay.");
2893
+ }
2894
+ return featureCompleteResponse(result.session, request, result.value, true);
2895
+ } catch (error) {
2896
+ return errorResponse(error);
2897
+ }
2898
+ },
2791
2899
  async featureReset(input) {
2792
2900
  try {
2793
2901
  const request = FeatureResetInputSchema.parse(input).request;
@@ -2854,6 +2962,7 @@ var flowPlanApprove = (workspace, input) => service(workspace).planApprove(input
2854
2962
  var flowRunStart = (workspace, input) => service(workspace).runStart(input);
2855
2963
  var flowReviewStart = (workspace, input) => service(workspace).reviewStart(input);
2856
2964
  var flowFeatureComplete = (workspace, input) => service(workspace).featureComplete(input);
2965
+ var flowFeatureCompleteReplay = (workspace, input) => service(workspace).featureCompleteReplay(input);
2857
2966
  var flowFeatureReset = (workspace, input) => service(workspace).featureReset(input);
2858
2967
  var flowSessionClose = (workspace, input) => service(workspace).sessionClose(input);
2859
2968
 
@@ -2867,6 +2976,7 @@ function boundedHostText(label, options) {
2867
2976
  var text = boundedHostText("Text");
2868
2977
  var featureId = host.string().max(MAX_SESSION_ID_LENGTH).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
2869
2978
  var operationId = host.string().min(1).max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/);
2979
+ var reviewAssignmentId = host.string().min(1).max(256);
2870
2980
  var revision = host.number().int().safe().nonnegative();
2871
2981
  var guard = { operationId, expectedRevision: revision };
2872
2982
  var artifact = host.object({
@@ -2904,36 +3014,8 @@ var reviewResult = host.object({
2904
3014
  findings: host.array(reviewFinding).max(MAX_REVIEW_FINDINGS).default([]),
2905
3015
  terminalDisposition: host.enum(["submitted", "observed_unsubmitted"])
2906
3016
  }).strict().superRefine((result, context) => {
2907
- const blocking = result.findings.some((finding) => finding.severity === "blocking");
2908
- for (const [index, finding] of result.findings.entries()) {
2909
- if (finding.severity === "blocking" && !finding.evidence) {
2910
- context.addIssue({
2911
- code: "custom",
2912
- path: ["findings", index, "evidence"],
2913
- message: "A blocking finding requires concrete evidence."
2914
- });
2915
- }
2916
- }
2917
- if (result.verdict === "failed" && !blocking) {
2918
- context.addIssue({
2919
- code: "custom",
2920
- path: ["findings"],
2921
- message: "A failed review requires a blocking finding."
2922
- });
2923
- }
2924
- if (result.verdict === "passed" && blocking) {
2925
- context.addIssue({
2926
- code: "custom",
2927
- path: ["findings"],
2928
- message: "A passed review cannot contain blocking findings."
2929
- });
2930
- }
2931
- if (result.terminalDisposition === "observed_unsubmitted" && result.verdict !== "failed") {
2932
- context.addIssue({
2933
- code: "custom",
2934
- path: ["terminalDisposition"],
2935
- message: "Observed-but-unsubmitted review work must fail closed."
2936
- });
3017
+ for (const issue of reviewResultSemanticIssues(result)) {
3018
+ context.addIssue({ code: "custom", ...issue });
2937
3019
  }
2938
3020
  });
2939
3021
  var StatusArgs = {
@@ -2941,7 +3023,10 @@ var StatusArgs = {
2941
3023
  host.object({ view: host.literal("compact") }).strict(),
2942
3024
  host.object({ view: host.literal("detail") }).strict(),
2943
3025
  host.object({ view: host.literal("execution") }).strict(),
2944
- host.object({ view: host.literal("reviewer"), assignmentId: text.max(256) }).strict()
3026
+ host.object({
3027
+ view: host.literal("reviewer"),
3028
+ assignmentId: reviewAssignmentId
3029
+ }).strict()
2945
3030
  ])
2946
3031
  };
2947
3032
  var PlanSaveArgs = {
@@ -2974,7 +3059,7 @@ var FeatureCompleteArgs = {
2974
3059
  request: host.object({
2975
3060
  ...guard,
2976
3061
  featureId,
2977
- assignmentId: text.max(256),
3062
+ assignmentId: reviewAssignmentId,
2978
3063
  summary: text,
2979
3064
  result: reviewResult
2980
3065
  }).strict()
@@ -3016,6 +3101,12 @@ function executeMutation(context, validation, handler) {
3016
3101
  validation.cancel(context.sessionID);
3017
3102
  return execute(context, handler);
3018
3103
  }
3104
+ function executeReviewerMutation(context, validation, handler, replayHandler) {
3105
+ if (context.agent !== "flow-reviewer") {
3106
+ return execute(context, replayHandler);
3107
+ }
3108
+ return executeMutation(context, validation, handler);
3109
+ }
3019
3110
  function createTools(_ctx, options) {
3020
3111
  return {
3021
3112
  flow_guidance: tool({
@@ -3071,9 +3162,9 @@ function createTools(_ctx, options) {
3071
3162
  execute: (args, context) => executeMutation(context, options.validation, (workspace) => flowReviewStart(workspace, args))
3072
3163
  }),
3073
3164
  flow_feature_complete: tool({
3074
- description: "Atomically record the pending review result and feature outcome.",
3165
+ description: "Submit a pending review result; only the reviewer may create a new completion, while exact accepted requests remain replayable.",
3075
3166
  args: FeatureCompleteArgs,
3076
- execute: (args, context) => executeMutation(context, options.validation, (workspace) => flowFeatureComplete(workspace, args))
3167
+ execute: (args, context) => executeReviewerMutation(context, options.validation, (workspace) => flowFeatureComplete(workspace, args), (workspace) => flowFeatureCompleteReplay(workspace, args))
3077
3168
  }),
3078
3169
  flow_feature_reset: tool({
3079
3170
  description: "Reset a feature and dependents for a fresh full retry.",
@@ -3134,7 +3225,7 @@ class ValidationCaptureCoordinator {
3134
3225
  #prune() {
3135
3226
  const cutoff = this.#now() - CAPTURE_TTL_MS;
3136
3227
  for (const [sessionID, capture] of this.#pending) {
3137
- if (capture.armedAt < cutoff) {
3228
+ if (capture.callID === null && capture.armedAt < cutoff) {
3138
3229
  this.#pending.delete(sessionID);
3139
3230
  }
3140
3231
  }
@@ -3263,17 +3354,18 @@ function createCommandHook(assertOperational) {
3263
3354
  }
3264
3355
  };
3265
3356
  }
3266
- function guardTools(tools, leadership) {
3357
+ function guardTools(tools, runtimeGuard) {
3267
3358
  return Object.fromEntries(Object.entries(tools).map(([name, definition]) => [
3268
3359
  name,
3269
3360
  {
3270
3361
  ...definition,
3271
3362
  execute: async (...args) => {
3272
- if (!leadership.isOperational()) {
3363
+ const status = runtimeGuard.query();
3364
+ if (!status.operational) {
3273
3365
  return JSON.stringify({
3274
3366
  status: "error",
3275
- summary: "Flow is disabled because more than one runtime is registered for this project.",
3276
- workflowData: { runtimeGuard: leadership.query() }
3367
+ summary: status.message,
3368
+ workflowData: { runtimeGuard: status }
3277
3369
  });
3278
3370
  }
3279
3371
  return definition.execute(...args);
@@ -3284,13 +3376,13 @@ function guardTools(tools, leadership) {
3284
3376
  var FlowPlugin = async (ctx) => {
3285
3377
  const log = createFlowLog(ctx);
3286
3378
  const version = resolveFlowPluginVersion();
3287
- const leadership = registerFlowPluginInstance(ctx.worktree ?? ctx.directory, {
3379
+ const runtimeGuard = registerFlowPluginInstance(ctx.worktree ?? ctx.directory, {
3288
3380
  packageName: "opencode-plugin-flow",
3289
3381
  version,
3290
3382
  protocolVersion: FLOW_LEADERSHIP_PROTOCOL_VERSION,
3291
3383
  instanceId: createFlowPluginInstanceId()
3292
3384
  });
3293
- const initial = leadership.query();
3385
+ const initial = runtimeGuard.query();
3294
3386
  log(initial.operational ? "info" : "error", `Flow ${version}: ${initial.message}`);
3295
3387
  const validation = new ValidationCaptureCoordinator({
3296
3388
  persistObservation: persistWorkspaceValidation
@@ -3301,10 +3393,10 @@ var FlowPlugin = async (ctx) => {
3301
3393
  });
3302
3394
  return {
3303
3395
  config: createConfigHook(ctx, {
3304
- assertOperational: (action) => leadership.assertOperational(action)
3396
+ assertOperational: (action) => runtimeGuard.assertOperational(action)
3305
3397
  }),
3306
- tool: guardTools(tools, leadership),
3307
- "command.execute.before": createCommandHook((action) => leadership.assertOperational(action)),
3398
+ tool: guardTools(tools, runtimeGuard),
3399
+ "command.execute.before": createCommandHook((action) => runtimeGuard.assertOperational(action)),
3308
3400
  event: async (input) => {
3309
3401
  const event = input.event;
3310
3402
  if (event.type !== "session.idle" && event.type !== "session.compacted") {
@@ -3329,7 +3421,7 @@ var FlowPlugin = async (ctx) => {
3329
3421
  }
3330
3422
  },
3331
3423
  dispose: async () => {
3332
- leadership.release();
3424
+ runtimeGuard.release();
3333
3425
  }
3334
3426
  };
3335
3427
  };
@@ -3338,4 +3430,4 @@ export {
3338
3430
  plugin_default as default
3339
3431
  };
3340
3432
 
3341
- //# debugId=D206226F320FFC7564756E2164756E21
3433
+ //# debugId=E5E0649B3D35337264756E2164756E21