opencode-plugin-flow 7.0.1 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -0
- package/README.md +33 -7
- package/dist/index.js +459 -180
- package/dist/index.js.map +20 -19
- package/package.json +5 -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({
|
|
@@ -2364,7 +2550,13 @@ function reviewerProjection(session, assignmentId) {
|
|
|
2364
2550
|
targets: [...candidate.targets],
|
|
2365
2551
|
validation: [...candidate.validation],
|
|
2366
2552
|
dependsOn: [...candidate.dependsOn]
|
|
2367
|
-
}))
|
|
2553
|
+
})),
|
|
2554
|
+
...plan.gate === undefined ? {} : { gate: plan.gate },
|
|
2555
|
+
...plan.externalEvidence === undefined ? {} : {
|
|
2556
|
+
externalEvidence: plan.externalEvidence.map((entry) => ({
|
|
2557
|
+
...entry
|
|
2558
|
+
}))
|
|
2559
|
+
}
|
|
2368
2560
|
} : null,
|
|
2369
2561
|
feature: feature ?? null,
|
|
2370
2562
|
assignment,
|
|
@@ -3521,6 +3713,10 @@ var flowFeatureCompleteReplay = (workspace, input) => service(workspace).feature
|
|
|
3521
3713
|
var flowFeatureReset = (workspace, input) => service(workspace).featureReset(input);
|
|
3522
3714
|
var flowSessionClose = (workspace, input) => service(workspace).sessionClose(input);
|
|
3523
3715
|
|
|
3716
|
+
// src/infrastructure/fs/workspace-validation.ts
|
|
3717
|
+
import { readFile as readFile2, stat } from "node:fs/promises";
|
|
3718
|
+
import { isAbsolute as isAbsolute2, join as join3, relative, resolve as resolve2 } from "node:path";
|
|
3719
|
+
|
|
3524
3720
|
// src/application/prepare-validation.ts
|
|
3525
3721
|
function maximumSerializedUnusedCaptureId(session) {
|
|
3526
3722
|
const used = new Set(session.runs.flatMap((run) => run.validations.map((validation) => validation.id)));
|
|
@@ -3539,6 +3735,11 @@ function maximumSerializedObservation(session, prepared) {
|
|
|
3539
3735
|
exitCode: Number.MIN_SAFE_INTEGER,
|
|
3540
3736
|
outputDigest: prepared.sourceDigest,
|
|
3541
3737
|
outputComplete: false,
|
|
3738
|
+
hostPlatform: LONGEST_EVIDENCE_PLATFORM,
|
|
3739
|
+
observedAssertions: prepared.assertions.map((name) => ({
|
|
3740
|
+
name,
|
|
3741
|
+
status: "skipped"
|
|
3742
|
+
})),
|
|
3542
3743
|
ineligibleReason: LONGEST_VALIDATION_INELIGIBLE_REASON
|
|
3543
3744
|
};
|
|
3544
3745
|
}
|
|
@@ -3546,7 +3747,7 @@ function assertValidationCanBeRecorded(session, prepared) {
|
|
|
3546
3747
|
const prospective = recordValidation(session, maximumSerializedObservation(session, prepared)).session;
|
|
3547
3748
|
SessionSchema.parse(prospective);
|
|
3548
3749
|
}
|
|
3549
|
-
async function prepareValidation(repository, input) {
|
|
3750
|
+
async function prepareValidation(repository, input, hostPlatform) {
|
|
3550
3751
|
return repository.transact(async (transaction) => {
|
|
3551
3752
|
const session = await transaction.load();
|
|
3552
3753
|
if (!session)
|
|
@@ -3561,12 +3762,18 @@ async function prepareValidation(repository, input) {
|
|
|
3561
3762
|
if (run.reviews.length > 0) {
|
|
3562
3763
|
throw new Error("Validation cannot start after review has begun.");
|
|
3563
3764
|
}
|
|
3765
|
+
if (input.resultsPath !== undefined && !isArtifactPath(input.resultsPath)) {
|
|
3766
|
+
throw new Error(`Validation results path: ${ARTIFACT_PATH_MESSAGE}`);
|
|
3767
|
+
}
|
|
3564
3768
|
const prepared = {
|
|
3565
3769
|
featureId: run.featureId,
|
|
3566
3770
|
runId: run.id,
|
|
3567
3771
|
command: input.command,
|
|
3568
3772
|
scope: input.scope,
|
|
3569
|
-
sourceDigest: await transaction.computeSourceDigest()
|
|
3773
|
+
sourceDigest: await transaction.computeSourceDigest(),
|
|
3774
|
+
hostPlatform,
|
|
3775
|
+
assertions: declaredAssertions(session, input.command),
|
|
3776
|
+
resultsPath: input.resultsPath
|
|
3570
3777
|
};
|
|
3571
3778
|
assertValidationCanBeRecorded(session, prepared);
|
|
3572
3779
|
return prepared;
|
|
@@ -3589,7 +3796,18 @@ async function persistObservedValidation(repository, input) {
|
|
|
3589
3796
|
|
|
3590
3797
|
// src/infrastructure/fs/workspace-validation.ts
|
|
3591
3798
|
function prepareWorkspaceValidation(workspace, input) {
|
|
3592
|
-
return prepareValidation(createFileSessionRepository(workspace), input);
|
|
3799
|
+
return prepareValidation(createFileSessionRepository(workspace), input, normalizeEvidencePlatform(process.platform));
|
|
3800
|
+
}
|
|
3801
|
+
async function readWorkspaceTestReport(workspace, relativePath) {
|
|
3802
|
+
const root = resolve2(workspace);
|
|
3803
|
+
const target = resolve2(join3(root, relativePath));
|
|
3804
|
+
const inside = relative(root, target);
|
|
3805
|
+
if (inside === "" || isAbsolute2(inside) || inside.split(/[\\/]/)[0] === "..")
|
|
3806
|
+
return null;
|
|
3807
|
+
const info = await stat(target).catch(() => null);
|
|
3808
|
+
if (!info?.isFile() || info.size > MAX_TEST_REPORT_BYTES)
|
|
3809
|
+
return null;
|
|
3810
|
+
return { text: await readFile2(target, "utf8"), modifiedMs: info.mtimeMs };
|
|
3593
3811
|
}
|
|
3594
3812
|
function persistWorkspaceValidation(workspace, input) {
|
|
3595
3813
|
return persistObservedValidation(createFileSessionRepository(workspace), input);
|
|
@@ -3646,6 +3864,7 @@ class AutoDriveCoordinator {
|
|
|
3646
3864
|
#timing = null;
|
|
3647
3865
|
#options;
|
|
3648
3866
|
#hostParentage = false;
|
|
3867
|
+
#hostMissingParentage = false;
|
|
3649
3868
|
constructor(options) {
|
|
3650
3869
|
this.#options = options;
|
|
3651
3870
|
}
|
|
@@ -3794,8 +4013,12 @@ class AutoDriveCoordinator {
|
|
|
3794
4013
|
${FLOW_MANAGER_KERNEL}`;
|
|
3795
4014
|
}
|
|
3796
4015
|
observeHostMessage(host, message) {
|
|
3797
|
-
if (message.role === "assistant"
|
|
3798
|
-
|
|
4016
|
+
if (message.role === "assistant") {
|
|
4017
|
+
if (message.parentID === undefined)
|
|
4018
|
+
this.#hostMissingParentage = true;
|
|
4019
|
+
else
|
|
4020
|
+
this.#hostParentage = true;
|
|
4021
|
+
}
|
|
3799
4022
|
const lease = this.#lease;
|
|
3800
4023
|
if (lease?.hostSessionId !== host)
|
|
3801
4024
|
return;
|
|
@@ -3853,6 +4076,11 @@ ${FLOW_MANAGER_KERNEL}`;
|
|
|
3853
4076
|
if (revision > point.revision)
|
|
3854
4077
|
point.advance = revision + Number(reviewerPending);
|
|
3855
4078
|
}
|
|
4079
|
+
continuationSupport() {
|
|
4080
|
+
if (this.#hostParentage)
|
|
4081
|
+
return "supported";
|
|
4082
|
+
return this.#hostMissingParentage ? "unsupported" : "unknown";
|
|
4083
|
+
}
|
|
3856
4084
|
timingSnapshot() {
|
|
3857
4085
|
const timing = this.#timing;
|
|
3858
4086
|
if (!timing)
|
|
@@ -4002,6 +4230,7 @@ function createConfigHook(ctx, options) {
|
|
|
4002
4230
|
}
|
|
4003
4231
|
applyFlowConfig(config, {
|
|
4004
4232
|
onWarning: (warning) => log("warn", warning),
|
|
4233
|
+
onNotice: (notice) => log("info", notice),
|
|
4005
4234
|
onCollision: (kind, name) => log("warn", `Flow replaced a user-defined ${kind} named '${name}'; rename the local entry while Flow is enabled.`)
|
|
4006
4235
|
});
|
|
4007
4236
|
};
|
|
@@ -4009,7 +4238,7 @@ function createConfigHook(ctx, options) {
|
|
|
4009
4238
|
|
|
4010
4239
|
// src/platform/opencode/leadership.ts
|
|
4011
4240
|
import { realpathSync as realpathSync2 } from "node:fs";
|
|
4012
|
-
import { resolve as
|
|
4241
|
+
import { resolve as resolve3 } from "node:path";
|
|
4013
4242
|
var REGISTRY_KIND = "opencode-plugin-flow.runtime-leadership";
|
|
4014
4243
|
var REGISTRY_FORMAT_VERSION = 2;
|
|
4015
4244
|
var FLOW_LEADERSHIP_PROTOCOL_VERSION = 1;
|
|
@@ -4026,7 +4255,7 @@ function validIdentity(value) {
|
|
|
4026
4255
|
function canonicalProjectId(scopeId) {
|
|
4027
4256
|
if (!boundedText2(scopeId))
|
|
4028
4257
|
throw new TypeError("Flow runtime scope must be a non-empty path.");
|
|
4029
|
-
const projectId =
|
|
4258
|
+
const projectId = resolve3(scopeId);
|
|
4030
4259
|
try {
|
|
4031
4260
|
return realpathSync2(projectId);
|
|
4032
4261
|
} catch {
|
|
@@ -4190,7 +4419,15 @@ var plan = host.object({
|
|
|
4190
4419
|
overview: text,
|
|
4191
4420
|
requirements: host.array(text).max(MAX_PLAN_FEATURES).default([]),
|
|
4192
4421
|
decisions: host.array(text).max(MAX_PLAN_FEATURES).default([]),
|
|
4193
|
-
features: host.array(planFeature).min(1).max(MAX_PLAN_FEATURES)
|
|
4422
|
+
features: host.array(planFeature).min(1).max(MAX_PLAN_FEATURES),
|
|
4423
|
+
gate: text.optional(),
|
|
4424
|
+
externalEvidence: host.array(host.object({
|
|
4425
|
+
requirement: text,
|
|
4426
|
+
environment: text,
|
|
4427
|
+
command: text,
|
|
4428
|
+
platform: host.enum(EVIDENCE_PLATFORMS).optional(),
|
|
4429
|
+
assertions: host.array(text).max(MAX_DECLARED_ASSERTIONS).optional()
|
|
4430
|
+
}).strict()).max(MAX_PLAN_FEATURES).optional()
|
|
4194
4431
|
}).strict().superRefine((value, context) => {
|
|
4195
4432
|
if (encoder2.encode(JSON.stringify(value)).byteLength > MAX_PLAN_BYTES) {
|
|
4196
4433
|
context.addIssue({
|
|
@@ -4238,7 +4475,10 @@ var ValidationStartArgs = {
|
|
|
4238
4475
|
expectedRevision: revision,
|
|
4239
4476
|
featureId,
|
|
4240
4477
|
command: text,
|
|
4241
|
-
scope: host.enum(["focused", "broad"])
|
|
4478
|
+
scope: host.enum(["focused", "broad"]),
|
|
4479
|
+
resultsPath: boundedHostText("Validation results path", {
|
|
4480
|
+
maxBytes: MAX_PATH_BYTES
|
|
4481
|
+
}).optional()
|
|
4242
4482
|
}).strict()
|
|
4243
4483
|
};
|
|
4244
4484
|
var ReviewStartArgs = {
|
|
@@ -4291,23 +4531,30 @@ function toolError(error) {
|
|
|
4291
4531
|
}
|
|
4292
4532
|
});
|
|
4293
4533
|
}
|
|
4294
|
-
function
|
|
4295
|
-
|
|
4296
|
-
return response;
|
|
4534
|
+
function withAutoContext(response, options) {
|
|
4535
|
+
let workflowData = response.workflowData;
|
|
4297
4536
|
try {
|
|
4298
|
-
const timing =
|
|
4299
|
-
if (
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4537
|
+
const timing = options.autoTimingSnapshot?.();
|
|
4538
|
+
if (timing)
|
|
4539
|
+
workflowData = { ...workflowData, autoTiming: timing };
|
|
4540
|
+
} catch {}
|
|
4541
|
+
try {
|
|
4542
|
+
const support = options.autoContinuationSupport?.();
|
|
4543
|
+
if (support === "supported" || support === "unsupported") {
|
|
4544
|
+
workflowData = {
|
|
4545
|
+
...workflowData,
|
|
4546
|
+
autoContinuation: {
|
|
4547
|
+
scope: "current-plugin-process",
|
|
4548
|
+
support,
|
|
4549
|
+
...support === "unsupported" ? {
|
|
4550
|
+
reason: "host-reports-no-assistant-message-parentage",
|
|
4551
|
+
recovery: "Drive each feature with /flow-run."
|
|
4552
|
+
} : {}
|
|
4553
|
+
}
|
|
4554
|
+
};
|
|
4555
|
+
}
|
|
4556
|
+
} catch {}
|
|
4557
|
+
return workflowData === response.workflowData ? response : { ...response, workflowData };
|
|
4311
4558
|
}
|
|
4312
4559
|
async function execute(context, handler) {
|
|
4313
4560
|
try {
|
|
@@ -4336,7 +4583,7 @@ function createTools(_ctx, options) {
|
|
|
4336
4583
|
flow_status: tool({
|
|
4337
4584
|
description: "Read compact, execution, detail, or reviewer Flow state.",
|
|
4338
4585
|
args: StatusArgs,
|
|
4339
|
-
execute: (args, context) => execute(context, async (workspace) =>
|
|
4586
|
+
execute: (args, context) => execute(context, async (workspace) => withAutoContext(await flowStatus(workspace, args), options))
|
|
4340
4587
|
}),
|
|
4341
4588
|
flow_plan_save: tool({
|
|
4342
4589
|
description: "Create or replace the active draft plan.",
|
|
@@ -4437,14 +4684,35 @@ function isBash(tool2) {
|
|
|
4437
4684
|
|
|
4438
4685
|
class ValidationCaptureCoordinator {
|
|
4439
4686
|
#persist;
|
|
4687
|
+
#readReport;
|
|
4440
4688
|
#now;
|
|
4441
4689
|
#randomId;
|
|
4442
4690
|
#pending = new Map;
|
|
4443
4691
|
constructor(options) {
|
|
4444
4692
|
this.#persist = options.persistObservation;
|
|
4693
|
+
this.#readReport = options.readReport;
|
|
4445
4694
|
this.#now = options.now ?? Date.now;
|
|
4446
4695
|
this.#randomId = options.randomId ?? randomUUID3;
|
|
4447
4696
|
}
|
|
4697
|
+
async#observeAssertions(capture) {
|
|
4698
|
+
if (capture.assertions.length === 0)
|
|
4699
|
+
return [];
|
|
4700
|
+
const absent2 = capture.assertions.map((name) => ({
|
|
4701
|
+
name,
|
|
4702
|
+
status: "absent"
|
|
4703
|
+
}));
|
|
4704
|
+
if (!capture.resultsPath || !this.#readReport)
|
|
4705
|
+
return absent2;
|
|
4706
|
+
let report;
|
|
4707
|
+
try {
|
|
4708
|
+
report = await this.#readReport(capture.workspace, capture.resultsPath);
|
|
4709
|
+
} catch {
|
|
4710
|
+
return absent2;
|
|
4711
|
+
}
|
|
4712
|
+
if (!report || report.modifiedMs <= capture.armedAt)
|
|
4713
|
+
return absent2;
|
|
4714
|
+
return observeAssertions(capture.assertions, report.text);
|
|
4715
|
+
}
|
|
4448
4716
|
#prune() {
|
|
4449
4717
|
const cutoff = this.#now() - CAPTURE_TTL_MS;
|
|
4450
4718
|
for (const [sessionID, capture] of this.#pending) {
|
|
@@ -4502,12 +4770,17 @@ class ValidationCaptureCoordinator {
|
|
|
4502
4770
|
const observedExit = exitCode(output.metadata);
|
|
4503
4771
|
const observedComplete = completeOutput(output.metadata);
|
|
4504
4772
|
const hostGap = observedExit === null ? "exit-code-unavailable" : observedComplete === null ? "output-completeness-unknown" : null;
|
|
4773
|
+
const observedAssertions = await this.#observeAssertions(capture);
|
|
4505
4774
|
const observation = await this.#persist(capture.workspace, {
|
|
4506
4775
|
featureId: capture.featureId,
|
|
4507
4776
|
runId: capture.runId,
|
|
4508
4777
|
command: capture.command,
|
|
4509
4778
|
scope: capture.scope,
|
|
4510
4779
|
sourceDigest: capture.sourceDigest,
|
|
4780
|
+
hostPlatform: capture.hostPlatform,
|
|
4781
|
+
assertions: capture.assertions,
|
|
4782
|
+
resultsPath: capture.resultsPath,
|
|
4783
|
+
...observedAssertions.length > 0 ? { observedAssertions } : {},
|
|
4511
4784
|
captureId: capture.captureId,
|
|
4512
4785
|
exitCode: observedExit,
|
|
4513
4786
|
outputDigest: digest(output.output),
|
|
@@ -4521,6 +4794,7 @@ class ValidationCaptureCoordinator {
|
|
|
4521
4794
|
scope: observation.scope,
|
|
4522
4795
|
passed: isValidationEligible(observation),
|
|
4523
4796
|
recordedRevision: observation.recordedRevision,
|
|
4797
|
+
...observation.observedAssertions ? { assertions: observation.observedAssertions } : {},
|
|
4524
4798
|
...observation.ineligibleReason ? { ineligibleReason: observation.ineligibleReason } : {}
|
|
4525
4799
|
})}`;
|
|
4526
4800
|
return observation;
|
|
@@ -4602,6 +4876,9 @@ function createCommandHook(assertOperational, autoDrive) {
|
|
|
4602
4876
|
if (command !== "flow-auto")
|
|
4603
4877
|
return void autoDrive.deactivate(input.sessionID);
|
|
4604
4878
|
const metadata = await autoDrive.activate(input.sessionID);
|
|
4879
|
+
if (autoDrive.continuationSupport() === "unsupported") {
|
|
4880
|
+
output.parts.unshift(textPart("Note: this OpenCode host does not report assistant message parentage, so Flow cannot continue automatically between features here. Each feature still runs normally; drive the next one with /flow-run."));
|
|
4881
|
+
}
|
|
4605
4882
|
const instruction = output.parts.find((part) => part.type === "text" && part.synthetic === true);
|
|
4606
4883
|
if (!instruction) {
|
|
4607
4884
|
autoDrive.deactivate(input.sessionID);
|
|
@@ -4702,12 +4979,14 @@ var FlowPlugin = async (ctx) => {
|
|
|
4702
4979
|
onWarning: (message) => log("warn", message)
|
|
4703
4980
|
});
|
|
4704
4981
|
const validation = new ValidationCaptureCoordinator({
|
|
4705
|
-
persistObservation: persistWorkspaceValidation
|
|
4982
|
+
persistObservation: persistWorkspaceValidation,
|
|
4983
|
+
readReport: readWorkspaceTestReport
|
|
4706
4984
|
});
|
|
4707
4985
|
const tools = createTools(ctx, {
|
|
4708
4986
|
validation,
|
|
4709
4987
|
prepareValidation: prepareWorkspaceValidation,
|
|
4710
|
-
autoTimingSnapshot: () => autoDrive.timingSnapshot()
|
|
4988
|
+
autoTimingSnapshot: () => autoDrive.timingSnapshot(),
|
|
4989
|
+
autoContinuationSupport: () => autoDrive.continuationSupport()
|
|
4711
4990
|
});
|
|
4712
4991
|
return {
|
|
4713
4992
|
config: createConfigHook(ctx, {
|
|
@@ -4774,4 +5053,4 @@ export {
|
|
|
4774
5053
|
plugin_default as default
|
|
4775
5054
|
};
|
|
4776
5055
|
|
|
4777
|
-
//# debugId=
|
|
5056
|
+
//# debugId=59C59901BAE4E80164756E2164756E21
|