opencode-plugin-flow 7.0.1 → 7.1.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
@@ -185,6 +185,19 @@ Save one plan with:
185
185
  - \`overview\`: the implementation approach and important boundaries.
186
186
  - \`requirements\`: acceptance criteria, constraints, and non-goals.
187
187
  - \`decisions\`: assumptions and architecture or scope choices already made.
188
+ - \`gate\`: the canonical whole-repository command, read from repository
189
+ configuration. Broad evidence later runs this command byte-for-byte, so a check
190
+ too weak to fail forecloses broad evidence for the session.
191
+ - \`externalEvidence\`: each acceptance observation needing an operating system,
192
+ architecture, service, credential, setting, or hardware this host may lack, as
193
+ \`requirement\`, \`environment\`, the exact \`command\` whose passing is that observation,
194
+ \`platform\`: \`win32\`, \`darwin\`, \`linux\`, or \`other\` for a non-OS environment, and
195
+ \`assertions\`: the test case names whose passing is that observation, empty for
196
+ evidence that is not a test result. Empty list when the goal is fully observable
197
+ here. That command, on that platform, reporting those cases passing is what
198
+ satisfies it: final review and completed closure stay refused until then. A skipped
199
+ case exits zero and satisfies nothing, and recording the gap as a \`requirements\`
200
+ non-goal declares nothing.
188
201
  - \`features\`: ordered outcome slices, each with a stable \`id\`, \`title\`,
189
202
  \`summary\`, bounded \`targets\`, concrete \`validation\`, and \`dependsOn\` ids.
190
203
 
@@ -198,19 +211,14 @@ Preserve stable finding, issue, or requirement IDs exactly in the saved feature
198
211
  \`summary\` or \`validation\`; each stays traceable from the immutable plan to one
199
212
  outcome and its evidence.
200
213
 
201
- When \`validation\` names an executable command, record the exact plan-listed
202
- command byte-for-byte. Unrun behavior prose remains reviewer judgment, never a
203
- fabricated command result. Explicitly name any required operating system,
204
- architecture, service, credential, external setting, hardware, or evidence
205
- environment for \`flow-run\` to preflight before implementation.
214
+ A \`validation\` entry naming a command is recorded byte-for-byte; prose there stays
215
+ reviewer judgment, never a fabricated result.
206
216
 
207
217
  Before saving, confirm:
208
218
 
209
219
  - every requirement maps to a feature or an explicit non-goal;
210
220
  - targets name real files, modules, routes, commands, or artifacts;
211
221
  - validation names the behavior or contract the check will prove;
212
- - every required evidence environment has an identified execution path, and
213
- any path needing user or external authority is explicit;
214
222
  - dependencies capture true ordering without circular or hidden work;
215
223
  - assumptions and intentional gaps are visible in \`decisions\`.
216
224
 
@@ -303,11 +311,9 @@ read-only access. It is evidence, not a verdict. Lack of shell access alone is
303
311
  not a failure; a missing or conflicting baseline fact, or a material mode,
304
312
  platform, race, or failure-path claim without proof, is.
305
313
 
306
- Validation scope is a claim. Treat \`broad\` as adequate only when the durable
307
- command is the repository's canonical applicable gate or a justified equivalent
308
- for the delivered state. Flow deliberately projects no raw command output; use
309
- the durable command, exit code, completeness, digest, source binding, and your
310
- workspace inspection. A weak or unclear coverage claim is an evidence gap.
314
+ Flow deliberately projects no raw command output; use the durable command, exit
315
+ code, completeness, digest, source binding, and your workspace inspection. A weak or
316
+ unclear coverage claim is an evidence gap.
311
317
 
312
318
  For a final assignment, also inspect broad validation and confirm docs,
313
319
  commands, package surfaces, and remaining gaps are consistent with completion.
@@ -488,9 +494,9 @@ feature and current worktree:
488
494
 
489
495
  - Preserve every named finding/requirement; map each to an observable acceptance
490
496
  outcome.
491
- - Inventory exact commands, behavior evidence, required operating system,
492
- architecture, service, credential, external setting, or hardware, and an
493
- authorized path for each.
497
+ - Inventory each \`externalEvidence\` command, its platform, and where it writes a
498
+ JUnit report: arm it with \`resultsPath\` naming that file, or its declared cases go
499
+ unobserved and the entry stays open on exit zero.
494
500
  - Reuse one conversational run baseline of unrelated work, deletions, renames,
495
501
  file types, and executable modes. Refresh changed facts; give each review only
496
502
  facts the feature changes or depends on, and give final review the full
@@ -501,9 +507,8 @@ feature and current worktree:
501
507
  state-machine work, express it as a compact matrix with \`state/interleaving\`,
502
508
  \`event\`, \`expected outcome\`, \`cleanup/invariant\`, and \`evidence\` columns.
503
509
 
504
- Carry the checklist/IDs through workers and review. Required evidence needing
505
- user or external authority stops before implementation. If skipped or unavailable, it forbids
506
- \`flow_review_start\`; a substitute pass cannot cure it.
510
+ Carry the checklist/IDs through workers and review. Evidence needing user or
511
+ external authority stops before implementation.
507
512
 
508
513
  ## Implement
509
514
 
@@ -547,9 +552,8 @@ checks from the changed behavior and risk:
547
552
  sufficient alone only for genuinely mechanical or documentation-only work.
548
553
  - UI claims need visual inspection when a runnable target is available.
549
554
  - Cleanup claims need behavior-preservation evidence, not formatting alone.
550
- - \`scope: "broad"\` is a coverage claim, not a stronger label. Use it only for the
551
- repository's canonical applicable gate or a justified equivalent covering the
552
- delivered repository state.
555
+ - \`scope: "broad"\` runs the plan's declared \`gate\` and nothing else; the runtime
556
+ refuses any other command at that scope.
553
557
 
554
558
  Immediately before each evidence Bash command, call \`flow_validation_start\`
555
559
  with current revision, feature id, exact command, and \`scope\` (\`focused\` or
@@ -557,35 +561,29 @@ with current revision, feature id, exact command, and \`scope\` (\`focused\` or
557
561
  records the host observation; copy no host-observed fields into a later request.
558
562
  The command is durable, so include no secrets.
559
563
 
560
- Exact plan-listed gate commands are recorded byte-for-byte.
561
- A failed, incomplete, or source-drifted exact plan-listed observation creates a
562
- freshness boundary. Before new review admission, that gate needs a complete
563
- exit-zero observation for current source recorded after its latest relevant
564
- failure or drift; returning to an older digest does not revive an earlier pass,
565
- and substitute broad validation cannot discharge it. Plan-listed validation prose
566
- that has never run as an exact command remains reviewer judgment, not a fabricated
567
- pass or failure.
564
+ A failed, incomplete, or source-drifted observation of a plan-listed command or of
565
+ the declared \`gate\` blocks review until that same command passes for current
566
+ source. No substitute discharges it; reverting the source revives nothing.
568
567
 
569
- A gate that cannot pass withholds completed closure, and reporting the blocker is
570
- half the move. Fix the gate, or ask the user to choose deferred or abandoned
571
- closure. Ending the turn having done neither leaves the workflow with no next step.
568
+ A gate or \`externalEvidence\` command that cannot pass withholds completed closure,
569
+ and reporting the blocker is half the move. Reach the passing command, or ask the
570
+ user to choose deferred or abandoned closure. Ending the turn having done neither
571
+ leaves the workflow with no next step, whatever the blocker was.
572
572
 
573
573
  Every host-observed validation advances the session revision, so the revision
574
574
  that armed a completed command is stale. The \`[flow-validation]\` marker reports
575
- \`passed\` and \`recordedRevision\`. Use \`recordedRevision\` for the next
575
+ \`passed\`, \`recordedRevision\`, and any declared \`assertions\` with what the report
576
+ said about each. Use \`recordedRevision\` for the next
576
577
  \`flow_validation_start\`, or for \`flow_review_start\` only when \`passed: true\`. If
577
578
  the marker is absent or malformed, refresh compact status before mutating.
578
579
 
579
580
  Use focused validation for ordinary features. For the final feature, run the
580
- repository's broad applicable gate after the last relevant edit. A source edit
581
- invalidates earlier applicability. Failed or unavailable checks are blockers,
582
- not passing evidence. If the canonical gate cannot run, explain why the chosen
583
- equivalent is broad enough.
581
+ plan's declared \`gate\` at broad scope after the last relevant edit. A source edit
582
+ invalidates earlier applicability. Failed or unavailable checks are blockers, not
583
+ passing evidence.
584
584
 
585
- Immediately before review admission, reconcile the preflight inventory against
586
- the recorded current-source observations. Do not call \`flow_review_start\` while
587
- known required behavior or environment evidence is skipped or unavailable,
588
- including requirements that are not exact stored commands.
585
+ Immediately before review admission, reconcile the preflight inventory against the
586
+ recorded current-source observations.
589
587
 
590
588
  ## Review and record
591
589
 
@@ -923,9 +921,12 @@ function reviewerSteps(env, onWarning) {
923
921
  }
924
922
  return Number(raw);
925
923
  }
924
+ var SHARED_REVIEWER_MODEL_NOTICE = "Flow: no OPENCODE_FLOW_REVIEWER_MODEL is set, so the independent reviewer runs on the same model as the manager. Independence is stronger with a different model family; set OPENCODE_FLOW_REVIEWER_MODEL to a provider/model this host can reach.";
926
925
  function createFlowCoreConfigEntries(options) {
927
926
  const env = options?.env ?? process.env;
928
927
  const model = envValue(env, "OPENCODE_FLOW_REVIEWER_MODEL");
928
+ if (!model)
929
+ options?.onNotice?.(SHARED_REVIEWER_MODEL_NOTICE);
929
930
  const steps = reviewerSteps(env, options?.onWarning);
930
931
  return {
931
932
  agent: {
@@ -948,7 +949,8 @@ function createFlowCoreConfigEntries(options) {
948
949
  }
949
950
  function applyFlowConfig(config, options) {
950
951
  const entries = createFlowCoreConfigEntries({
951
- ...options?.onWarning ? { onWarning: options.onWarning } : {}
952
+ ...options?.onWarning ? { onWarning: options.onWarning } : {},
953
+ ...options?.onNotice ? { onNotice: options.onNotice } : {}
952
954
  });
953
955
  for (const name of Object.keys(entries.agent)) {
954
956
  if (config.agent && name in config.agent)
@@ -1022,6 +1024,8 @@ var MAX_TEXT_BYTES = 32 * 1024;
1022
1024
  var MAX_ARTIFACTS = 128;
1023
1025
  var MAX_PATH_BYTES = 4 * 1024;
1024
1026
  var MAX_VALIDATION_ID_LENGTH = 256;
1027
+ var MAX_DECLARED_ASSERTIONS = 32;
1028
+ var MAX_TEST_REPORT_BYTES = 4 * 1024 * 1024;
1025
1029
  var MAX_VALIDATIONS_PER_RUN = MAX_PLAN_FEATURES + 1;
1026
1030
  var MAX_REVIEW_FINDINGS = 100;
1027
1031
  var MAX_SESSION_BYTES = 4 * 1024 * 1024;
@@ -1062,8 +1066,243 @@ function isFeatureId(value) {
1062
1066
  return FEATURE_ID_PATTERN.test(value);
1063
1067
  }
1064
1068
 
1069
+ // src/domain/test-results.ts
1070
+ var TESTCASE = /<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase\s*>)/g;
1071
+ var ATTRIBUTE = /([\w:.-]+)\s*=\s*"([^"]*)"/g;
1072
+ var NEGATIVE = /<(failure|error|skipped)\b/;
1073
+ function attributes(source) {
1074
+ const found = {};
1075
+ for (const match of source.matchAll(ATTRIBUTE)) {
1076
+ if (match[1])
1077
+ found[match[1]] = decodeEntities(match[2] ?? "");
1078
+ }
1079
+ return found;
1080
+ }
1081
+ function decodeEntities(value) {
1082
+ return value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
1083
+ }
1084
+ function labels(attributes2) {
1085
+ const name = attributes2.name ?? "";
1086
+ if (name === "")
1087
+ return [];
1088
+ const suites = [
1089
+ attributes2.classname,
1090
+ attributes2.class,
1091
+ attributes2.file,
1092
+ attributes2.filepath
1093
+ ].filter((suite) => suite !== undefined && suite !== "");
1094
+ return [
1095
+ name,
1096
+ ...new Set(suites.flatMap((suite) => [
1097
+ `${suite} ${name}`,
1098
+ `${suite}.${name}`,
1099
+ `${suite} > ${name}`
1100
+ ]))
1101
+ ];
1102
+ }
1103
+ function absent(declared) {
1104
+ return declared.slice(0, MAX_DECLARED_ASSERTIONS).map((name) => ({ name, status: "absent" }));
1105
+ }
1106
+ function observeAssertions(declared, report) {
1107
+ const outcomes = new Map;
1108
+ if (!/<testsuites?\b/.test(report))
1109
+ return absent(declared);
1110
+ for (const match of report.matchAll(TESTCASE)) {
1111
+ const body = match[3] ?? "";
1112
+ const status = NEGATIVE.test(body) ? /<skipped\b/.test(body) ? "skipped" : "failed" : "passed";
1113
+ for (const label of labels(attributes(match[1] ?? ""))) {
1114
+ const prior = outcomes.get(label);
1115
+ if (prior === undefined || prior === "passed" && status !== "passed") {
1116
+ outcomes.set(label, status);
1117
+ }
1118
+ }
1119
+ }
1120
+ return declared.slice(0, MAX_DECLARED_ASSERTIONS).map((name) => ({
1121
+ name,
1122
+ status: outcomes.get(name) ?? "absent"
1123
+ }));
1124
+ }
1125
+ function assertionsSatisfied(declared, observed) {
1126
+ return declared.every((name) => (observed ?? []).some((assertion) => assertion.name === name && assertion.status === "passed"));
1127
+ }
1128
+ function unmetAssertions(declared, observed) {
1129
+ return declared.flatMap((name) => {
1130
+ const found = (observed ?? []).find((assertion) => assertion.name === name);
1131
+ if (found?.status === "passed")
1132
+ return [];
1133
+ return [`${JSON.stringify(name)} ${found?.status ?? "absent"}`];
1134
+ });
1135
+ }
1136
+
1137
+ // src/domain/transition-error.ts
1138
+ class FlowTransitionError extends Error {
1139
+ code = "FLOW_TRANSITION_REJECTED";
1140
+ }
1141
+
1142
+ // src/domain/validation.ts
1143
+ var VALIDATION_INELIGIBLE_REASONS = [
1144
+ "source-drift",
1145
+ "exit-code-unavailable",
1146
+ "output-completeness-unknown"
1147
+ ];
1148
+ var LONGEST_VALIDATION_INELIGIBLE_REASON = VALIDATION_INELIGIBLE_REASONS.reduce((longest, reason) => reason.length > longest.length ? reason : longest);
1149
+ var EVIDENCE_PLATFORMS = [
1150
+ "win32",
1151
+ "darwin",
1152
+ "linux",
1153
+ "other"
1154
+ ];
1155
+ var LONGEST_EVIDENCE_PLATFORM = EVIDENCE_PLATFORMS.reduce((longest, platform) => platform.length > longest.length ? platform : longest);
1156
+ function normalizeEvidencePlatform(value) {
1157
+ return EVIDENCE_PLATFORMS.find((platform) => platform !== "other" && platform === value) ?? "other";
1158
+ }
1159
+ function isValidationEligible(observation, sourceDigest) {
1160
+ return observation.ineligibleReason === undefined && observation.exitCode === 0 && observation.outputComplete && (sourceDigest === undefined || observation.sourceDigest === sourceDigest);
1161
+ }
1162
+ var NARROWING_FLAGS = new Set([
1163
+ "-t",
1164
+ "--test-name-pattern",
1165
+ "--testNamePattern",
1166
+ "-k",
1167
+ "-run",
1168
+ "--grep",
1169
+ "--filter"
1170
+ ]);
1171
+ function narrowingArguments(command) {
1172
+ return command.split(/\s+/).slice(1).filter((token) => {
1173
+ if (token.startsWith("-")) {
1174
+ return NARROWING_FLAGS.has(token.split("=")[0] ?? token);
1175
+ }
1176
+ const file = token.split("/").pop() ?? "";
1177
+ return /\.(?:test|spec)\./.test(file) || /_test\.[a-z]+$/.test(file) || /^test_.+\.py$/.test(file);
1178
+ });
1179
+ }
1180
+ function declaredAssertions(session, command) {
1181
+ return [
1182
+ ...new Set((session.plan?.externalEvidence ?? []).filter((entry) => entry.command === command).flatMap((entry) => entry.assertions ?? []))
1183
+ ];
1184
+ }
1185
+ function sameAssertions(left, right) {
1186
+ const serialize = (value) => JSON.stringify((value ?? []).map((assertion) => [assertion.name, assertion.status]));
1187
+ return serialize(left) === serialize(right);
1188
+ }
1189
+ function recordValidation(session, input) {
1190
+ if (input.captureId.length < 1 || input.captureId.length > MAX_VALIDATION_ID_LENGTH) {
1191
+ throw new FlowTransitionError(`Validation capture id must contain 1-${MAX_VALIDATION_ID_LENGTH} characters.`);
1192
+ }
1193
+ if (input.exitCode === null && input.ineligibleReason === undefined) {
1194
+ throw new FlowTransitionError("An observation without an exit code must record an ineligible reason.");
1195
+ }
1196
+ const prior = session.runs.flatMap((run2) => run2.validations).find((validation) => validation.id === input.captureId);
1197
+ if (prior) {
1198
+ if (prior.featureId !== input.featureId || prior.runId !== input.runId || prior.scope !== input.scope || prior.command !== input.command || prior.sourceDigest !== input.sourceDigest || prior.exitCode !== input.exitCode || prior.outputDigest !== input.outputDigest || prior.outputComplete !== input.outputComplete || prior.hostPlatform !== input.hostPlatform || prior.resultsPath !== input.resultsPath || !sameAssertions(prior.observedAssertions, input.observedAssertions) || prior.ineligibleReason !== input.ineligibleReason) {
1199
+ throw new FlowTransitionError("Validation capture id was already used for a different observation.");
1200
+ }
1201
+ return { session, value: prior, replayed: true };
1202
+ }
1203
+ if (session.closure) {
1204
+ throw new FlowTransitionError("This Flow session is closed and archive-only.");
1205
+ }
1206
+ const run = session.runs.find((candidate) => candidate.state === "active");
1207
+ if (!run || run.id !== input.runId || run.featureId !== input.featureId) {
1208
+ throw new FlowTransitionError("Validation no longer belongs to the active feature run.");
1209
+ }
1210
+ if (run.reviews.length > 0) {
1211
+ throw new FlowTransitionError("Validation cannot be recorded after review has begun.");
1212
+ }
1213
+ if (run.validations.length >= MAX_VALIDATIONS_PER_RUN) {
1214
+ throw new FlowTransitionError(`A feature run may contain at most ${MAX_VALIDATIONS_PER_RUN} validation observations.`);
1215
+ }
1216
+ if (input.scope === "broad") {
1217
+ const narrowing = narrowingArguments(input.command);
1218
+ if (narrowing.length > 0) {
1219
+ throw new FlowTransitionError(`A broad observation cannot select which tests it runs (${narrowing.join(", ")}). Arm the repository's canonical gate, or record this command as focused.`);
1220
+ }
1221
+ const gate = session.plan?.gate;
1222
+ if (gate !== undefined && input.command !== gate) {
1223
+ throw new FlowTransitionError(`A broad observation must run the plan-declared canonical gate (${gate}). Arm that exact command, or record this one as focused.`);
1224
+ }
1225
+ }
1226
+ const revision = session.revision + 1;
1227
+ const observation = {
1228
+ id: input.captureId,
1229
+ featureId: input.featureId,
1230
+ runId: input.runId,
1231
+ scope: input.scope,
1232
+ command: input.command,
1233
+ sourceDigest: input.sourceDigest,
1234
+ exitCode: input.exitCode,
1235
+ outputDigest: input.outputDigest,
1236
+ outputComplete: input.outputComplete,
1237
+ recordedRevision: revision,
1238
+ ...input.hostPlatform ? { hostPlatform: input.hostPlatform } : {},
1239
+ ...input.resultsPath ? { resultsPath: input.resultsPath } : {},
1240
+ ...input.observedAssertions && input.observedAssertions.length > 0 ? { observedAssertions: input.observedAssertions } : {},
1241
+ ...input.ineligibleReason ? { ineligibleReason: input.ineligibleReason } : {}
1242
+ };
1243
+ const draft = structuredClone(session);
1244
+ return {
1245
+ session: {
1246
+ ...draft,
1247
+ revision,
1248
+ runs: draft.runs.map((candidate) => candidate.id === run.id ? {
1249
+ ...candidate,
1250
+ validations: [...candidate.validations, observation]
1251
+ } : candidate)
1252
+ },
1253
+ value: observation,
1254
+ replayed: false
1255
+ };
1256
+ }
1257
+ function isObservedOnDeclaredPlatform(entry, observation) {
1258
+ if (entry.platform === undefined || entry.platform === "other")
1259
+ return true;
1260
+ return observation.hostPlatform === entry.platform;
1261
+ }
1262
+ function externalEvidenceRefusal(session, entry, sourceDigest) {
1263
+ const eligible = session.runs.flatMap((run) => run.validations).filter((observation) => observation.command === entry.command && isValidationEligible(observation, sourceDigest));
1264
+ const wrongHosts = [
1265
+ ...new Set(eligible.filter((observation) => !isObservedOnDeclaredPlatform(entry, observation)).map((observation) => observation.hostPlatform ?? "an unrecorded host"))
1266
+ ];
1267
+ const unmet = eligible.filter((observation) => isObservedOnDeclaredPlatform(entry, observation)).toSorted((left, right) => left.recordedRevision - right.recordedRevision).map((observation) => unmetAssertions(entry.assertions ?? [], observation.observedAssertions)).filter((names) => names.length > 0).at(-1);
1268
+ const needs = entry.platform === undefined || entry.platform === "other" ? entry.environment : `${entry.environment} on ${entry.platform}`;
1269
+ const detail = wrongHosts.length > 0 ? `passed on ${wrongHosts.join(", ")} but this entry declares ${entry.platform}, so that run observed something else — a skipped case exits zero too` : unmet ? `passed on ${entry.platform ?? "the declared host"} but reported no passing result for ${unmet.join(", ")}; arm it again with \`resultsPath\` naming the report the command writes, and make those cases run` : `needs ${needs}`;
1270
+ return `${JSON.stringify(entry.command)} (${detail}, for ${entry.requirement})`;
1271
+ }
1272
+ function unsatisfiedExternalEvidence(session, sourceDigest) {
1273
+ const declared = session.plan?.externalEvidence ?? [];
1274
+ if (declared.length === 0)
1275
+ return [];
1276
+ const observed = session.runs.flatMap((run) => run.validations);
1277
+ return declared.filter((entry) => !observed.some((observation) => observation.command === entry.command && isObservedOnDeclaredPlatform(entry, observation) && assertionsSatisfied(entry.assertions ?? [], observation.observedAssertions) && isValidationEligible(observation, sourceDigest)));
1278
+ }
1279
+ function isValidationFresh(session, run, observation) {
1280
+ return session.runs.filter((candidate) => candidate.featureId === run.featureId).flatMap((candidate) => candidate.validations).every((candidate) => candidate.command !== observation.command || isValidationEligible(candidate) || candidate.recordedRevision < observation.recordedRevision);
1281
+ }
1282
+ function unresolvedVetoedCommands(session, run, sourceDigest) {
1283
+ const gate = session.plan?.gate;
1284
+ const planned = session.approval === "approved" ? [
1285
+ ...session.plan?.features.find((candidate) => candidate.id === run.featureId)?.validation ?? [],
1286
+ ...gate === undefined ? [] : [gate]
1287
+ ] : [];
1288
+ const failed = session.runs.filter((candidate) => candidate.featureId === run.featureId).flatMap((candidate) => candidate.validations).filter((observation) => !isValidationEligible(observation));
1289
+ const commands = [
1290
+ ...new Set(failed.filter((observation) => observation.scope === "broad" || planned.includes(observation.command)).map((observation) => observation.command))
1291
+ ];
1292
+ return commands.filter((command) => !run.validations.some((observation) => observation.command === command && isValidationEligible(observation, sourceDigest) && isValidationFresh(session, run, observation)));
1293
+ }
1294
+
1065
1295
  // src/domain/plan.ts
