opencode-plugin-flow 7.0.2 → 7.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
@@ -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({
@@ -2169,69 +2355,132 @@ var StatusInputSchema = z.object({
2169
2355
  }).strict();
2170
2356
 
2171
2357
  // src/application/delivery.ts
2172
- var NO_ARTIFACTS = "none reported";
2173
- function formatFeature(feature) {
2174
- const findings = feature.terminalFindings.map((finding) => ` - ${finding.severity}: ${finding.summary}`);
2175
- return [
2176
- `- ${feature.id} — ${feature.title}`,
2177
- ` attempts: ${feature.attempts}; latest state: ${feature.latestState}`,
2178
- ` outcome: ${feature.outcomeSummary ?? "none recorded"}`,
2179
- findings.length > 0 ? " terminal findings:" : " terminal findings: none",
2180
- ...findings
2358
+ var LIMITATIONS = [
2359
+ "Artifact paths and the canonical gate are caller declarations; Flow validates binding, not completeness or fitness.",
2360
+ "Goal alignment, scope discipline, evidence completeness, requirement coverage, test adequacy, and review substance remain model judgments.",
2361
+ "Freshness holds when review is accepted; an archive does not attest the current workspace."
2362
+ ];
2363
+ function currentRun2(session, featureId) {
2364
+ return session.runs.findLast((run) => run.featureId === featureId && run.state !== "superseded");
2365
+ }
2366
+ function assuranceProjection(session) {
2367
+ if (!session.closure)
2368
+ throw new Error("Assurance requires a recorded closure.");
2369
+ const complete = session.closure.kind === "completed";
2370
+ const features = session.plan?.features ?? [];
2371
+ const runs = features.flatMap((feature) => {
2372
+ const run = currentRun2(session, feature.id);
2373
+ return run ? [run] : [];
2374
+ });
2375
+ const accepted = runs.flatMap((run) => {
2376
+ const ids = new Set(run.reviews.filter((review) => review.result?.verdict === "passed").flatMap((review) => review.validationIds));
2377
+ return run.validations.filter((observation) => ids.has(observation.id) && isValidationEligible(observation));
2378
+ });
2379
+ const check = (id, label, tier, satisfied, explanation) => ({
2380
+ id,
2381
+ label,
2382
+ tier,
2383
+ status: complete ? satisfied ? "satisfied" : "unsatisfied" : "not-applicable",
2384
+ explanation: complete ? explanation : `${session.closure?.kind} closure makes no completion claim.`
2385
+ });
2386
+ const completed = features.filter((feature) => isFeatureComplete(session, feature.id)).length;
2387
+ const passing = runs.filter((run) => run.reviews.some((review) => review.result?.verdict === "passed")).length;
2388
+ const structural = session.plan !== null && features.length > 0 && completed === features.length && passing === features.length && runs.some((run) => run.reviews.some((review) => review.kind === "final" && review.result?.verdict === "passed")) && !runs.some((run) => (run.reviews.at(-1)?.result?.findings ?? []).some((finding) => finding.severity === "blocking"));
2389
+ const checks = [
2390
+ check("recorded-completion", "Recorded completion", "ts-enforced", structural, `${completed}/${features.length} features and ${passing}/${features.length} independent reviews pass, including a final review with no terminal blocker.`),
2391
+ check("accepted-validation", "Accepted validation", "host-attested", runs.length === features.length && runs.every((run) => accepted.some((observation) => observation.runId === run.id)), `${runs.filter((run) => accepted.some((item) => item.runId === run.id)).length}/${features.length} terminal runs have eligible host evidence accepted by review.`)
2181
2392
  ];
2393
+ const gate = session.plan?.gate;
2394
+ checks.push(gate === undefined ? {
2395
+ id: "canonical-gate",
2396
+ label: "Canonical gate",
2397
+ tier: "caller-declared",
2398
+ status: "not-applicable",
2399
+ explanation: "This legacy plan declared no canonical gate."
2400
+ } : check("canonical-gate", "Canonical gate", "host-attested", accepted.some((observation) => observation.command === gate && observation.scope === "broad"), `${JSON.stringify(gate)} must have eligible broad evidence accepted by review.`));
2401
+ const declared = session.plan?.externalEvidence;
2402
+ const missing = unsatisfiedExternalEvidence(session).length;
2403
+ checks.push(declared === undefined ? {
2404
+ id: "external-evidence",
2405
+ label: "Declared external evidence",
2406
+ tier: "caller-declared",
2407
+ status: "not-applicable",
2408
+ explanation: "This legacy plan declared no external-evidence obligations."
2409
+ } : check("external-evidence", "Declared external evidence", declared.length === 0 ? "caller-declared" : "host-attested", missing === 0, `${declared.length - missing}/${declared.length} declared obligations have eligible evidence on their declared host with named cases passing.`));
2410
+ return {
2411
+ conclusion: !complete ? "completion-not-claimed" : checks.some((item) => item.status === "unsatisfied") ? "completion-unsupported" : "completion-supported",
2412
+ checks,
2413
+ limitations: [...LIMITATIONS]
2414
+ };
2182
2415
  }
2416
+ var TIER_LABELS = {
2417
+ "ts-enforced": "TS-enforced",
2418
+ "host-attested": "host-attested",
2419
+ "caller-declared": "caller-declared"
2420
+ };
2183
2421
  function formatReport(delivery) {
2184
- const artifacts = delivery.reportedArtifacts;
2422
+ const lines = delivery.features.flatMap((feature) => [
2423
+ `- ${feature.id} — ${feature.title}`,
2424
+ ` attempts: ${feature.attempts}; latest state: ${feature.latestState}`,
2425
+ ` outcome: ${feature.outcomeSummary ?? "none recorded"}`,
2426
+ ...feature.terminalFindings.length === 0 ? [" terminal findings: none"] : [
2427
+ " terminal findings:",
2428
+ ...feature.terminalFindings.map((finding) => ` - ${finding.severity}: ${finding.summary}`)
2429
+ ]
2430
+ ]);
2185
2431
  return [
2186
2432
  `Goal: ${delivery.goal}`,
2187
2433
  `Closure: ${delivery.closure.kind}${delivery.closure.summary ? ` — ${delivery.closure.summary}` : ""}`,
2188
2434
  `Progress: ${delivery.progress.completed} of ${delivery.progress.total} features complete`,
2189
2435
  "Features:",
2190
- ...delivery.features.flatMap(formatFeature),
2436
+ ...lines,
2437
+ `Assurance: ${delivery.assurance.conclusion.replaceAll("-", " ")}`,
2438
+ "Assurance checks:",
2439
+ ...delivery.assurance.checks.map((item) => `- ${item.status} [${TIER_LABELS[item.tier]}] ${item.label}: ${item.explanation}`),
2440
+ "Assurance limitations:",
2441
+ ...delivery.assurance.limitations.map((item) => `- ${item}`),
2191
2442
  "Artifacts as reported by Flow from caller declarations, not an exact or exhaustive Git delta:",
2192
- `- latest attempts: ${artifacts.latestAttempts.join(", ") || NO_ARTIFACTS}`,
2193
- `- superseded attempts only: ${artifacts.supersededAttemptsOnly.join(", ") || NO_ARTIFACTS}`
2443
+ `- latest attempts: ${delivery.reportedArtifacts.latestAttempts.join(", ") || "none reported"}`,
2444
+ `- superseded attempts only: ${delivery.reportedArtifacts.supersededAttemptsOnly.join(", ") || "none reported"}`
2194
2445
  ];
2195
2446
  }
2196
2447
  function deliveryProjection(session) {
2197
- if (!session.closure) {
2198
- throw new Error("A delivery projection requires a recorded closure.");
2199
- }
2200
- const planFeatures = session.plan?.features ?? [];
2201
- const featureRuns = planFeatures.map((feature) => ({
2448
+ if (!session.closure)
2449
+ throw new Error("Delivery requires a recorded closure.");
2450
+ const features = session.plan?.features ?? [];
2451
+ const grouped = features.map((feature) => ({
2202
2452
  feature,
2203
2453
  runs: session.runs.filter((run) => run.featureId === feature.id)
2204
2454
  }));
2205
- const latestRuns = featureRuns.flatMap(({ runs }) => runs.slice(-1));
2206
- const latestArtifacts = new Set(latestRuns.flatMap((run) => run.artifactsChanged.map((artifact) => artifact.path)));
2207
- const allArtifacts = new Set(session.runs.flatMap((run) => run.artifactsChanged.map((artifact) => artifact.path)));
2208
- const completed = planFeatures.filter((feature) => isFeatureComplete(session, feature.id)).length;
2455
+ const latest = grouped.flatMap(({ runs }) => runs.slice(-1));
2456
+ const latestArtifacts = new Set(latest.flatMap((run) => run.artifactsChanged.map((item) => item.path)));
2457
+ const allArtifacts = new Set(session.runs.flatMap((run) => run.artifactsChanged.map((item) => item.path)));
2209
2458
  const delivery = {
2210
2459
  goal: session.goal,
2211
- closure: {
2212
- kind: session.closure.kind,
2213
- summary: session.closure.summary
2460
+ closure: { kind: session.closure.kind, summary: session.closure.summary },
2461
+ progress: {
2462
+ completed: features.filter((feature) => isFeatureComplete(session, feature.id)).length,
2463
+ total: features.length
2214
2464
  },
2215
- progress: { completed, total: planFeatures.length },
2216
- features: featureRuns.map(({ feature, runs }) => {
2217
- const latest = runs.at(-1);
2218
- const terminalResult = latest?.reviews.at(-1)?.result;
2465
+ features: grouped.map(({ feature, runs }) => {
2466
+ const run = runs.at(-1);
2219
2467
  return {
2220
2468
  id: feature.id,
2221
2469
  title: feature.title,
2222
2470
  attempts: runs.length,
2223
- latestState: latest?.state ?? "not-started",
2224
- outcomeSummary: latest?.summary ?? null,
2225
- terminalFindings: terminalResult?.findings.map((finding) => ({
2226
- severity: finding.severity,
2227
- summary: finding.summary
2471
+ latestState: run?.state ?? "not-started",
2472
+ outcomeSummary: run?.summary ?? null,
2473
+ terminalFindings: run?.reviews.at(-1)?.result?.findings.map(({ severity, summary }) => ({
2474
+ severity,
2475
+ summary
2228
2476
  })) ?? []
2229
2477
  };
2230
2478
  }),
2231
2479
  reportedArtifacts: {
2232
2480
  latestAttempts: [...latestArtifacts].sort(),
2233
2481
  supersededAttemptsOnly: [...allArtifacts].filter((path) => !latestArtifacts.has(path)).sort()
2234
- }
2482
+ },
2483
+ assurance: assuranceProjection(session)
2235
2484
  };
2236
2485
  return { ...delivery, report: formatReport(delivery) };
2237
2486
  }
@@ -2364,7 +2613,13 @@ function reviewerProjection(session, assignmentId) {
2364
2613
  targets: [...candidate.targets],
2365
2614
  validation: [...candidate.validation],
2366
2615
  dependsOn: [...candidate.dependsOn]
2367
- }))
2616
+ })),
2617
+ ...plan.gate === undefined ? {} : { gate: plan.gate },
2618
+ ...plan.externalEvidence === undefined ? {} : {
2619
+ externalEvidence: plan.externalEvidence.map((entry) => ({
2620
+ ...entry
2621
+ }))
2622
+ }
2368
2623
  } : null,
2369
2624
  feature: feature ?? null,
2370
2625
  assignment,
@@ -3521,6 +3776,10 @@ var flowFeatureCompleteReplay = (workspace, input) => service(workspace).feature
3521
3776
  var flowFeatureReset = (workspace, input) => service(workspace).featureReset(input);
3522
3777
  var flowSessionClose = (workspace, input) => service(workspace).sessionClose(input);
3523
3778
 
3779
+ // src/infrastructure/fs/workspace-validation.ts
3780
+ import { readFile as readFile2, stat } from "node:fs/promises";
3781
+ import { isAbsolute as isAbsolute2, join as join3, relative, resolve as resolve2 } from "node:path";
3782
+
3524
3783
  // src/application/prepare-validation.ts
3525
3784
  function maximumSerializedUnusedCaptureId(session) {
3526
3785
  const used = new Set(session.runs.flatMap((run) => run.validations.map((validation) => validation.id)));
@@ -3539,6 +3798,11 @@ function maximumSerializedObservation(session, prepared) {
3539
3798
  exitCode: Number.MIN_SAFE_INTEGER,
3540
3799
  outputDigest: prepared.sourceDigest,
3541
3800
  outputComplete: false,
3801
+ hostPlatform: LONGEST_EVIDENCE_PLATFORM,
3802
+ observedAssertions: prepared.assertions.map((name) => ({
3803
+ name,
3804
+ status: "skipped"
3805
+ })),
3542
3806
  ineligibleReason: LONGEST_VALIDATION_INELIGIBLE_REASON
3543
3807
  };
3544
3808
  }
@@ -3546,7 +3810,7 @@ function assertValidationCanBeRecorded(session, prepared) {
3546
3810
  const prospective = recordValidation(session, maximumSerializedObservation(session, prepared)).session;
3547
3811
  SessionSchema.parse(prospective);
3548
3812
  }
3549
- async function prepareValidation(repository, input) {
3813
+ async function prepareValidation(repository, input, hostPlatform) {
3550
3814
  return repository.transact(async (transaction) => {
3551
3815
  const session = await transaction.load();
3552
3816
  if (!session)
@@ -3561,12 +3825,18 @@ async function prepareValidation(repository, input) {
3561
3825
  if (run.reviews.length > 0) {
3562
3826
  throw new Error("Validation cannot start after review has begun.");
3563
3827
  }
3828
+ if (input.resultsPath !== undefined && !isArtifactPath(input.resultsPath)) {
3829
+ throw new Error(`Validation results path: ${ARTIFACT_PATH_MESSAGE}`);
3830
+ }
3564
3831
  const prepared = {
3565
3832
  featureId: run.featureId,
3566
3833
  runId: run.id,
3567
3834
  command: input.command,
3568
3835
  scope: input.scope,
3569
- sourceDigest: await transaction.computeSourceDigest()
3836
+ sourceDigest: await transaction.computeSourceDigest(),
3837
+ hostPlatform,
3838
+ assertions: declaredAssertions(session, input.command),
3839
+ resultsPath: input.resultsPath
3570
3840
  };
3571
3841
  assertValidationCanBeRecorded(session, prepared);
3572
3842
  return prepared;
@@ -3589,7 +3859,18 @@ async function persistObservedValidation(repository, input) {
3589
3859
 
3590
3860
  // src/infrastructure/fs/workspace-validation.ts
3591
3861
  function prepareWorkspaceValidation(workspace, input) {
3592
- return prepareValidation(createFileSessionRepository(workspace), input);
3862
+ return prepareValidation(createFileSessionRepository(workspace), input, normalizeEvidencePlatform(process.platform));
3863
+ }
3864
+ async function readWorkspaceTestReport(workspace, relativePath) {
3865
+ const root = resolve2(workspace);
3866
+ const target = resolve2(join3(root, relativePath));
3867
+ const inside = relative(root, target);
3868
+ if (inside === "" || isAbsolute2(inside) || inside.split(/[\\/]/)[0] === "..")
3869
+ return null;
3870
+ const info = await stat(target).catch(() => null);
3871
+ if (!info?.isFile() || info.size > MAX_TEST_REPORT_BYTES)
3872
+ return null;
3873
+ return { text: await readFile2(target, "utf8"), modifiedMs: info.mtimeMs };
3593
3874
  }
3594
3875
  function persistWorkspaceValidation(workspace, input) {
3595
3876
  return persistObservedValidation(createFileSessionRepository(workspace), input);
@@ -3646,6 +3927,7 @@ class AutoDriveCoordinator {
3646
3927
  #timing = null;
3647
3928
  #options;
3648
3929
  #hostParentage = false;
3930
+ #hostMissingParentage = false;
3649
3931
  constructor(options) {
3650
3932
  this.#options = options;
3651
3933
  }
@@ -3794,8 +4076,12 @@ class AutoDriveCoordinator {
3794
4076
  ${FLOW_MANAGER_KERNEL}`;
3795
4077
  }
3796
4078
  observeHostMessage(host, message) {
3797
- if (message.role === "assistant" && message.parentID !== undefined)
3798
- this.#hostParentage = true;
4079
+ if (message.role === "assistant") {
4080
+ if (message.parentID === undefined)
4081
+ this.#hostMissingParentage = true;
4082
+ else
4083
+ this.#hostParentage = true;
4084
+ }
3799
4085
  const lease = this.#lease;
3800
4086
  if (lease?.hostSessionId !== host)
3801
4087
  return;
@@ -3853,6 +4139,11 @@ ${FLOW_MANAGER_KERNEL}`;
3853
4139
  if (revision > point.revision)
3854
4140
  point.advance = revision + Number(reviewerPending);
3855
4141
  }
4142
+ continuationSupport() {
4143
+ if (this.#hostParentage)
4144
+ return "supported";
4145
+ return this.#hostMissingParentage ? "unsupported" : "unknown";
4146
+ }
3856
4147
  timingSnapshot() {
3857
4148
  const timing = this.#timing;
3858
4149
  if (!timing)
@@ -4002,6 +4293,7 @@ function createConfigHook(ctx, options) {
4002
4293
  }
4003
4294
  applyFlowConfig(config, {
4004
4295
  onWarning: (warning) => log("warn", warning),
4296
+ onNotice: (notice) => log("info", notice),
4005
4297
  onCollision: (kind, name) => log("warn", `Flow replaced a user-defined ${kind} named '${name}'; rename the local entry while Flow is enabled.`)
4006
4298
  });
4007
4299
  };
@@ -4009,7 +4301,7 @@ function createConfigHook(ctx, options) {
4009
4301
 
4010
4302
  // src/platform/opencode/leadership.ts
4011
4303
  import { realpathSync as realpathSync2 } from "node:fs";
4012
- import { resolve as resolve2 } from "node:path";
4304
+ import { resolve as resolve3 } from "node:path";
4013
4305
  var REGISTRY_KIND = "opencode-plugin-flow.runtime-leadership";
4014
4306
  var REGISTRY_FORMAT_VERSION = 2;
4015
4307
  var FLOW_LEADERSHIP_PROTOCOL_VERSION = 1;
@@ -4026,7 +4318,7 @@ function validIdentity(value) {
4026
4318
  function canonicalProjectId(scopeId) {
4027
4319
  if (!boundedText2(scopeId))
4028
4320
  throw new TypeError("Flow runtime scope must be a non-empty path.");
4029
- const projectId = resolve2(scopeId);
4321
+ const projectId = resolve3(scopeId);
4030
4322
  try {
4031
4323
  return realpathSync2(projectId);
4032
4324
  } catch {
@@ -4190,7 +4482,15 @@ var plan = host.object({
4190
4482
  overview: text,
4191
4483
  requirements: host.array(text).max(MAX_PLAN_FEATURES).default([]),
4192
4484
  decisions: host.array(text).max(MAX_PLAN_FEATURES).default([]),
4193
- features: host.array(planFeature).min(1).max(MAX_PLAN_FEATURES)
4485
+ features: host.array(planFeature).min(1).max(MAX_PLAN_FEATURES),
4486
+ gate: text.optional(),
4487
+ externalEvidence: host.array(host.object({
4488
+ requirement: text,
4489
+ environment: text,
4490
+ command: text,
4491
+ platform: host.enum(EVIDENCE_PLATFORMS).optional(),
4492
+ assertions: host.array(text).max(MAX_DECLARED_ASSERTIONS).optional()
4493
+ }).strict()).max(MAX_PLAN_FEATURES).optional()
4194
4494
  }).strict().superRefine((value, context) => {
4195
4495
  if (encoder2.encode(JSON.stringify(value)).byteLength > MAX_PLAN_BYTES) {
4196
4496
  context.addIssue({
@@ -4238,7 +4538,10 @@ var ValidationStartArgs = {
4238
4538
  expectedRevision: revision,
4239
4539
  featureId,
4240
4540
  command: text,
4241
- scope: host.enum(["focused", "broad"])
4541
+ scope: host.enum(["focused", "broad"]),
4542
+ resultsPath: boundedHostText("Validation results path", {
4543
+ maxBytes: MAX_PATH_BYTES
4544
+ }).optional()
4242
4545
  }).strict()
4243
4546
  };
4244
4547
  var ReviewStartArgs = {
@@ -4291,23 +4594,30 @@ function toolError(error) {
4291
4594
  }
4292
4595
  });
4293
4596
  }
4294
- function withAutoTiming(response, snapshot2) {
4295
- if (!snapshot2)
4296
- return response;
4597
+ function withAutoContext(response, options) {
4598
+ let workflowData = response.workflowData;
4297
4599
  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
- }
4600
+ const timing = options.autoTimingSnapshot?.();
4601
+ if (timing)
4602
+ workflowData = { ...workflowData, autoTiming: timing };
4603
+ } catch {}
4604
+ try {
4605
+ const support = options.autoContinuationSupport?.();
4606
+ if (support === "supported" || support === "unsupported") {
4607
+ workflowData = {
4608
+ ...workflowData,
4609
+ autoContinuation: {
4610
+ scope: "current-plugin-process",
4611
+ support,
4612
+ ...support === "unsupported" ? {
4613
+ reason: "host-reports-no-assistant-message-parentage",
4614
+ recovery: "Drive each feature with /flow-run."
4615
+ } : {}
4616
+ }
4617
+ };
4618
+ }
4619
+ } catch {}
4620
+ return workflowData === response.workflowData ? response : { ...response, workflowData };
4311
4621
  }
4312
4622
  async function execute(context, handler) {
4313
4623
  try {
@@ -4336,7 +4646,7 @@ function createTools(_ctx, options) {
4336
4646
  flow_status: tool({
4337
4647
  description: "Read compact, execution, detail, or reviewer Flow state.",
4338
4648
  args: StatusArgs,
4339
- execute: (args, context) => execute(context, async (workspace) => withAutoTiming(await flowStatus(workspace, args), options.autoTimingSnapshot))
4649
+ execute: (args, context) => execute(context, async (workspace) => withAutoContext(await flowStatus(workspace, args), options))
4340
4650
  }),
4341
4651
  flow_plan_save: tool({
4342
4652
  description: "Create or replace the active draft plan.",
@@ -4437,14 +4747,35 @@ function isBash(tool2) {
4437
4747
 
4438
4748
  class ValidationCaptureCoordinator {
4439
4749
  #persist;
4750
+ #readReport;
4440
4751
  #now;
4441
4752
  #randomId;
4442
4753
  #pending = new Map;
4443
4754
  constructor(options) {
4444
4755
  this.#persist = options.persistObservation;
4756
+ this.#readReport = options.readReport;
4445
4757
  this.#now = options.now ?? Date.now;
4446
4758
  this.#randomId = options.randomId ?? randomUUID3;
4447
4759
  }
4760
+ async#observeAssertions(capture) {
4761
+ if (capture.assertions.length === 0)
4762
+ return [];
4763
+ const absent2 = capture.assertions.map((name) => ({
4764
+ name,
4765
+ status: "absent"
4766
+ }));
4767
+ if (!capture.resultsPath || !this.#readReport)
4768
+ return absent2;
4769
+ let report;
4770
+ try {
4771
+ report = await this.#readReport(capture.workspace, capture.resultsPath);
4772
+ } catch {
4773
+ return absent2;
4774
+ }
4775
+ if (!report || report.modifiedMs <= capture.armedAt)
4776
+ return absent2;
4777
+ return observeAssertions(capture.assertions, report.text);
4778
+ }
4448
4779
  #prune() {
4449
4780
  const cutoff = this.#now() - CAPTURE_TTL_MS;
4450
4781
  for (const [sessionID, capture] of this.#pending) {
@@ -4502,12 +4833,17 @@ class ValidationCaptureCoordinator {
4502
4833
  const observedExit = exitCode(output.metadata);
4503
4834
  const observedComplete = completeOutput(output.metadata);
4504
4835
  const hostGap = observedExit === null ? "exit-code-unavailable" : observedComplete === null ? "output-completeness-unknown" : null;
4836
+ const observedAssertions = await this.#observeAssertions(capture);
4505
4837
  const observation = await this.#persist(capture.workspace, {
4506
4838
  featureId: capture.featureId,
4507
4839
  runId: capture.runId,
4508
4840
  command: capture.command,
4509
4841
  scope: capture.scope,
4510
4842
  sourceDigest: capture.sourceDigest,
4843
+ hostPlatform: capture.hostPlatform,
4844
+ assertions: capture.assertions,
4845
+ resultsPath: capture.resultsPath,
4846
+ ...observedAssertions.length > 0 ? { observedAssertions } : {},
4511
4847
  captureId: capture.captureId,
4512
4848
  exitCode: observedExit,
4513
4849
  outputDigest: digest(output.output),
@@ -4521,6 +4857,7 @@ class ValidationCaptureCoordinator {
4521
4857
  scope: observation.scope,
4522
4858
  passed: isValidationEligible(observation),
4523
4859
  recordedRevision: observation.recordedRevision,
4860
+ ...observation.observedAssertions ? { assertions: observation.observedAssertions } : {},
4524
4861
  ...observation.ineligibleReason ? { ineligibleReason: observation.ineligibleReason } : {}
4525
4862
  })}`;
4526
4863
  return observation;
@@ -4602,6 +4939,9 @@ function createCommandHook(assertOperational, autoDrive) {
4602
4939
  if (command !== "flow-auto")
4603
4940
  return void autoDrive.deactivate(input.sessionID);
4604
4941
  const metadata = await autoDrive.activate(input.sessionID);
4942
+ if (autoDrive.continuationSupport() === "unsupported") {
4943
+ 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."));
4944
+ }
4605
4945
  const instruction = output.parts.find((part) => part.type === "text" && part.synthetic === true);
4606
4946
  if (!instruction) {
4607
4947
  autoDrive.deactivate(input.sessionID);
@@ -4702,12 +5042,14 @@ var FlowPlugin = async (ctx) => {
4702
5042
  onWarning: (message) => log("warn", message)
4703
5043
  });
4704
5044
  const validation = new ValidationCaptureCoordinator({
4705
- persistObservation: persistWorkspaceValidation
5045
+ persistObservation: persistWorkspaceValidation,
5046
+ readReport: readWorkspaceTestReport
4706
5047
  });
4707
5048
  const tools = createTools(ctx, {
4708
5049
  validation,
4709
5050
  prepareValidation: prepareWorkspaceValidation,
4710
- autoTimingSnapshot: () => autoDrive.timingSnapshot()
5051
+ autoTimingSnapshot: () => autoDrive.timingSnapshot(),
5052
+ autoContinuationSupport: () => autoDrive.continuationSupport()
4711
5053
  });
4712
5054
  return {
4713
5055
  config: createConfigHook(ctx, {
@@ -4774,4 +5116,4 @@ export {
4774
5116
  plugin_default as default
4775
5117
  };
4776
5118
 
4777
- //# debugId=F9B2A9081022601764756E2164756E21
5119
+ //# debugId=50DAD2770F6274F964756E2164756E21