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/CHANGELOG.md +187 -0
- package/README.md +34 -7
- package/dist/index.js +557 -215
- package/dist/index.js.map +21 -20
- package/package.json +6 -1
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
492
|
-
|
|
493
|
-
|
|
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.
|
|
505
|
-
|
|
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"\`
|
|
551
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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,
|
|
570
|
-
half the move.
|
|
571
|
-
closure. Ending the turn having done neither
|
|
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
|
|
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
|
-
|
|
581
|
-
invalidates earlier applicability. Failed or unavailable checks are blockers,
|
|
582
|
-
|
|
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
|
-
|
|
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(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/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
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
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
|
|
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
|
-
...
|
|
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: ${
|
|
2193
|
-
`- superseded attempts only: ${
|
|
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("
|
|
2199
|
-
|
|
2200
|
-
const
|
|
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
|
|
2206
|
-
const latestArtifacts = new Set(
|
|
2207
|
-
const allArtifacts = new Set(session.runs.flatMap((run) => run.artifactsChanged.map((
|
|
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
|
-
|
|
2213
|
-
|
|
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
|
-
|
|
2216
|
-
|
|
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:
|
|
2224
|
-
outcomeSummary:
|
|
2225
|
-
terminalFindings:
|
|
2226
|
-
severity
|
|
2227
|
-
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"
|
|
3798
|
-
|
|
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
|
|
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 =
|
|
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
|
|
4295
|
-
|
|
4296
|
-
return response;
|
|
4597
|
+
function withAutoContext(response, options) {
|
|
4598
|
+
let workflowData = response.workflowData;
|
|
4297
4599
|
try {
|
|
4298
|
-
const timing =
|
|
4299
|
-
if (
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
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) =>
|
|
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=
|
|
5119
|
+
//# debugId=50DAD2770F6274F964756E2164756E21
|