opencode-plugin-flow 6.0.0 → 6.2.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,8 +7,10 @@ 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 and every state-changing \`flow_*\` call. The reserved
11
- \`flow-reviewer\` independently reviews; it never edits or mutates Flow state.
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.
12
14
 
13
15
  ## Route from status
14
16
 
@@ -32,7 +34,8 @@ report that the plugin is not fully loaded instead of simulating state changes.
32
34
 
33
35
  - Approved plans do not change. Reset affected work or close the session before
34
36
  changing direction.
35
- - Only one feature runs at a time.
37
+ - Only one durable feature run is active at a time. Conversation-local worker
38
+ waves do not create additional runs or Flow state.
36
39
  - Work stays inside the active feature and preserves unrelated user changes.
37
40
  - A passing feature needs successful current-source validation and one
38
41
  independent reviewer assignment. The final feature uses broad validation and
@@ -199,9 +202,10 @@ description: Implement, validate, independently review, and record one approved
199
202
 
200
203
  # Flow Run
201
204
 
202
- Work on exactly one approved feature. The root manager owns edits and every
203
- state-changing \`flow_*\` call; the reserved \`flow-reviewer\` owns the independent
204
- review.
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.
205
209
 
206
210
  ## Start and scope
207
211
 
@@ -227,9 +231,38 @@ normally be one stable Markdown artifact; JSON requires an explicit request.
227
231
  Do not stage, commit, push, publish, or mutate releases unless the user asks for
228
232
  that separate action.
229
233
 
234
+ ## Bounded worker waves
235
+
236
+ Work serially by default. After manager orientation, fan out only when at least
237
+ two genuinely independent slices can be named. Run one cohort of two or three
238
+ \`flow-worker\` instances at a time. Issue every cohort Task call in the same
239
+ assistant tool-use turn before consuming any result. If the host or model
240
+ serializes those calls, treat and report that execution as serial instead of
241
+ claiming parallelism. Each prompt must name a stable slice id, the exact outcome
242
+ and read or write scope, expected coverage, recommended manager checks,
243
+ dependencies, and a stop condition. Edit scopes must be exact and
244
+ non-overlapping. Shared contracts, lockfiles, and generated outputs remain
245
+ manager-owned unless one worker receives the whole relevant scope.
246
+
247
+ Workers cannot call Flow tools or spawn children. Each returns one concise
248
+ handoff containing status, scope and coverage, evidence or changed paths,
249
+ recommended manager checks, gaps and risks, and integration notes. Workers do
250
+ not run Bash; all executable checks remain manager-owned. Missing, partial, or
251
+ blocked output remains an explicit coverage gap.
252
+
253
+ After all workers stop, compare actual changed paths with every assigned scope,
254
+ then inspect the combined diff and evidence and reconcile unexpected paths or
255
+ conflicts before validation. At most one targeted follow-up wave may address a
256
+ failed slice, newly unlocked dependency, or material claim verification. Do
257
+ not start an automatic third wave. Coordination stays in the conversation:
258
+ create no manifest, sidecar, Session field, durable handoff, or recovery ledger.
259
+ After an interruption, inspect Flow status and the worktree and treat partial
260
+ worker edits as untrusted.
261
+
230
262
  ## Validate
231
263
 
232
- Choose checks from the changed behavior and risk:
264
+ Only validate after every worker has stopped and integration is settled. Choose
265
+ checks from the changed behavior and risk:
233
266
 
234
267
  - Prefer focused behavioral tests that would fail without the change.
235
268
  - Cover persistence, integration, API, browser, accessibility, package, or
@@ -307,6 +340,44 @@ if (FLOW_GUIDANCE_BY_ID.size !== FLOW_GUIDANCE_IDS.length) {
307
340
  }
308
341
 
309
342
  // src/prompt-surfaces.ts
343
+ var FLOW_WORKER_PROMPT = `# Flow bounded worker
344
+
345
+ You are one hidden Flow worker supporting the root manager inside one active feature. Own only the single slice explicitly assigned by that manager. Preserve all unrelated work and do not broaden the assignment.
346
+
347
+ You may run concurrently with sibling workers. Do not enter their scopes, assume their results, or revert changes you did not make.
348
+
349
+ ## Scope and authority
350
+
351
+ - Use the manager assignment as your only source of Flow lifecycle context. Do not call any \`flow_*\` tool, including \`flow_status\`.
352
+ - Do not delegate, spawn subtasks, or load skills.
353
+ - Do not stage, commit, push, publish, or create a release.
354
+ - Do not run Bash commands. The manager owns every executable check.
355
+ - Never edit .flow or .git metadata paths; the host denies those paths.
356
+ - A read-only evidence slice must not edit files.
357
+ - 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.
358
+ - 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.
359
+
360
+ ## Handoff
361
+
362
+ Return exactly one concise handoff using this structure:
363
+
364
+ ## Status
365
+ success | partial | blocked
366
+
367
+ ## Scope & coverage
368
+ - Assigned slice and what was covered
369
+
370
+ ## Findings / changed paths
371
+ - Evidence found or exact paths changed
372
+
373
+ ## Recommended manager checks
374
+ - Exact checks the manager should run, or none
375
+
376
+ ## Gaps & risks
377
+ - Missing coverage, blockers, conflicts, or none
378
+
379
+ ## Integration notes
380
+ - What the manager must verify or integrate, or none`;
310
381
  function skillBody(id) {
311
382
  return getFlowGuidance(id).content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim();
312
383
  }