1296
+ function gateIssue(gate) {
1297
+ const narrowing = narrowingArguments(gate);
1298
+ return narrowing.length === 0 ? null : `The plan's canonical gate cannot select which tests it runs (${narrowing.join(", ")}).`;
1299
+ }
1066
1300
  function planIssue(plan) {
1301
+ if (plan.gate !== undefined) {
1302
+ const issue = gateIssue(plan.gate);
1303
+ if (issue)
1304
+ return issue;
1305
+ }
1067
1306
  if (Buffer.byteLength(JSON.stringify(plan), "utf8") > MAX_PLAN_BYTES) {
1068
1307
  return `A plan may contain at most ${MAX_PLAN_BYTES} UTF-8 bytes.`;
1069
1308
  }
@@ -1213,112 +1452,6 @@ function reviewResultSemanticIssues(result) {
1213
1452
  return issues;
1214
1453
  }
1215
1454
 
1216
- // src/domain/transition-error.ts
1217
- class FlowTransitionError extends Error {
1218
- code = "FLOW_TRANSITION_REJECTED";
1219
- }
1220
-
1221
- // src/domain/validation.ts
1222
- var VALIDATION_INELIGIBLE_REASONS = [
1223
- "source-drift",
1224
- "exit-code-unavailable",
1225
- "output-completeness-unknown"
1226
- ];
1227
- var LONGEST_VALIDATION_INELIGIBLE_REASON = VALIDATION_INELIGIBLE_REASONS.reduce((longest, reason) => reason.length > longest.length ? reason : longest);
1228
- function isValidationEligible(observation, sourceDigest) {
1229
- return observation.ineligibleReason === undefined && observation.exitCode === 0 && observation.outputComplete && (sourceDigest === undefined || observation.sourceDigest === sourceDigest);
1230
- }
1231
- var NARROWING_FLAGS = new Set([
1232
- "-t",
1233
- "--test-name-pattern",
1234
- "--testNamePattern",
1235
- "-k",
1236
- "-run",
1237
- "--grep",
1238
- "--filter"
1239
- ]);
1240
- function narrowingArguments(command) {
1241
- return command.split(/\s+/).slice(1).filter((token) => {
1242
- if (token.startsWith("-")) {
1243
- return NARROWING_FLAGS.has(token.split("=")[0] ?? token);
1244
- }
1245
- const file = token.split("/").pop() ?? "";
1246
- return /\.(?:test|spec)\./.test(file) || /_test\.[a-z]+$/.test(file) || /^test_.+\.py$/.test(file);
1247
- });
1248
- }
1249
- function recordValidation(session, input) {
1250
- if (input.captureId.length < 1 || input.captureId.length > MAX_VALIDATION_ID_LENGTH) {
1251
- throw new FlowTransitionError(`Validation capture id must contain 1-${MAX_VALIDATION_ID_LENGTH} characters.`);
1252
- }
1253
- if (input.exitCode === null && input.ineligibleReason === undefined) {
1254
- throw new FlowTransitionError("An observation without an exit code must record an ineligible reason.");
1255
- }
1256
- const prior = session.runs.flatMap((run2) => run2.validations).find((validation) => validation.id === input.captureId);
1257
- if (prior) {
1258
- if (prior.featureId !== input.featureId || prior.runId !== input.runId || prior.scope !== input.scope || prior.command !== input.command || prior.sourceDigest !== input.sourceDigest || prior.exitCode !== input.exitCode || prior.outputDigest !== input.outputDigest || prior.outputComplete !== input.outputComplete || prior.ineligibleReason !== input.ineligibleReason) {
1259
- throw new FlowTransitionError("Validation capture id was already used for a different observation.");
1260
- }
1261
- return { session, value: prior, replayed: true };
1262
- }
1263
- if (session.closure) {
1264
- throw new FlowTransitionError("This Flow session is closed and archive-only.");
1265
- }
1266
- const run = session.runs.find((candidate) => candidate.state === "active");
1267
- if (!run || run.id !== input.runId || run.featureId !== input.featureId) {
1268
- throw new FlowTransitionError("Validation no longer belongs to the active feature run.");
1269
- }
1270
- if (run.reviews.length > 0) {
1271
- throw new FlowTransitionError("Validation cannot be recorded after review has begun.");
1272
- }
1273
- if (run.validations.length >= MAX_VALIDATIONS_PER_RUN) {
1274
- throw new FlowTransitionError(`A feature run may contain at most ${MAX_VALIDATIONS_PER_RUN} validation observations.`);
1275
- }
1276
- if (input.scope === "broad") {
1277
- const narrowing = narrowingArguments(input.command);
1278
- if (narrowing.length > 0) {
1279
- throw new FlowTransitionError(`A broad observation cannot select which tests it runs (${narrowing.join(", ")}). Arm the repository's canonical gate, or record this command as focused.`);
1280
- }
1281
- }
1282
- const revision = session.revision + 1;
1283
- const observation = {
1284
- id: input.captureId,
1285
- featureId: input.featureId,
1286
- runId: input.runId,
1287
- scope: input.scope,
1288
- command: input.command,
1289
- sourceDigest: input.sourceDigest,
1290
- exitCode: input.exitCode,
1291
- outputDigest: input.outputDigest,
1292
- outputComplete: input.outputComplete,
1293
- recordedRevision: revision,
1294
- ...input.ineligibleReason ? { ineligibleReason: input.ineligibleReason } : {}
1295
- };
1296
- const draft = structuredClone(session);
1297
- return {
1298
- session: {
1299
- ...draft,
1300
- revision,
1301
- runs: draft.runs.map((candidate) => candidate.id === run.id ? {
1302
- ...candidate,
1303
- validations: [...candidate.validations, observation]
1304
- } : candidate)
1305
- },
1306
- value: observation,
1307
- replayed: false
1308
- };
1309
- }
1310
- function isValidationFresh(session, run, observation) {
1311
- return session.runs.filter((candidate) => candidate.featureId === run.featureId).flatMap((candidate) => candidate.validations).every((candidate) => candidate.command !== observation.command || isValidationEligible(candidate) || candidate.recordedRevision < observation.recordedRevision);
1312
- }
1313
- function unresolvedVetoedCommands(session, run, sourceDigest) {
1314
- const planned = session.approval === "approved" ? session.plan?.features.find((candidate) => candidate.id === run.featureId)?.validation ?? [] : [];
1315
- const failed = session.runs.filter((candidate) => candidate.featureId === run.featureId).flatMap((candidate) => candidate.validations).filter((observation) => !isValidationEligible(observation));
1316
- const commands = [
1317
- ...new Set(failed.filter((observation) => observation.scope === "broad" || planned.includes(observation.command)).map((observation) => observation.command))
1318
- ];
1319
- return commands.filter((command) => !run.validations.some((observation) => observation.command === command && isValidationEligible(observation, sourceDigest) && isValidationFresh(session, run, observation)));
1320
- }
1321
-
1322
1455
  // src/domain/transitions.ts
1323
1456
  function fail(message) {
1324
1457
  throw new FlowTransitionError(message);
@@ -1374,6 +1507,22 @@ function assertPlan(plan) {
1374
1507
  if (issue)
1375
1508
  fail(issue);
1376
1509
  }
1510
+ function assertDeclaredGate(plan) {
1511
+ if (plan.gate === undefined) {
1512
+ fail("A saved plan must declare `gate`: the exact canonical command that validates the whole repository, which every broad observation then has to run.");
1513
+ }
1514
+ }
1515
+ function assertDeclaredExternalEvidence(plan) {
1516
+ if (plan.externalEvidence === undefined) {
1517
+ fail("A saved plan must declare `externalEvidence`: every acceptance observation needing an environment this host may not be, each with the exact command whose passing is that observation. Declare an empty list when the goal is fully observable here.");
1518
+ }
1519
+ if (plan.externalEvidence?.some((entry) => entry.platform === undefined)) {
1520
+ fail("Every `externalEvidence` entry must declare `platform`: the operating system that can observe it (`win32`, `darwin`, or `linux`), or `other` when the missing environment is a service, credential, setting, or device rather than an OS. Flow compares an OS against the host the command actually ran on.");
1521
+ }
1522
+ if (plan.externalEvidence?.some((entry) => entry.assertions === undefined)) {
1523
+ fail("Every `externalEvidence` entry must declare `assertions`: the test case names whose passing is that observation, so a run cannot discharge it by exiting zero for a case that was skipped. Declare an empty list when the evidence is not a test result — a credential, a device, or a setting has no case names.");
1524
+ }
1525
+ }
1377
1526
  function assertArtifacts(artifacts) {
1378
1527
  const issue = artifactIssues(artifacts)[0];
1379
1528
  if (issue)
@@ -1406,6 +1555,8 @@ function sessionStatus(session) {
1406
1555
  function savePlan(session, input, environment) {
1407
1556
  assertPlan(input.plan);
1408
1557
  if (!session) {
1558
+ assertDeclaredGate(input.plan);
1559
+ assertDeclaredExternalEvidence(input.plan);
1409
1560
  if (input.expectedRevision !== 0) {
1410
1561
  fail("A new Flow session must start from expectedRevision 0.");
1411
1562
  }
@@ -1435,6 +1586,8 @@ function savePlan(session, input, environment) {
1435
1586
  const replay = existingOperation(session, "plan-save", input.operationId, input);
1436
1587
  if (replay)
1437
1588
  return { session, value: null, replayed: true };
1589
+ assertDeclaredGate(input.plan);
1590
+ assertDeclaredExternalEvidence(input.plan);
1438
1591
  assertRevision(session, input.expectedRevision);
1439
1592
  assertMutable(session);
1440
1593
  if (session.approval === "approved")
@@ -1576,6 +1729,12 @@ function startReview(session, input, environment) {
1576
1729
  fail(`Review requires passing these exact commands for the current workspace content: ${unresolved.map((command) => JSON.stringify(command)).join(", ")}. A different command cannot discharge one that failed.`);
1577
1730
  }
1578
1731
  const kind = isFinalFeatureRun(session, run) ? "final" : "feature";
1732
+ if (kind === "final") {
1733
+ const unsatisfied = unsatisfiedExternalEvidence(session, input.sourceDigest);
1734
+ if (unsatisfied.length > 0) {
1735
+ fail(`Final review requires the plan's declared external evidence to pass for the current workspace content: ${unsatisfied.map((entry) => externalEvidenceRefusal(session, entry, input.sourceDigest)).join(", ")}. A substitute observation cannot discharge it. If the environment is unavailable, ask the user to choose deferred or abandoned closure.`);
1736
+ }
1737
+ }
1579
1738
  const applicable = run.validations.filter((validation) => isValidationEligible(validation, input.sourceDigest) && isValidationFresh(session, run, validation));
1580
1739
  const hasRequiredValidation = kind === "feature" ? applicable.length > 0 : applicable.some((validation) => validation.scope === "broad");
1581
1740
  if (!hasRequiredValidation) {
@@ -1752,6 +1911,12 @@ function closeSession(session, input) {
1752
1911
  if (input.kind === "completed" && sessionStatus(session) !== "completed") {
1753
1912
  fail("A completed close requires every planned feature to pass review.");
1754
1913
  }
1914
+ if (input.kind === "completed") {
1915
+ const unsatisfied = unsatisfiedExternalEvidence(session);
1916
+ if (unsatisfied.length > 0) {
1917
+ fail(`A completed close requires the plan's declared external evidence to have passed: ${unsatisfied.map((entry) => externalEvidenceRefusal(session, entry)).join(", ")}. Close deferred or abandoned instead.`);
1918
+ }
1919
+ }
1755
1920
  let closure = null;
1756
1921
  const next = commit(session, "session-close", input.operationId, input, (draft, revision) => {
1757
1922
  closure = {
@@ -1981,12 +2146,25 @@ var PlanFeatureSchema = z.object({
1981
2146
  validation: z.array(boundedText("Feature validation")).max(MAX_PLAN_FEATURES).default([]),
1982
2147
  dependsOn: z.array(FeatureIdSchema).max(MAX_PLAN_FEATURES).default([])
1983
2148
  }).strict();
2149
+ var ExternalEvidenceSchema = z.object({
2150
+ requirement: boundedText("External evidence requirement"),
2151
+ environment: boundedText("External evidence environment"),
2152
+ command: boundedText("External evidence command"),
2153
+ platform: z.enum(EVIDENCE_PLATFORMS).optional(),
2154
+ assertions: z.array(boundedText("External evidence assertion")).max(MAX_DECLARED_ASSERTIONS).optional()
2155
+ }).strict();
2156
+ var ObservedAssertionSchema = z.object({
2157
+ name: boundedText("Observed assertion name"),
2158
+ status: z.enum(["passed", "failed", "skipped", "absent"])
2159
+ }).strict();
1984
2160
  var PlanSchema = z.object({
1985
2161
  summary: boundedText("Plan summary"),
1986
2162
  overview: boundedText("Plan overview"),
1987
2163
  requirements: z.array(boundedText("Plan requirement")).max(MAX_PLAN_FEATURES).default([]),
1988
2164
  decisions: z.array(boundedText("Plan decision")).max(MAX_PLAN_FEATURES).default([]),
1989
- features: z.array(PlanFeatureSchema).min(1).max(MAX_PLAN_FEATURES)
2165
+ features: z.array(PlanFeatureSchema).min(1).max(MAX_PLAN_FEATURES),
2166
+ gate: boundedText("Plan gate").optional(),
2167
+ externalEvidence: z.array(ExternalEvidenceSchema).max(MAX_PLAN_FEATURES).optional()
1990
2168
  }).strict().superRefine((plan, context) => {
1991
2169
  if (encoder.encode(JSON.stringify(plan)).byteLength > MAX_PLAN_BYTES) {
1992
2170
  context.addIssue({
@@ -2026,6 +2204,11 @@ var ValidationObservationSchema = z.object({
2026
2204
  outputDigest: SourceDigestSchema,
2027
2205
  outputComplete: z.boolean(),
2028
2206
  recordedRevision: RevisionSchema,
2207
+ hostPlatform: z.enum(EVIDENCE_PLATFORMS).optional(),
2208
+ resultsPath: boundedText("Validation results path", {
2209
+ maxBytes: MAX_PATH_BYTES
2210
+ }).optional(),
2211
+ observedAssertions: z.array(ObservedAssertionSchema).max(MAX_DECLARED_ASSERTIONS).optional(),
2029
2212
  ineligibleReason: z.enum(VALIDATION_INELIGIBLE_REASONS).optional()
2030
2213
  }).strict().refine((observation) => observation.exitCode !== null || observation.ineligibleReason !== undefined, {
2031
2214
  error: "An observation without an exit code must record an ineligible reason.",
@@ -2153,7 +2336,10 @@ var ValidationStartInputSchema = z.object({
2153
2336
  expectedRevision: RevisionSchema,
2154
2337
  featureId: FeatureIdSchema,
2155
2338
  command: boundedText("Validation command"),
2156
- scope: z.enum(["focused", "broad"])
2339
+ scope: z.enum(["focused", "broad"]),
2340
+ resultsPath: boundedText("Validation results path", {
2341
+ maxBytes: MAX_PATH_BYTES
2342
+ }).optional()
2157
2343
  }).strict()
2158
2344
  }).strict();
2159
2345
  var StatusInputSchema = z.object({
@@ -2364,7 +2550,13 @@ function reviewerProjection(session, assignmentId) {
2364
2550
  targets: [...candidate.targets],
2365
2551
  validation: [...candidate.validation],
2366
2552
  dependsOn: [...candidate.dependsOn]
2367
- }))
2553
+ })),
2554
+ ...plan.gate === undefined ? {} : { gate: plan.gate },
2555
+ ...plan.externalEvidence === undefined ? {} : {
2556
+ externalEvidence: plan.externalEvidence.map((entry) => ({
2557
+ ...entry
2558
+ }))
2559
+ }
2368
2560
  } : null,
2369
2561
  feature: feature ?? null,
2370
2562
  assignment,
@@ -3521,6 +3713,10 @@ var flowFeatureCompleteReplay = (workspace, input) => service(workspace).feature
3521
3713
  var flowFeatureReset = (workspace, input) => service(workspace).featureReset(input);
3522
3714
  var flowSessionClose = (workspace, input) => service(workspace).sessionClose(input);
3523
3715
 
3716
+ // src/infrastructure/fs/workspace-validation.ts
3717
+ import { readFile as readFile2, stat } from "node:fs/promises";
3718
+ import { isAbsolute as isAbsolute2, join as join3, relative, resolve as resolve2 } from "node:path";
3719
+
3524
3720
  // src/application/prepare-validation.ts
3525
3721
  function maximumSerializedUnusedCaptureId(session) {
3526
3722
  const used = new Set(session.runs.flatMap((run) => run.validations.map((validation) => validation.id)));
@@ -3539,6 +3735,11 @@ function maximumSerializedObservation(session, prepared) {
3539
3735
  exitCode: Number.MIN_SAFE_INTEGER,
3540
3736
  outputDigest: prepared.sourceDigest,
3541
3737
  outputComplete: false,
3738
+ hostPlatform: LONGEST_EVIDENCE_PLATFORM,
3739
+ observedAssertions: prepared.assertions.map((name) => ({
3740
+ name,
3741
+ status: "skipped"
3742
+ })),
3542
3743
  ineligibleReason: LONGEST_VALIDATION_INELIGIBLE_REASON
3543
3744
  };
3544
3745
  }
@@ -3546,7 +3747,7 @@ function assertValidationCanBeRecorded(session, prepared) {
3546
3747
  const prospective = recordValidation(session, maximumSerializedObservation(session, prepared)).session;
3547
3748
  SessionSchema.parse(prospective);
3548
3749
  }
3549
- async function prepareValidation(repository, input) {
3750
+ async function prepareValidation(repository, input, hostPlatform) {
3550
3751
  return repository.transact(async (transaction) => {
3551
3752
  const session = await transaction.load();
3552
3753
  if (!session)
@@ -3561,12 +3762,18 @@ async function prepareValidation(repository, input) {
3561
3762
  if (run.reviews.length > 0) {
3562
3763
  throw new Error("Validation cannot start after review has begun.");
3563
3764
  }
3765
+ if (input.resultsPath !== undefined && !isArtifactPath(input.resultsPath)) {
3766
+ throw new Error(`Validation results path: ${ARTIFACT_PATH_MESSAGE}`);
3767
+ }
3564
3768
  const prepared = {
3565
3769
  featureId: run.featureId,
3566
3770
  runId: run.id,
3567
3771
  command: input.command,
3568
3772
  scope: input.scope,
3569
- sourceDigest: await transaction.computeSourceDigest()
3773
+ sourceDigest: await transaction.computeSourceDigest(),
3774
+ hostPlatform,
3775
+ assertions: declaredAssertions(session, input.command),
3776
+ resultsPath: input.resultsPath
3570
3777
  };
3571
3778
  assertValidationCanBeRecorded(session, prepared);
3572
3779
  return prepared;
@@ -3589,7 +3796,18 @@ async function persistObservedValidation(repository, input) {
3589
3796
 
3590
3797
  // src/infrastructure/fs/workspace-validation.ts
3591
3798
  function prepareWorkspaceValidation(workspace, input) {
3592
- return prepareValidation(createFileSessionRepository(workspace), input);
3799
+ return prepareValidation(createFileSessionRepository(workspace), input, normalizeEvidencePlatform(process.platform));
3800
+ }
3801
+ async function readWorkspaceTestReport(workspace, relativePath) {
3802
+ const root = resolve2(workspace);
3803
+ const target = resolve2(join3(root, relativePath));
3804
+ const inside = relative(root, target);
3805
+ if (inside === "" || isAbsolute2(inside) || inside.split(/[\\/]/)[0] === "..")
3806
+ return null;
3807
+ const info = await stat(target).catch(() => null);
3808
+ if (!info?.isFile() || info.size > MAX_TEST_REPORT_BYTES)
3809
+ return null;
3810
+ return { text: await readFile2(target, "utf8"), modifiedMs: info.mtimeMs };
3593
3811
  }
3594
3812
  function persistWorkspaceValidation(workspace, input) {
3595
3813
  return persistObservedValidation(createFileSessionRepository(workspace), input);
@@ -3646,6 +3864,7 @@ class AutoDriveCoordinator {
3646
3864
  #timing = null;
3647
3865
  #options;
3648
3866
  #hostParentage = false;
3867
+ #hostMissingParentage = false;
3649
3868
  constructor(options) {
3650
3869
  this.#options = options;
3651
3870
  }
@@ -3794,8 +4013,12 @@ class AutoDriveCoordinator {
3794
4013
  ${FLOW_MANAGER_KERNEL}`;
3795
4014
  }
3796
4015
  observeHostMessage(host, message) {
3797
- if (message.role === "assistant" && message.parentID !== undefined)
3798
- this.#hostParentage = true;
4016
+ if (message.role === "assistant") {
4017
+ if (message.parentID === undefined)
4018
+ this.#hostMissingParentage = true;
4019
+ else
4020
+ this.#hostParentage = true;
4021
+ }
3799
4022
  const lease = this.#lease;
3800
4023
  if (lease?.hostSessionId !== host)
3801
4024
  return;
@@ -3853,6 +4076,11 @@ ${FLOW_MANAGER_KERNEL}`;
3853
4076
  if (revision > point.revision)
3854
4077
  point.advance = revision + Number(reviewerPending);
3855
4078
  }
4079
+ continuationSupport() {
4080
+ if (this.#hostParentage)
4081
+ return "supported";
4082
+ return this.#hostMissingParentage ? "unsupported" : "unknown";
4083
+ }
3856
4084
  timingSnapshot() {
3857
4085
  const timing = this.#timing;
3858
4086
  if (!timing)
@@ -4002,6 +4230,7 @@ function createConfigHook(ctx, options) {
4002
4230
  }
4003
4231
  applyFlowConfig(config, {
4004
4232
  onWarning: (warning) => log("warn", warning),
4233
+ onNotice: (notice) => log("info", notice),
4005
4234
  onCollision: (kind, name) => log("warn", `Flow replaced a user-defined ${kind} named '${name}'; rename the local entry while Flow is enabled.`)
4006
4235
  });
4007
4236
  };
@@ -4009,7 +4238,7 @@ function createConfigHook(ctx, options) {
4009
4238
 
4010
4239
  // src/platform/opencode/leadership.ts
4011
4240
  import { realpathSync as realpathSync2 } from "node:fs";
4012
- import { resolve as resolve2 } from "node:path";
4241
+ import { resolve as resolve3 } from "node:path";
4013
4242
  var REGISTRY_KIND = "opencode-plugin-flow.runtime-leadership";
4014
4243
  var REGISTRY_FORMAT_VERSION = 2;
4015
4244
  var FLOW_LEADERSHIP_PROTOCOL_VERSION = 1;
@@ -4026,7 +4255,7 @@ function validIdentity(value) {
4026
4255
  function canonicalProjectId(scopeId) {
4027
4256
  if (!boundedText2(scopeId))
4028
4257
  throw new TypeError("Flow runtime scope must be a non-empty path.");
4029
- const projectId = resolve2(scopeId);
4258
+ const projectId = resolve3(scopeId);
4030
4259
  try {
4031
4260
  return realpathSync2(projectId);
4032
4261
  } catch {
@@ -4190,7 +4419,15 @@ var plan = host.object({
4190
4419
  overview: text,
4191
4420
  requirements: host.array(text).max(MAX_PLAN_FEATURES).default([]),
4192
4421
  decisions: host.array(text).max(MAX_PLAN_FEATURES).default([]),
4193
- features: host.array(planFeature).min(1).max(MAX_PLAN_FEATURES)
4422
+ features: host.array(planFeature).min(1).max(MAX_PLAN_FEATURES),
4423
+ gate: text.optional(),
4424
+ externalEvidence: host.array(host.object({
4425
+ requirement: text,
4426
+ environment: text,
4427
+ command: text,
4428
+ platform: host.enum(EVIDENCE_PLATFORMS).optional(),
4429
+ assertions: host.array(text).max(MAX_DECLARED_ASSERTIONS).optional()
4430
+ }).strict()).max(MAX_PLAN_FEATURES).optional()
4194
4431
  }).strict().superRefine((value, context) => {
4195
4432
  if (encoder2.encode(JSON.stringify(value)).byteLength > MAX_PLAN_BYTES) {
4196
4433
  context.addIssue({
@@ -4238,7 +4475,10 @@ var ValidationStartArgs = {
4238
4475
  expectedRevision: revision,
4239
4476
  featureId,
4240
4477
  command: text,
4241
- scope: host.enum(["focused", "broad"])
4478
+ scope: host.enum(["focused", "broad"]),
4479
+ resultsPath: boundedHostText("Validation results path", {
4480
+ maxBytes: MAX_PATH_BYTES
4481
+ }).optional()
4242
4482
  }).strict()
4243
4483
  };
4244
4484
  var ReviewStartArgs = {
@@ -4291,23 +4531,30 @@ function toolError(error) {
4291
4531
  }
4292
4532
  });
4293
4533
  }
4294
- function withAutoTiming(response, snapshot2) {
4295
- if (!snapshot2)
4296
- return response;
4534
+ function withAutoContext(response, options) {
4535
+ let workflowData = response.workflowData;
4297
4536
  try {
4298
- const timing = snapshot2();
4299
- if (!timing)
4300
- return response;
4301
- return {
4302
- ...response,
4303
- workflowData: {
4304
- ...response.workflowData,
4305
- autoTiming: timing
4306
- }
4307
- };
4308
- } catch {
4309
- return response;
4310
- }
4537
+ const timing = options.autoTimingSnapshot?.();
4538
+ if (timing)
4539
+ workflowData = { ...workflowData, autoTiming: timing };
4540
+ } catch {}
4541
+ try {
4542
+ const support = options.autoContinuationSupport?.();
4543
+ if (support === "supported" || support === "unsupported") {
4544
+ workflowData = {
4545
+ ...workflowData,
4546
+ autoContinuation: {
4547
+ scope: "current-plugin-process",
4548
+ support,
4549
+ ...support === "unsupported" ? {
4550
+ reason: "host-reports-no-assistant-message-parentage",
4551
+ recovery: "Drive each feature with /flow-run."
4552
+ } : {}
4553
+ }
4554
+ };
4555
+ }
4556
+ } catch {}
4557
+ return workflowData === response.workflowData ? response : { ...response, workflowData };
4311
4558
  }
4312
4559
  async function execute(context, handler) {
4313
4560
  try {
@@ -4336,7 +4583,7 @@ function createTools(_ctx, options) {
4336
4583
  flow_status: tool({
4337
4584
  description: "Read compact, execution, detail, or reviewer Flow state.",
4338
4585
  args: StatusArgs,
4339
- execute: (args, context) => execute(context, async (workspace) => withAutoTiming(await flowStatus(workspace, args), options.autoTimingSnapshot))
4586
+ execute: (args, context) => execute(context, async (workspace) => withAutoContext(await flowStatus(workspace, args), options))
4340
4587
  }),
4341
4588
  flow_plan_save: tool({
4342
4589
  description: "Create or replace the active draft plan.",
@@ -4437,14 +4684,35 @@ function isBash(tool2) {
4437
4684
 
4438
4685
  class ValidationCaptureCoordinator {
4439
4686
  #persist;
4687
+ #readReport;
4440
4688
  #now;
4441
4689
  #randomId;
4442
4690
  #pending = new Map;
4443
4691
  constructor(options) {
4444
4692
  this.#persist = options.persistObservation;
4693
+ this.#readReport = options.readReport;
4445
4694
  this.#now = options.now ?? Date.now;
4446
4695
  this.#randomId = options.randomId ?? randomUUID3;
4447
4696
  }
4697
+ async#observeAssertions(capture) {
4698
+ if (capture.assertions.length === 0)
4699
+ return [];
4700
+ const absent2 = capture.assertions.map((name) => ({
4701
+ name,
4702
+ status: "absent"
4703
+ }));
4704
+ if (!capture.resultsPath || !this.#readReport)
4705
+ return absent2;
4706
+ let report;
4707
+ try {
4708
+ report = await this.#readReport(capture.workspace, capture.resultsPath);
4709
+ } catch {
4710
+ return absent2;
4711
+ }
4712
+ if (!report || report.modifiedMs <= capture.armedAt)
4713
+ return absent2;
4714
+ return observeAssertions(capture.assertions, report.text);
4715
+ }
4448
4716
  #prune() {
4449
4717
  const cutoff = this.#now() - CAPTURE_TTL_MS;
4450
4718
  for (const [sessionID, capture] of this.#pending) {
@@ -4502,12 +4770,17 @@ class ValidationCaptureCoordinator {
4502
4770
  const observedExit = exitCode(output.metadata);
4503
4771
  const observedComplete = completeOutput(output.metadata);
4504
4772
  const hostGap = observedExit === null ? "exit-code-unavailable" : observedComplete === null ? "output-completeness-unknown" : null;
4773
+ const observedAssertions = await this.#observeAssertions(capture);
4505
4774
  const observation = await this.#persist(capture.workspace, {
4506
4775
  featureId: capture.featureId,
4507
4776
  runId: capture.runId,
4508
4777
  command: capture.command,
4509
4778
  scope: capture.scope,
4510
4779
  sourceDigest: capture.sourceDigest,
4780
+ hostPlatform: capture.hostPlatform,
4781
+ assertions: capture.assertions,
4782
+ resultsPath: capture.resultsPath,
4783
+ ...observedAssertions.length > 0 ? { observedAssertions } : {},
4511
4784
  captureId: capture.captureId,
4512
4785
  exitCode: observedExit,
4513
4786
  outputDigest: digest(output.output),
@@ -4521,6 +4794,7 @@ class ValidationCaptureCoordinator {
4521
4794
  scope: observation.scope,
4522
4795
  passed: isValidationEligible(observation),
4523
4796
  recordedRevision: observation.recordedRevision,
4797
+ ...observation.observedAssertions ? { assertions: observation.observedAssertions } : {},
4524
4798
  ...observation.ineligibleReason ? { ineligibleReason: observation.ineligibleReason } : {}
4525
4799
  })}`;
4526
4800
  return observation;
@@ -4602,6 +4876,9 @@ function createCommandHook(assertOperational, autoDrive) {
4602
4876
  if (command !== "flow-auto")
4603
4877
  return void autoDrive.deactivate(input.sessionID);
4604
4878
  const metadata = await autoDrive.activate(input.sessionID);
4879
+ if (autoDrive.continuationSupport() === "unsupported") {
4880
+ output.parts.unshift(textPart("Note: this OpenCode host does not report assistant message parentage, so Flow cannot continue automatically between features here. Each feature still runs normally; drive the next one with /flow-run."));
4881
+ }
4605
4882
  const instruction = output.parts.find((part) => part.type === "text" && part.synthetic === true);
4606
4883
  if (!instruction) {
4607
4884
  autoDrive.deactivate(input.sessionID);
@@ -4702,12 +4979,14 @@ var FlowPlugin = async (ctx) => {
4702
4979
  onWarning: (message) => log("warn", message)
4703
4980
  });
4704
4981
  const validation = new ValidationCaptureCoordinator({
4705
- persistObservation: persistWorkspaceValidation
4982
+ persistObservation: persistWorkspaceValidation,
4983
+ readReport: readWorkspaceTestReport
4706
4984
  });
4707
4985
  const tools = createTools(ctx, {
4708
4986
  validation,
4709
4987
  prepareValidation: prepareWorkspaceValidation,
4710
- autoTimingSnapshot: () => autoDrive.timingSnapshot()
4988
+ autoTimingSnapshot: () => autoDrive.timingSnapshot(),
4989
+ autoContinuationSupport: () => autoDrive.continuationSupport()
4711
4990
  });
4712
4991
  return {
4713
4992
  config: createConfigHook(ctx, {
@@ -4774,4 +5053,4 @@ export {
4774
5053
  plugin_default as default
4775
5054
  };
4776
5055
 
4777
- //# debugId=F9B2A9081022601764756E2164756E21
5056
+ //# debugId=59C59901BAE4E80164756E2164756E21