@@ -351,6 +422,8 @@ function compileFlowPromptSurface(surface) {
351
422
  `);
352
423
  case "flow-reviewer":
353
424
  return skillBody("flow-review");
425
+ case "flow-worker":
426
+ return FLOW_WORKER_PROMPT;
354
427
  default: {
355
428
  const unsupported = surface;
356
429
  throw new Error(`Unsupported Flow prompt surface '${unsupported}'.`);
@@ -374,6 +447,26 @@ var FLOW_CORE_AGENTS = {
374
447
  "flow_*": "deny",
375
448
  flow_status: "allow"
376
449
  }
450
+ },
451
+ "flow-worker": {
452
+ mode: "subagent",
453
+ hidden: true,
454
+ description: "Bounded worker for one read-only evidence or exact-scope implementation slice.",
455
+ prompt: compileFlowPromptSurface("flow-worker"),
456
+ permission: {
457
+ edit: {
458
+ "*": "allow",
459
+ ".flow": "deny",
460
+ ".flow/**": "deny",
461
+ ".git": "deny",
462
+ ".git/**": "deny"
463
+ },
464
+ bash: "deny",
465
+ external_directory: "deny",
466
+ skill: "deny",
467
+ task: { "*": "deny" },
468
+ "flow_*": "deny"
469
+ }
377
470
  }
378
471
  };
379
472
  var FLOW_CORE_COMMANDS = {
@@ -432,6 +525,13 @@ function createFlowCoreConfigEntries(options) {
432
525
  ...FLOW_CORE_AGENTS["flow-reviewer"].permission,
433
526
  task: { "*": "deny" }
434
527
  }
528
+ },
529
+ "flow-worker": {
530
+ ...FLOW_CORE_AGENTS["flow-worker"],
531
+ permission: {
532
+ ...FLOW_CORE_AGENTS["flow-worker"].permission,
533
+ task: { "*": "deny" }
534
+ }
435
535
  }
436
536
  },
437
537
  command: Object.fromEntries(Object.entries(FLOW_CORE_COMMANDS).map(([name, value]) => [
@@ -606,6 +706,39 @@ function planIssue(plan) {
606
706
  return visited === plan.features.length ? null : "The plan dependency graph is cyclic.";
607
707
  }
608
708
 
709
+ // src/domain/session.ts
710
+ function reviewResultSemanticIssues(result) {
711
+ const issues = [];
712
+ const blocking = result.findings.some((finding) => finding.severity === "blocking");
713
+ for (const [index, finding] of result.findings.entries()) {
714
+ if (finding.severity === "blocking" && !finding.evidence?.trim()) {
715
+ issues.push({
716
+ path: ["findings", index, "evidence"],
717
+ message: "A blocking finding requires concrete evidence."
718
+ });
719
+ }
720
+ }
721
+ if (result.verdict === "failed" && !blocking) {
722
+ issues.push({
723
+ path: ["findings"],
724
+ message: "A failed review requires a blocking finding."
725
+ });
726
+ }
727
+ if (result.verdict === "passed" && blocking) {
728
+ issues.push({
729
+ path: ["findings"],
730
+ message: "A passed review cannot contain blocking findings."
731
+ });
732
+ }
733
+ if (result.terminalDisposition === "observed_unsubmitted" && result.verdict !== "failed") {
734
+ issues.push({
735
+ path: ["terminalDisposition"],
736
+ message: "Observed-but-unsubmitted review work must fail closed."
737
+ });
738
+ }
739
+ return issues;
740
+ }
741
+
609
742
  // src/domain/transitions.ts
610
743
  class FlowTransitionError extends Error {
611
744
  code = "FLOW_TRANSITION_REJECTED";
@@ -941,19 +1074,9 @@ function assertReviewResult(result) {
941
1074
  if (result.findings.length > MAX_REVIEW_FINDINGS) {
942
1075
  fail(`A review may contain at most ${MAX_REVIEW_FINDINGS} findings.`);
943
1076
  }
944
- const blocking = result.findings.some((finding) => finding.severity === "blocking");
945
- const unsupported = result.findings.some((finding) => finding.severity === "blocking" && !finding.evidence?.trim());
946
- if (unsupported)
947
- fail("A blocking finding requires concrete evidence.");
948
- if (result.verdict === "failed" && !blocking) {
949
- fail("A failed review requires a blocking finding.");
950
- }
951
- if (result.verdict === "passed" && blocking) {
952
- fail("A passed review cannot contain a blocking finding.");
953
- }
954
- if (result.terminalDisposition === "observed_unsubmitted" && result.verdict !== "failed") {
955
- fail("Observed-but-unsubmitted review work must fail closed.");
956
- }
1077
+ const issue = reviewResultSemanticIssues(result)[0];
1078
+ if (issue)
1079
+ fail(issue.message);
957
1080
  }
958
1081
  function completeFeature(session, input) {
959
1082
  assertReviewResult(input.result);
@@ -1364,36 +1487,8 @@ var PublicReviewResultSchema = z.object({
1364
1487
  findings: z.array(ReviewFindingSchema).max(MAX_REVIEW_FINDINGS).default([]),
1365
1488
  terminalDisposition: z.enum(["submitted", "observed_unsubmitted"])
1366
1489
  }).strict().superRefine((result, context) => {
1367
- const blocking = result.findings.some((finding) => finding.severity === "blocking");
1368
- for (const [index, finding] of result.findings.entries()) {
1369
- if (finding.severity === "blocking" && !finding.evidence) {
1370
- context.addIssue({
1371
- code: "custom",
1372
- path: ["findings", index, "evidence"],
1373
- message: "A blocking finding requires concrete evidence."
1374
- });
1375
- }
1376
- }
1377
- if (result.verdict === "failed" && !blocking) {
1378
- context.addIssue({
1379
- code: "custom",
1380
- path: ["findings"],
1381
- message: "A failed review requires a blocking finding."
1382
- });
1383
- }
1384
- if (result.verdict === "passed" && blocking) {
1385
- context.addIssue({
1386
- code: "custom",
1387
- path: ["findings"],
1388
- message: "A passed review cannot contain blocking findings."
1389
- });
1390
- }
1391
- if (result.terminalDisposition === "observed_unsubmitted" && result.verdict !== "failed") {
1392
- context.addIssue({
1393
- code: "custom",
1394
- path: ["terminalDisposition"],
1395
- message: "Observed-but-unsubmitted review work must fail closed."
1396
- });
1490
+ for (const issue of reviewResultSemanticIssues(result)) {
1491
+ context.addIssue({ code: "custom", ...issue });
1397
1492
  }
1398
1493
  });
1399
1494
  var ValidationObservationSchema = z.object({
@@ -2212,7 +2307,7 @@ function validIdentity(value) {
2212
2307
  }
2213
2308
  function canonicalProjectId(scopeId) {
2214
2309
  if (!boundedText2(scopeId))
2215
- throw new TypeError("Flow leadership scope ID must be a non-empty path.");
2310
+ throw new TypeError("Flow runtime scope must be a non-empty path.");
2216
2311
  const projectId = resolve2(scopeId);
2217
2312
  try {
2218
2313
  return realpathSync2(projectId);
@@ -2260,17 +2355,11 @@ function acquireRegistry() {
2260
2355
  function snapshot(identity) {
2261
2356
  return Object.freeze({ ...identity });
2262
2357
  }
2263
- function makeStatus(identity, reason, registrations = [], registered = false) {
2358
+ function makeStatus(reason) {
2264
2359
  const operational = reason === "sole-instance";
2265
2360
  return Object.freeze({
2266
- instanceId: identity.instanceId,
2267
- registered,
2268
2361
  operational,
2269
- role: operational ? "leader" : registered || reason === "incompatible-registry" ? "indeterminate" : "unregistered",
2270
2362
  reason,
2271
- registeredCount: reason === "incompatible-registry" && !registered ? null : registrations.length,
2272
- diagnosticLeader: operational ? identity : null,
2273
- registrations: Object.freeze([...registrations]),
2274
2363
  message: operational ? "Flow is active for this project." : `Flow is not operational (${reason}).`
2275
2364
  });
2276
2365
  }
@@ -2281,7 +2370,7 @@ var createFlowPluginInstanceId = () => globalThis.crypto.randomUUID();
2281
2370
  function registerFlowPluginInstance(scopeId, input) {
2282
2371
  const projectId = canonicalProjectId(scopeId);
2283
2372
  if (!validIdentity(input)) {
2284
- throw new TypeError("Flow leadership identity is invalid.");
2373
+ throw new TypeError("Flow runtime identity is invalid.");
2285
2374
  }
2286
2375
  const identity = snapshot(input);
2287
2376
  const registry = acquireRegistry();
@@ -2294,7 +2383,7 @@ function registerFlowPluginInstance(scopeId, input) {
2294
2383
  }
2295
2384
  const existing = project.get(identity.instanceId);
2296
2385
  if (existing && !sameIdentity(existing, identity)) {
2297
- throw new Error(`Flow leadership instance ID '${identity.instanceId}' is already registered with different identity data.`);
2386
+ throw new Error(`Flow runtime instance ID '${identity.instanceId}' is already registered with different identity data.`);
2298
2387
  }
2299
2388
  record = existing ?? identity;
2300
2389
  project.set(identity.instanceId, record);
@@ -2302,32 +2391,28 @@ function registerFlowPluginInstance(scopeId, input) {
2302
2391
  let released = false;
2303
2392
  const query = () => {
2304
2393
  if (released)
2305
- return makeStatus(identity, "released");
2394
+ return makeStatus("released");
2306
2395
  const currentRegistry = readRegistry();
2307
2396
  if (!record)
2308
- return makeStatus(identity, "incompatible-registry");
2397
+ return makeStatus("incompatible-registry");
2309
2398
  if (currentRegistry === undefined)
2310
- return makeStatus(identity, "not-registered");
2399
+ return makeStatus("not-registered");
2311
2400
  if (!compatibleRegistry(currentRegistry))
2312
- return makeStatus(identity, "incompatible-registry");
2401
+ return makeStatus("incompatible-registry");
2313
2402
  const project = currentRegistry.projects.get(projectId);
2314
- const registrations = project ? [...project.values()].map(snapshot) : [];
2315
- if (project?.get(identity.instanceId) !== record) {
2316
- return makeStatus(identity, "not-registered", registrations);
2403
+ if (!project || project.get(identity.instanceId) !== record) {
2404
+ return makeStatus("not-registered");
2317
2405
  }
2318
2406
  if (identity.protocolVersion !== FLOW_LEADERSHIP_PROTOCOL_VERSION) {
2319
- return makeStatus(identity, "incompatible-registry", registrations, true);
2407
+ return makeStatus("incompatible-registry");
2320
2408
  }
2321
- if (registrations.length !== 1) {
2322
- return makeStatus(identity, "duplicate-instances", registrations, true);
2409
+ if (project.size !== 1) {
2410
+ return makeStatus("duplicate-instances");
2323
2411
  }
2324
- return makeStatus(identity, "sole-instance", registrations, true);
2412
+ return makeStatus("sole-instance");
2325
2413
  };
2326
2414
  return Object.freeze({
2327
- identity,
2328
- scopeId: projectId,
2329
2415
  query,
2330
- isOperational: () => query().operational,
2331
2416
  assertOperational(action) {
2332
2417
  const current = query();
2333
2418
  if (!current.operational) {
@@ -2815,36 +2900,8 @@ var reviewResult = host.object({
2815
2900
  findings: host.array(reviewFinding).max(MAX_REVIEW_FINDINGS).default([]),
2816
2901
  terminalDisposition: host.enum(["submitted", "observed_unsubmitted"])
2817
2902
  }).strict().superRefine((result, context) => {
2818
- const blocking = result.findings.some((finding) => finding.severity === "blocking");
2819
- for (const [index, finding] of result.findings.entries()) {
2820
- if (finding.severity === "blocking" && !finding.evidence) {
2821
- context.addIssue({
2822
- code: "custom",
2823
- path: ["findings", index, "evidence"],
2824
- message: "A blocking finding requires concrete evidence."
2825
- });
2826
- }
2827
- }
2828
- if (result.verdict === "failed" && !blocking) {
2829
- context.addIssue({
2830
- code: "custom",
2831
- path: ["findings"],
2832
- message: "A failed review requires a blocking finding."
2833
- });
2834
- }
2835
- if (result.verdict === "passed" && blocking) {
2836
- context.addIssue({
2837
- code: "custom",
2838
- path: ["findings"],
2839
- message: "A passed review cannot contain blocking findings."
2840
- });
2841
- }
2842
- if (result.terminalDisposition === "observed_unsubmitted" && result.verdict !== "failed") {
2843
- context.addIssue({
2844
- code: "custom",
2845
- path: ["terminalDisposition"],
2846
- message: "Observed-but-unsubmitted review work must fail closed."
2847
- });
2903
+ for (const issue of reviewResultSemanticIssues(result)) {
2904
+ context.addIssue({ code: "custom", ...issue });
2848
2905
  }
2849
2906
  });
2850
2907
  var StatusArgs = {
@@ -3045,7 +3102,7 @@ class ValidationCaptureCoordinator {
3045
3102
  #prune() {
3046
3103
  const cutoff = this.#now() - CAPTURE_TTL_MS;
3047
3104
  for (const [sessionID, capture] of this.#pending) {
3048
- if (capture.armedAt < cutoff) {
3105
+ if (capture.callID === null && capture.armedAt < cutoff) {
3049
3106
  this.#pending.delete(sessionID);
3050
3107
  }
3051
3108
  }
@@ -3174,17 +3231,18 @@ function createCommandHook(assertOperational) {
3174
3231
  }
3175
3232
  };
3176
3233
  }
3177
- function guardTools(tools, leadership) {
3234
+ function guardTools(tools, runtimeGuard) {
3178
3235
  return Object.fromEntries(Object.entries(tools).map(([name, definition]) => [
3179
3236
  name,
3180
3237
  {
3181
3238
  ...definition,
3182
3239
  execute: async (...args) => {
3183
- if (!leadership.isOperational()) {
3240
+ const status = runtimeGuard.query();
3241
+ if (!status.operational) {
3184
3242
  return JSON.stringify({
3185
3243
  status: "error",
3186
- summary: "Flow is disabled because more than one runtime is registered for this project.",
3187
- workflowData: { runtimeGuard: leadership.query() }
3244
+ summary: status.message,
3245
+ workflowData: { runtimeGuard: status }
3188
3246
  });
3189
3247
  }
3190
3248
  return definition.execute(...args);
@@ -3195,13 +3253,13 @@ function guardTools(tools, leadership) {
3195
3253
  var FlowPlugin = async (ctx) => {
3196
3254
  const log = createFlowLog(ctx);
3197
3255
  const version = resolveFlowPluginVersion();
3198
- const leadership = registerFlowPluginInstance(ctx.worktree ?? ctx.directory, {
3256
+ const runtimeGuard = registerFlowPluginInstance(ctx.worktree ?? ctx.directory, {
3199
3257
  packageName: "opencode-plugin-flow",
3200
3258
  version,
3201
3259
  protocolVersion: FLOW_LEADERSHIP_PROTOCOL_VERSION,
3202
3260
  instanceId: createFlowPluginInstanceId()
3203
3261
  });
3204
- const initial = leadership.query();
3262
+ const initial = runtimeGuard.query();
3205
3263
  log(initial.operational ? "info" : "error", `Flow ${version}: ${initial.message}`);
3206
3264
  const validation = new ValidationCaptureCoordinator({
3207
3265
  persistObservation: persistWorkspaceValidation
@@ -3212,10 +3270,10 @@ var FlowPlugin = async (ctx) => {
3212
3270
  });
3213
3271
  return {
3214
3272
  config: createConfigHook(ctx, {
3215
- assertOperational: (action) => leadership.assertOperational(action)
3273
+ assertOperational: (action) => runtimeGuard.assertOperational(action)
3216
3274
  }),
3217
- tool: guardTools(tools, leadership),
3218
- "command.execute.before": createCommandHook((action) => leadership.assertOperational(action)),
3275
+ tool: guardTools(tools, runtimeGuard),
3276
+ "command.execute.before": createCommandHook((action) => runtimeGuard.assertOperational(action)),
3219
3277
  event: async (input) => {
3220
3278
  const event = input.event;
3221
3279
  if (event.type !== "session.idle" && event.type !== "session.compacted") {
@@ -3240,7 +3298,7 @@ var FlowPlugin = async (ctx) => {
3240
3298
  }
3241
3299
  },
3242
3300
  dispose: async () => {
3243
- leadership.release();
3301
+ runtimeGuard.release();
3244
3302
  }
3245
3303
  };
3246
3304
  };
@@ -3249,4 +3307,4 @@ export {
3249
3307
  plugin_default as default
3250
3308
  };
3251
3309
 
3252
- //# debugId=11C725AFDFE8E73E64756E2164756E21
3310
+ //# debugId=48BDB7D8B1F2A60764756E2164756E21