@hecer/yoke 1.20.0 → 1.22.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +6 -1
- package/bench/analyze-codex-comparison.mjs +90 -17
- package/bench/compare-codex.mjs +159 -36
- package/bench/result-schema.mjs +132 -0
- package/canon/manifest.yaml +1 -1
- package/dist/agents/pi-telemetry.js +2 -1
- package/dist/agents/process-streams.js +12 -64
- package/dist/agents/provider-selection.js +12 -0
- package/dist/agents/telemetry.js +52 -52
- package/dist/change/inbox.js +8 -3
- package/dist/check/command.js +69 -17
- package/dist/check/delivery.js +121 -0
- package/dist/cli.js +86 -8
- package/dist/code-intelligence/budgets.js +138 -0
- package/dist/code-intelligence/contracts.js +2 -0
- package/dist/code-intelligence/coordinator.js +156 -84
- package/dist/code-intelligence/evidence.js +87 -34
- package/dist/code-intelligence/mcp-client.js +10 -2
- package/dist/code-intelligence/mcp-server.js +10 -10
- package/dist/dashboard/analytics.js +5 -3
- package/dist/goals/command.js +183 -53
- package/dist/goals/usage.js +87 -0
- package/dist/loop/candidate-cleanup.js +47 -17
- package/dist/loop/candidates.js +17 -11
- package/dist/loop/cleanup.js +100 -0
- package/dist/loop/dispatcher.js +89 -26
- package/dist/loop/failure.js +104 -0
- package/dist/loop/gate-snapshot.js +19 -0
- package/dist/loop/git.js +1 -1
- package/dist/loop/loop.js +100 -66
- package/dist/loop/parallel-adapters.js +22 -4
- package/dist/loop/parallel-command.js +32 -5
- package/dist/loop/recovery.js +23 -5
- package/dist/loop/reporter.js +21 -4
- package/dist/loop/run-command.js +102 -48
- package/dist/loop/runner.js +5 -4
- package/dist/loop/worker.js +145 -91
- package/dist/observability/history.js +1 -1
- package/dist/observability/invocation.js +42 -0
- package/dist/observability/usage.js +17 -0
- package/dist/prd/command.js +20 -7
- package/dist/prd/decompose.js +5 -2
- package/dist/retrofit/command.js +22 -0
- package/dist/retrofit/config.js +26 -1
- package/dist/retrofit/gitignore.js +2 -0
- package/dist/retrofit/planners/claude.js +4 -4
- package/dist/retrofit/wsl.js +23 -3
- package/dist/routing/attempts.js +241 -0
- package/dist/routing/capability.js +13 -9
- package/dist/routing/optimization.js +73 -0
- package/dist/routing/registry.js +7 -1
- package/dist/routing/router.js +280 -127
- package/dist/setup/command.js +54 -6
- package/dist/smoke/command.js +302 -74
- package/docs/BENCHMARK-MANIFEST.md +131 -0
- package/docs/CODE-INTELLIGENCE.md +43 -1
- package/docs/CODEX-COMPARISON-2026-09-29.md +15 -0
- package/docs/DELIVERY-JOURNEYS.md +199 -0
- package/docs/ECONOMIC-ROUTING.md +180 -0
- package/docs/GOALS.md +61 -4
- package/docs/RELEASE-VALIDATION-1.22.0.md +115 -0
- package/docs/parallel-execution.md +37 -9
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
|
@@ -39,10 +39,11 @@ export function createPiTelemetry() {
|
|
|
39
39
|
if (complete) {
|
|
40
40
|
// Optional totals must also cover every turn; omitted is not measured zero.
|
|
41
41
|
const measured = Object.fromEntries(Object.entries(totals).filter(([key]) => counts[key] === turns));
|
|
42
|
+
const partial = Object.fromEntries(Object.entries(totals).filter(([key]) => counts[key] !== turns));
|
|
42
43
|
return { usageAvailable: true, tokens: {
|
|
43
44
|
...measured, inputTokens: totals.inputTokens, outputTokens: totals.outputTokens,
|
|
44
45
|
...(reportedModels.length === 1 ? { model: reportedModels[0] } : {}),
|
|
45
|
-
}, ...(reportedModels.length > 1 ? { reportedModels } : {}) };
|
|
46
|
+
}, ...(Object.keys(partial).length ? { partialUsage: partial } : {}), ...(reportedModels.length > 1 ? { reportedModels } : {}) };
|
|
46
47
|
}
|
|
47
48
|
return { usageAvailable: false,
|
|
48
49
|
...(Object.keys(totals).length ? { partialUsage: { ...totals } } : {}),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseProviderTelemetry } from './telemetry.js';
|
|
1
|
+
import { createStepTelemetry, parseProviderTelemetry } from './telemetry.js';
|
|
2
2
|
import { createPiTelemetry } from './pi-telemetry.js';
|
|
3
3
|
export function createBoundedOutput(limitBytes) {
|
|
4
4
|
let text = '';
|
|
@@ -22,11 +22,17 @@ export function createTelemetryAccumulator(agent) {
|
|
|
22
22
|
let trailing = '';
|
|
23
23
|
let telemetry = { usageAvailable: false };
|
|
24
24
|
let reportedModels = [];
|
|
25
|
-
const
|
|
26
|
-
? { input: 0, output: 0, cached: 0, cacheWrite: 0, reasoning: 0, cost: 0, hasInput: false, hasOutput: false, hasCached: false, hasCacheWrite: false, hasReasoning: false, hasCost: false }
|
|
27
|
-
: undefined;
|
|
25
|
+
const steps = agent === 'opencode' || agent === 'kilo' ? createStepTelemetry() : undefined;
|
|
28
26
|
const update = (lines) => {
|
|
29
27
|
for (const line of lines) {
|
|
28
|
+
if (steps) {
|
|
29
|
+
try {
|
|
30
|
+
const event = JSON.parse(line);
|
|
31
|
+
if (event && typeof event === 'object' && !Array.isArray(event))
|
|
32
|
+
steps.consume(event);
|
|
33
|
+
}
|
|
34
|
+
catch { /* non-JSON diagnostics carry no usage */ }
|
|
35
|
+
}
|
|
30
36
|
if (pi) {
|
|
31
37
|
try {
|
|
32
38
|
const event = JSON.parse(line);
|
|
@@ -45,35 +51,6 @@ export function createTelemetryAccumulator(agent) {
|
|
|
45
51
|
// never add it to earlier results or to assistant-message snapshots.
|
|
46
52
|
if (next.tokens || next.partialUsage)
|
|
47
53
|
telemetry = next;
|
|
48
|
-
if (stepTotals && isStepFinish(line)) {
|
|
49
|
-
const usage = next.tokens ?? next.partialUsage;
|
|
50
|
-
if (usage) {
|
|
51
|
-
if (usage.inputTokens !== undefined) {
|
|
52
|
-
stepTotals.input += usage.inputTokens;
|
|
53
|
-
stepTotals.hasInput = true;
|
|
54
|
-
}
|
|
55
|
-
if (usage.outputTokens !== undefined) {
|
|
56
|
-
stepTotals.output += usage.outputTokens;
|
|
57
|
-
stepTotals.hasOutput = true;
|
|
58
|
-
}
|
|
59
|
-
if (usage.cachedInputTokens !== undefined) {
|
|
60
|
-
stepTotals.cached += usage.cachedInputTokens;
|
|
61
|
-
stepTotals.hasCached = true;
|
|
62
|
-
}
|
|
63
|
-
if (usage.cacheWriteInputTokens !== undefined) {
|
|
64
|
-
stepTotals.cacheWrite += usage.cacheWriteInputTokens;
|
|
65
|
-
stepTotals.hasCacheWrite = true;
|
|
66
|
-
}
|
|
67
|
-
if (usage.reasoningOutputTokens !== undefined) {
|
|
68
|
-
stepTotals.reasoning += usage.reasoningOutputTokens;
|
|
69
|
-
stepTotals.hasReasoning = true;
|
|
70
|
-
}
|
|
71
|
-
if (usage.totalCostUsd !== undefined) {
|
|
72
|
-
stepTotals.cost += usage.totalCostUsd;
|
|
73
|
-
stepTotals.hasCost = true;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
54
|
}
|
|
78
55
|
};
|
|
79
56
|
return {
|
|
@@ -88,42 +65,13 @@ export function createTelemetryAccumulator(agent) {
|
|
|
88
65
|
trailing = '';
|
|
89
66
|
if (pi)
|
|
90
67
|
return pi.finish();
|
|
91
|
-
|
|
92
|
-
const latest = telemetry.tokens;
|
|
93
|
-
const inputTokens = stepTotals.hasInput ? stepTotals.input : latest?.inputTokens;
|
|
94
|
-
const outputTokens = stepTotals.hasOutput ? stepTotals.output : latest?.outputTokens;
|
|
95
|
-
const partialUsage = {
|
|
96
|
-
...(inputTokens !== undefined ? { inputTokens } : {}),
|
|
97
|
-
...(outputTokens !== undefined ? { outputTokens } : {}),
|
|
98
|
-
...(stepTotals.hasCached ? { cachedInputTokens: stepTotals.cached } : latest?.cachedInputTokens !== undefined ? { cachedInputTokens: latest.cachedInputTokens } : {}),
|
|
99
|
-
...(stepTotals.hasCacheWrite ? { cacheWriteInputTokens: stepTotals.cacheWrite } : latest?.cacheWriteInputTokens !== undefined ? { cacheWriteInputTokens: latest.cacheWriteInputTokens } : {}),
|
|
100
|
-
...(stepTotals.hasReasoning ? { reasoningOutputTokens: stepTotals.reasoning } : latest?.reasoningOutputTokens !== undefined ? { reasoningOutputTokens: latest.reasoningOutputTokens } : {}),
|
|
101
|
-
...(stepTotals.hasCost ? { totalCostUsd: stepTotals.cost } : latest?.totalCostUsd !== undefined ? { totalCostUsd: latest.totalCostUsd } : {}),
|
|
102
|
-
...(latest?.model ? { model: latest.model } : {}),
|
|
103
|
-
};
|
|
104
|
-
if (inputTokens !== undefined && outputTokens !== undefined) {
|
|
105
|
-
telemetry = { usageAvailable: true, tokens: { ...partialUsage, inputTokens, outputTokens } };
|
|
106
|
-
}
|
|
107
|
-
else {
|
|
108
|
-
telemetry = { usageAvailable: false, partialUsage };
|
|
109
|
-
}
|
|
110
|
-
}
|
|
68
|
+
telemetry = steps?.finish() ?? telemetry;
|
|
111
69
|
if (telemetry.tokens) {
|
|
112
70
|
const { model: _model, ...tokens } = telemetry.tokens;
|
|
113
|
-
return {
|
|
71
|
+
return { ...telemetry, tokens: { ...tokens, ...(reportedModels.length === 1 ? { model: reportedModels[0] } : {}) },
|
|
114
72
|
...(reportedModels.length > 1 ? { reportedModels } : {}) };
|
|
115
73
|
}
|
|
116
74
|
return { ...telemetry, ...(reportedModels.length ? { reportedModels } : {}) };
|
|
117
75
|
},
|
|
118
76
|
};
|
|
119
77
|
}
|
|
120
|
-
function isStepFinish(line) {
|
|
121
|
-
try {
|
|
122
|
-
const value = JSON.parse(line);
|
|
123
|
-
const part = value.part && typeof value.part === 'object' ? value.part : undefined;
|
|
124
|
-
return value.type === 'step_finish' || part?.type === 'step-finish';
|
|
125
|
-
}
|
|
126
|
-
catch {
|
|
127
|
-
return false;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Runner defaults belong to the runner that selected them. Undefined overrides
|
|
2
|
+
* are absent; explicit false values (for example bare: false) still override. */
|
|
3
|
+
export function resolveProviderSelection(agent, defaults, overrides = {}) {
|
|
4
|
+
const inherited = defaults && (defaults.agent ?? 'codex') === agent ? defaults : {};
|
|
5
|
+
const result = {};
|
|
6
|
+
for (const key of ['provider', 'model', 'reasoningEffort', 'variant', 'bare', 'nativeMultiAgent']) {
|
|
7
|
+
const value = overrides[key] ?? inherited[key];
|
|
8
|
+
if (value !== undefined)
|
|
9
|
+
Object.assign(result, { [key]: value });
|
|
10
|
+
}
|
|
11
|
+
return result;
|
|
12
|
+
}
|
package/dist/agents/telemetry.js
CHANGED
|
@@ -13,6 +13,50 @@ function parseJson(value) {
|
|
|
13
13
|
function isRecord(value) {
|
|
14
14
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
15
15
|
}
|
|
16
|
+
/** OpenCode-family usage belongs to individual completed steps. Each field needs
|
|
17
|
+
* coverage of every step before it can be called a total. Shared by batch and
|
|
18
|
+
* streaming readers so an omitted measurement cannot turn into a measured zero. */
|
|
19
|
+
export function createStepTelemetry() {
|
|
20
|
+
let steps = 0;
|
|
21
|
+
const totals = {};
|
|
22
|
+
const counts = {};
|
|
23
|
+
return {
|
|
24
|
+
consume(event) {
|
|
25
|
+
const part = isRecord(event.part) ? event.part : undefined;
|
|
26
|
+
if (event.type !== 'step_finish' && part?.type !== 'step-finish')
|
|
27
|
+
return;
|
|
28
|
+
steps++;
|
|
29
|
+
const tokens = isRecord(part?.tokens) ? part.tokens : {};
|
|
30
|
+
const cache = isRecord(tokens.cache) ? tokens.cache : undefined;
|
|
31
|
+
const fields = {
|
|
32
|
+
inputTokens: tokens.input,
|
|
33
|
+
outputTokens: tokens.output,
|
|
34
|
+
cachedInputTokens: tokens.cached ?? tokens.cacheRead ?? cache?.read,
|
|
35
|
+
cacheWriteInputTokens: tokens.cacheWrite ?? cache?.write,
|
|
36
|
+
reasoningOutputTokens: tokens.reasoning,
|
|
37
|
+
totalCostUsd: part?.cost ?? event.cost,
|
|
38
|
+
};
|
|
39
|
+
for (const [field, raw] of Object.entries(fields)) {
|
|
40
|
+
const value = finite(raw);
|
|
41
|
+
if (value === undefined)
|
|
42
|
+
continue;
|
|
43
|
+
totals[field] = (totals[field] ?? 0) + value;
|
|
44
|
+
counts[field] = (counts[field] ?? 0) + 1;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
finish() {
|
|
48
|
+
if (!steps)
|
|
49
|
+
return undefined;
|
|
50
|
+
const complete = counts.inputTokens === steps && counts.outputTokens === steps;
|
|
51
|
+
if (!complete)
|
|
52
|
+
return { usageAvailable: false, ...(Object.keys(totals).length ? { partialUsage: { ...totals } } : {}) };
|
|
53
|
+
const measured = Object.fromEntries(Object.entries(totals).filter(([field]) => counts[field] === steps));
|
|
54
|
+
const partialUsage = Object.fromEntries(Object.entries(totals).filter(([field]) => counts[field] !== steps));
|
|
55
|
+
return { usageAvailable: true, tokens: { ...measured, inputTokens: totals.inputTokens, outputTokens: totals.outputTokens },
|
|
56
|
+
...(Object.keys(partialUsage).length ? { partialUsage } : {}) };
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
16
60
|
function textContent(value) {
|
|
17
61
|
if (typeof value === 'string')
|
|
18
62
|
return value;
|
|
@@ -154,9 +198,7 @@ export function parseProviderTelemetry(agent, lines) {
|
|
|
154
198
|
let totalCostUsd;
|
|
155
199
|
let model;
|
|
156
200
|
let reportedModels = [];
|
|
157
|
-
const
|
|
158
|
-
? { input: 0, output: 0, cached: 0, cacheWrite: 0, reasoning: 0, cost: 0, hasInput: false, hasOutput: false, hasCached: false, hasCacheWrite: false, hasReasoning: false, hasCost: false }
|
|
159
|
-
: undefined;
|
|
201
|
+
const steps = agent === 'opencode' || agent === 'kilo' ? createStepTelemetry() : undefined;
|
|
160
202
|
for (const line of lines) {
|
|
161
203
|
let parsed;
|
|
162
204
|
try {
|
|
@@ -168,45 +210,11 @@ export function parseProviderTelemetry(agent, lines) {
|
|
|
168
210
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
169
211
|
continue;
|
|
170
212
|
const event = parsed;
|
|
213
|
+
steps?.consume(event);
|
|
171
214
|
if ((agent === 'qwen' || agent === 'claude') && event.parent_tool_use_id != null)
|
|
172
215
|
continue;
|
|
173
216
|
const message = event.message && typeof event.message === 'object' ? event.message : undefined;
|
|
174
217
|
const stats = event.stats && typeof event.stats === 'object' ? event.stats : undefined;
|
|
175
|
-
const part = event.part && typeof event.part === 'object' ? event.part : undefined;
|
|
176
|
-
const partTokens = part?.tokens && typeof part.tokens === 'object' ? part.tokens : undefined;
|
|
177
|
-
if (harnessTotals && (event.type === 'step_finish' || part?.type === 'step-finish') && partTokens) {
|
|
178
|
-
const cache = partTokens.cache && typeof partTokens.cache === 'object' ? partTokens.cache : undefined;
|
|
179
|
-
const stepInput = finite(partTokens.input);
|
|
180
|
-
const stepOutput = finite(partTokens.output);
|
|
181
|
-
const stepCached = finite(partTokens.cached ?? partTokens.cacheRead ?? cache?.read);
|
|
182
|
-
const stepCacheWrite = finite(partTokens.cacheWrite ?? cache?.write);
|
|
183
|
-
const stepReasoning = finite(partTokens.reasoning);
|
|
184
|
-
const stepCost = finite(part?.cost ?? event.cost);
|
|
185
|
-
if (stepInput !== undefined) {
|
|
186
|
-
harnessTotals.input += stepInput;
|
|
187
|
-
harnessTotals.hasInput = true;
|
|
188
|
-
}
|
|
189
|
-
if (stepOutput !== undefined) {
|
|
190
|
-
harnessTotals.output += stepOutput;
|
|
191
|
-
harnessTotals.hasOutput = true;
|
|
192
|
-
}
|
|
193
|
-
if (stepCached !== undefined) {
|
|
194
|
-
harnessTotals.cached += stepCached;
|
|
195
|
-
harnessTotals.hasCached = true;
|
|
196
|
-
}
|
|
197
|
-
if (stepCacheWrite !== undefined) {
|
|
198
|
-
harnessTotals.cacheWrite += stepCacheWrite;
|
|
199
|
-
harnessTotals.hasCacheWrite = true;
|
|
200
|
-
}
|
|
201
|
-
if (stepReasoning !== undefined) {
|
|
202
|
-
harnessTotals.reasoning += stepReasoning;
|
|
203
|
-
harnessTotals.hasReasoning = true;
|
|
204
|
-
}
|
|
205
|
-
if (stepCost !== undefined) {
|
|
206
|
-
harnessTotals.cost += stepCost;
|
|
207
|
-
harnessTotals.hasCost = true;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
218
|
const usage = (event.usage && typeof event.usage === 'object'
|
|
211
219
|
? event.usage
|
|
212
220
|
: message?.usage && typeof message.usage === 'object'
|
|
@@ -277,20 +285,12 @@ export function parseProviderTelemetry(agent, lines) {
|
|
|
277
285
|
if (typeof eventModel === 'string' && eventModel && reportedModels.length <= 1)
|
|
278
286
|
model = eventModel;
|
|
279
287
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
cachedInputTokens = harnessTotals.cached;
|
|
287
|
-
if (harnessTotals.hasCacheWrite)
|
|
288
|
-
cacheWriteInputTokens = harnessTotals.cacheWrite;
|
|
289
|
-
if (harnessTotals.hasReasoning)
|
|
290
|
-
reasoningOutputTokens = harnessTotals.reasoning;
|
|
291
|
-
if (harnessTotals.hasCost)
|
|
292
|
-
totalCostUsd = harnessTotals.cost;
|
|
293
|
-
}
|
|
288
|
+
const stepUsage = steps?.finish();
|
|
289
|
+
if (stepUsage)
|
|
290
|
+
return { ...stepUsage,
|
|
291
|
+
...(stepUsage.tokens && model ? { tokens: { ...stepUsage.tokens, model } } : {}),
|
|
292
|
+
...(!stepUsage.tokens && model ? { reportedModels: [model] } : {}),
|
|
293
|
+
};
|
|
294
294
|
if (inputTokens === undefined || outputTokens === undefined) {
|
|
295
295
|
const partialUsage = {
|
|
296
296
|
...(inputTokens !== undefined ? { inputTokens } : {}),
|
package/dist/change/inbox.js
CHANGED
|
@@ -9,8 +9,9 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, w
|
|
|
9
9
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { criterionCommandProblem, isAcceptanceCriterion, loadPrd, parsePrd, savePrd, validateDependencies } from '../loop/prd.js';
|
|
12
|
-
import { agentInvocation, buildWatchdogInvocation, isAgentAvailable,
|
|
12
|
+
import { agentInvocation, buildWatchdogInvocation, isAgentAvailable, runCapturedAgent, } from '../loop/runner.js';
|
|
13
13
|
import { commitPaths } from '../loop/git.js';
|
|
14
|
+
import { measureInvocation } from '../observability/invocation.js';
|
|
14
15
|
const ChangeRequestSchema = z.object({
|
|
15
16
|
version: z.literal(1),
|
|
16
17
|
id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
@@ -184,7 +185,9 @@ export function runChangeApply(targetDir, opts) {
|
|
|
184
185
|
rmSync(proposal, { force: true });
|
|
185
186
|
const base = agentInvocation(opts.runner, buildChangePrompt(request, proposal, existing, brief), plannerDir, opts.permissions ?? 'safe', opts.selection);
|
|
186
187
|
const invocation = buildWatchdogInvocation(base, opts.timeoutMs ?? 0);
|
|
187
|
-
const result = (opts.
|
|
188
|
+
const result = measureInvocation({ root: targetDir, agent: opts.runner, role: 'change-planner', storyId: request.id, runId: `change:${request.id}`, selection: opts.selection, invocation,
|
|
189
|
+
execute: opts.run ?? (item => runCapturedAgent(opts.runner, item)),
|
|
190
|
+
});
|
|
188
191
|
if (!result.success)
|
|
189
192
|
return { ok: false, added: 0, summary: `planner failed: ${result.summary}`, changeId: request.id };
|
|
190
193
|
if (readFileSync(prdPath, 'utf8') !== existingText || (readPlanningFile(targetDir, '.yoke/plan.md', 80_000) ?? '') !== brief) {
|
|
@@ -246,7 +249,9 @@ export function runChangeApply(targetDir, opts) {
|
|
|
246
249
|
rmSync(reviewPath, { force: true });
|
|
247
250
|
const reviewBase = agentInvocation(reviewer, buildChangeReviewPrompt(request, reviewPath, appended), plannerDir, opts.permissions ?? 'safe', reviewer === opts.runner ? opts.selection : undefined);
|
|
248
251
|
const reviewInvocation = buildWatchdogInvocation(reviewBase, opts.timeoutMs ?? 0);
|
|
249
|
-
const reviewResult = (opts.
|
|
252
|
+
const reviewResult = measureInvocation({ root: targetDir, agent: reviewer, role: 'coverage-review', storyId: request.id, runId: `change:${request.id}`, selection: reviewer === opts.runner ? opts.selection : undefined, invocation: reviewInvocation,
|
|
253
|
+
execute: opts.review ?? (item => runCapturedAgent(reviewer, item)),
|
|
254
|
+
});
|
|
250
255
|
if (!reviewResult.success) {
|
|
251
256
|
return { ok: false, added: 0, summary: `coverage review failed: ${reviewResult.summary}`, changeId: request.id };
|
|
252
257
|
}
|
package/dist/check/command.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
5
|
import { parse } from 'yaml';
|
|
@@ -15,10 +15,17 @@ import { writeOutputArtifact } from '../output/artifact.js';
|
|
|
15
15
|
import { DEFAULT_OUTPUT_POLICY } from '../output/types.js';
|
|
16
16
|
import { createProviderProcessRecord, filesystemProviderProcessRecordAdapter } from '../agents/process-record.js';
|
|
17
17
|
import { trackProcessRecordIdentity } from '../agents/process-record-identity.js';
|
|
18
|
+
import { DeliverySchema, startDelivery, finishDelivery } from './delivery.js';
|
|
18
19
|
const Criterion = z.object({ id: z.string().min(1).max(120), text: z.string().min(1).max(8000), commands: z.array(z.string().min(1).max(8000)).max(30) }).strict();
|
|
19
|
-
const Acceptance = z.object({ version: z.literal(1), criteria: z.array(Criterion).max(200), protected: z.array(z.string().min(1)).max(500).default([]) }).strict().superRefine((value, ctx) => {
|
|
20
|
+
const Acceptance = z.object({ version: z.literal(1), criteria: z.array(Criterion).max(200), protected: z.array(z.string().min(1)).max(500).default([]), delivery: DeliverySchema.optional() }).strict().superRefine((value, ctx) => {
|
|
20
21
|
if (new Set(value.criteria.map(c => c.id)).size !== value.criteria.length)
|
|
21
22
|
ctx.addIssue({ code: 'custom', message: 'Duplicate acceptance criterion id' });
|
|
23
|
+
const ids = new Set(value.criteria.map(criterion => criterion.id));
|
|
24
|
+
for (const item of [...(value.delivery?.artifacts ?? []), ...(value.delivery?.journeys ?? [])]) {
|
|
25
|
+
for (const id of item.criteria)
|
|
26
|
+
if (!ids.has(id))
|
|
27
|
+
ctx.addIssue({ code: 'custom', path: ['delivery'], message: `Delivery references unknown criterion: ${id}` });
|
|
28
|
+
}
|
|
22
29
|
});
|
|
23
30
|
export async function checkProjectAsync(directory, options = {}) {
|
|
24
31
|
const controller = new AbortController();
|
|
@@ -162,8 +169,36 @@ function verifyCommandAsync(command, root, signal) {
|
|
|
162
169
|
});
|
|
163
170
|
}
|
|
164
171
|
export function loadAcceptance(root) {
|
|
172
|
+
return loadAcceptanceSnapshot(root).manifest;
|
|
173
|
+
}
|
|
174
|
+
/** Hash the exact bounded bytes that were parsed; never reread an untrusted path for the digest. */
|
|
175
|
+
function loadAcceptanceSnapshot(root) {
|
|
165
176
|
const file = statePath(root, 'acceptance.yaml');
|
|
166
|
-
|
|
177
|
+
if (!existsSync(file))
|
|
178
|
+
return { manifest: null, digest: null };
|
|
179
|
+
const fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
180
|
+
try {
|
|
181
|
+
const before = fstatSync(fd);
|
|
182
|
+
if (!before.isFile() || before.size > 64 * 1024 * 1024)
|
|
183
|
+
throw Error('Acceptance must be a regular file of at most 64 MiB');
|
|
184
|
+
const bytes = Buffer.alloc(before.size + 1);
|
|
185
|
+
let length = 0;
|
|
186
|
+
while (length < bytes.length) {
|
|
187
|
+
const count = readSync(fd, bytes, length, bytes.length - length, null);
|
|
188
|
+
if (!count)
|
|
189
|
+
break;
|
|
190
|
+
length += count;
|
|
191
|
+
}
|
|
192
|
+
const after = fstatSync(fd), named = lstatSync(statePath(root, 'acceptance.yaml'));
|
|
193
|
+
if (length !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs ||
|
|
194
|
+
named.ino !== before.ino || named.dev !== before.dev || named.size !== before.size || named.mtimeMs !== before.mtimeMs || named.ctimeMs !== before.ctimeMs)
|
|
195
|
+
throw Error('Acceptance changed while loading');
|
|
196
|
+
const content = bytes.subarray(0, length);
|
|
197
|
+
return { manifest: Acceptance.parse(parse(content.toString('utf8'))), digest: createHash('sha256').update(content).digest('hex') };
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
closeSync(fd);
|
|
201
|
+
}
|
|
167
202
|
}
|
|
168
203
|
function protectedPath(root, path) {
|
|
169
204
|
if (isAbsolute(path))
|
|
@@ -209,17 +244,41 @@ export function acceptanceProtectionProblem(root, baselineRoot = root) {
|
|
|
209
244
|
return `Protected acceptance cannot be verified: ${error.message}`;
|
|
210
245
|
}
|
|
211
246
|
}
|
|
247
|
+
function finalizeDelivery(root, manifest, start, fingerprint, criteria, options = {}) {
|
|
248
|
+
let changed = false;
|
|
249
|
+
const delivery = finishDelivery(root, manifest, start, fingerprint, criteria, { ...options, checkIntegrity: () => {
|
|
250
|
+
let problem = null;
|
|
251
|
+
try {
|
|
252
|
+
changed = workspaceFingerprint(root) !== fingerprint;
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
changed = true;
|
|
256
|
+
problem = `Checked source cannot be verified: ${error.message}`;
|
|
257
|
+
}
|
|
258
|
+
problem = problem ?? acceptanceProtectionProblem(root) ?? (changed ? 'Source changed during verification; run check again on a stable tree' : null);
|
|
259
|
+
if (problem)
|
|
260
|
+
criteria.push({ id: 'source-integrity', text: 'Checked source remained stable', commands: [], status: 'failed', summary: problem });
|
|
261
|
+
if (options.signal?.aborted || (options.deadline !== undefined && Date.now() >= options.deadline)) {
|
|
262
|
+
const cancelled = 'Verification cancelled or deadline reached';
|
|
263
|
+
criteria.push({ id: 'verification-cancelled', text: 'Verification completed within its allowed run', commands: [], status: 'failed', summary: cancelled });
|
|
264
|
+
problem = problem ?? cancelled;
|
|
265
|
+
}
|
|
266
|
+
return problem;
|
|
267
|
+
} });
|
|
268
|
+
return { delivery, changed };
|
|
269
|
+
}
|
|
212
270
|
export function checkProject(directory, options = {}) {
|
|
213
271
|
const root = realpathSync(directory);
|
|
214
272
|
const started = Date.now();
|
|
215
273
|
const before = workspaceFingerprint(root);
|
|
216
274
|
const problem = acceptanceProtectionProblem(root);
|
|
275
|
+
const { manifest, digest } = problem ? { manifest: null, digest: null } : loadAcceptanceSnapshot(root);
|
|
276
|
+
const deliveryStart = startDelivery(root, manifest, digest);
|
|
217
277
|
const criteria = [];
|
|
218
278
|
const execute = options.execute ?? ((command, cwd) => commandVerifier(command, { phase: 'verify' })(cwd));
|
|
219
279
|
if (problem)
|
|
220
280
|
criteria.push({ id: 'protected-acceptance', text: 'Acceptance infrastructure unchanged', commands: [], status: 'failed', summary: problem });
|
|
221
281
|
else {
|
|
222
|
-
const manifest = loadAcceptance(root);
|
|
223
282
|
for (const criterion of manifest?.criteria ?? []) {
|
|
224
283
|
const results = criterion.commands.map(command => {
|
|
225
284
|
try {
|
|
@@ -247,14 +306,11 @@ export function checkProject(directory, options = {}) {
|
|
|
247
306
|
if (criteria.length === 0)
|
|
248
307
|
criteria.push({ id: 'acceptance', text: 'Project acceptance', commands: [], status: 'unverified', summary: 'No acceptance manifest or project verification command found' });
|
|
249
308
|
}
|
|
250
|
-
const changed =
|
|
251
|
-
const afterProblem = acceptanceProtectionProblem(root);
|
|
252
|
-
if (changed || afterProblem)
|
|
253
|
-
criteria.push({ id: 'source-integrity', text: 'Checked source remained stable', commands: [], status: 'failed', summary: afterProblem ?? 'Source changed during verification; run check again on a stable tree' });
|
|
309
|
+
const { changed, delivery } = finalizeDelivery(root, manifest, deliveryStart, before, criteria);
|
|
254
310
|
const status = criteria.some(c => c.status === 'failed') ? 'failed' : criteria.some(c => c.status === 'unverified') ? 'unverified' : 'passed';
|
|
255
311
|
const id = randomUUID();
|
|
256
312
|
const evidencePath = statePath(root, 'checks', `${id}.json`);
|
|
257
|
-
const report = { version: 1, id, generatedAt: new Date().toISOString(), fingerprint: before, status, summary: changed ? 'Source changed during verification' : `${criteria.filter(c => c.status === 'passed').length}/${criteria.length} checks passed; ${status}`, criteria, durationMs: Date.now() - started, evidencePath };
|
|
313
|
+
const report = { version: 1, id, generatedAt: new Date().toISOString(), fingerprint: before, status, summary: changed ? 'Source changed during verification' : `${criteria.filter(c => c.status === 'passed').length}/${criteria.length} checks passed; ${status}`, criteria, delivery, durationMs: Date.now() - started, evidencePath };
|
|
258
314
|
mkdirSync(dirname(evidencePath), { recursive: true });
|
|
259
315
|
writeFileSync(evidencePath, JSON.stringify(report, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
260
316
|
return report;
|
|
@@ -265,6 +321,8 @@ async function checkProjectAsyncAdmitted(directory, options) {
|
|
|
265
321
|
const started = Date.now();
|
|
266
322
|
const before = workspaceFingerprint(root);
|
|
267
323
|
const problem = acceptanceProtectionProblem(root);
|
|
324
|
+
const { manifest, digest } = problem ? { manifest: null, digest: null } : loadAcceptanceSnapshot(root);
|
|
325
|
+
const deliveryStart = startDelivery(root, manifest, digest, options);
|
|
268
326
|
const criteria = [];
|
|
269
327
|
let cleanupUnconfirmed = false;
|
|
270
328
|
const rawExecute = options.execute ?? ((command, cwd) => verifyCommandAsync(command, cwd, options.signal));
|
|
@@ -281,7 +339,6 @@ async function checkProjectAsyncAdmitted(directory, options) {
|
|
|
281
339
|
if (problem)
|
|
282
340
|
criteria.push({ id: 'protected-acceptance', text: 'Acceptance infrastructure unchanged', commands: [], status: 'failed', summary: problem });
|
|
283
341
|
else {
|
|
284
|
-
const manifest = loadAcceptance(root);
|
|
285
342
|
for (const criterion of manifest?.criteria ?? []) {
|
|
286
343
|
const results = [];
|
|
287
344
|
for (const command of criterion.commands) {
|
|
@@ -316,16 +373,11 @@ async function checkProjectAsyncAdmitted(directory, options) {
|
|
|
316
373
|
if (criteria.length === 0)
|
|
317
374
|
criteria.push({ id: 'acceptance', text: 'Project acceptance', commands: [], status: 'unverified', summary: 'No acceptance manifest or project verification command found' });
|
|
318
375
|
}
|
|
319
|
-
const changed =
|
|
320
|
-
const afterProblem = acceptanceProtectionProblem(root);
|
|
321
|
-
if (changed || afterProblem)
|
|
322
|
-
criteria.push({ id: 'source-integrity', text: 'Checked source remained stable', commands: [], status: 'failed', summary: afterProblem ?? 'Source changed during verification; run check again on a stable tree' });
|
|
323
|
-
if (options.signal?.aborted)
|
|
324
|
-
criteria.push({ id: 'verification-cancelled', text: 'Verification completed within its allowed run', commands: [], status: 'failed', summary: 'Verification cancelled or deadline reached' });
|
|
376
|
+
const { changed, delivery } = finalizeDelivery(root, manifest, deliveryStart, before, criteria, options);
|
|
325
377
|
const status = criteria.some(c => c.status === 'failed') ? 'failed' : criteria.some(c => c.status === 'unverified') ? 'unverified' : 'passed';
|
|
326
378
|
const id = randomUUID();
|
|
327
379
|
const evidencePath = statePath(root, 'checks', `${id}.json`);
|
|
328
|
-
const report = { ...(cleanupUnconfirmed ? { cleanupUnconfirmed: true } : {}), version: 1, id, generatedAt: new Date().toISOString(), fingerprint: before, status, summary: changed ? 'Source changed during verification' : `${criteria.filter(c => c.status === 'passed').length}/${criteria.length} checks passed; ${status}`, criteria, durationMs: Date.now() - started, evidencePath };
|
|
380
|
+
const report = { ...(cleanupUnconfirmed ? { cleanupUnconfirmed: true } : {}), version: 1, id, generatedAt: new Date().toISOString(), fingerprint: before, status, summary: changed ? 'Source changed during verification' : `${criteria.filter(c => c.status === 'passed').length}/${criteria.length} checks passed; ${status}`, criteria, delivery, durationMs: Date.now() - started, evidencePath };
|
|
329
381
|
mkdirSync(dirname(evidencePath), { recursive: true });
|
|
330
382
|
writeFileSync(evidencePath, JSON.stringify(report, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
331
383
|
return report;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
const References = z.array(z.string().min(1).max(120)).min(1).max(200);
|
|
6
|
+
export const DeliverySchema = z.object({
|
|
7
|
+
version: z.literal(1),
|
|
8
|
+
artifacts: z.array(z.object({ path: z.string().min(1).max(1000), criteria: References }).strict()).max(32).default([]),
|
|
9
|
+
journeys: z.array(z.object({ id: z.string().min(1).max(120), text: z.string().min(1).max(8000), criteria: References }).strict()).max(100).default([]),
|
|
10
|
+
environment: z.object({ name: z.string().min(1).max(200), url: z.string().url().max(2000).optional() }).strict().optional(),
|
|
11
|
+
}).strict().superRefine((value, context) => {
|
|
12
|
+
if (new Set(value.artifacts.map(item => item.path)).size !== value.artifacts.length)
|
|
13
|
+
context.addIssue({ code: 'custom', message: 'Duplicate delivery artifact path' });
|
|
14
|
+
if (new Set(value.journeys.map(item => item.id)).size !== value.journeys.length)
|
|
15
|
+
context.addIssue({ code: 'custom', message: 'Duplicate delivery journey id' });
|
|
16
|
+
});
|
|
17
|
+
export const DELIVERY_HASH_LIMITS = Object.freeze({ fileBytes: 512 * 1024 * 1024, totalBytes: 1024 * 1024 * 1024, timeoutMs: 10_000 });
|
|
18
|
+
function snapshotBudget(options) {
|
|
19
|
+
return { signal: options.signal, remaining: DELIVERY_HASH_LIMITS.totalBytes, deadline: Math.min(options.deadline ?? Infinity, Date.now() + DELIVERY_HASH_LIMITS.timeoutMs) };
|
|
20
|
+
}
|
|
21
|
+
function checkBudget(budget) {
|
|
22
|
+
if (budget.signal?.aborted || Date.now() >= budget.deadline)
|
|
23
|
+
throw Error('Artifact hashing cancelled or deadline reached');
|
|
24
|
+
}
|
|
25
|
+
function artifactPath(root, path) {
|
|
26
|
+
if (isAbsolute(path))
|
|
27
|
+
throw Error('Artifact path must be relative');
|
|
28
|
+
const full = resolve(root, path), rel = relative(root, full);
|
|
29
|
+
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
30
|
+
throw Error('Artifact path escapes project');
|
|
31
|
+
let current = root;
|
|
32
|
+
for (const component of rel.split(sep)) {
|
|
33
|
+
current = join(current, component);
|
|
34
|
+
if (lstatSync(current).isSymbolicLink())
|
|
35
|
+
throw Error('Artifact paths must not contain symbolic links');
|
|
36
|
+
}
|
|
37
|
+
if (realpathSync(full) !== full)
|
|
38
|
+
throw Error('Artifact path is not canonical');
|
|
39
|
+
return full;
|
|
40
|
+
}
|
|
41
|
+
/** Bounded bytes and constant memory; the deadline is checked between synchronous reads. */
|
|
42
|
+
function snapshotArtifact(root, path, budget) {
|
|
43
|
+
let fd;
|
|
44
|
+
try {
|
|
45
|
+
checkBudget(budget);
|
|
46
|
+
const full = artifactPath(root, path);
|
|
47
|
+
const named = lstatSync(full);
|
|
48
|
+
if (!named.isFile())
|
|
49
|
+
throw Error('Artifact must be a regular file');
|
|
50
|
+
if (named.size > DELIVERY_HASH_LIMITS.fileBytes)
|
|
51
|
+
throw Error(`Artifact exceeds the ${DELIVERY_HASH_LIMITS.fileBytes} byte file limit`);
|
|
52
|
+
if (named.size > budget.remaining)
|
|
53
|
+
throw Error(`Artifact exceeds the remaining aggregate hash budget (${budget.remaining} bytes)`);
|
|
54
|
+
fd = openSync(full, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
55
|
+
const before = fstatSync(fd);
|
|
56
|
+
if (!before.isFile() || before.ino !== named.ino || before.dev !== named.dev || before.size !== named.size)
|
|
57
|
+
throw Error('Artifact changed before hashing');
|
|
58
|
+
const hash = createHash('sha256'), buffer = Buffer.alloc(256 * 1024);
|
|
59
|
+
let bytes = 0;
|
|
60
|
+
while (bytes < before.size) {
|
|
61
|
+
checkBudget(budget);
|
|
62
|
+
const count = readSync(fd, buffer, 0, Math.min(buffer.length, before.size - bytes), null);
|
|
63
|
+
if (!count)
|
|
64
|
+
break;
|
|
65
|
+
hash.update(buffer.subarray(0, count));
|
|
66
|
+
bytes += count;
|
|
67
|
+
budget.remaining -= count;
|
|
68
|
+
}
|
|
69
|
+
checkBudget(budget);
|
|
70
|
+
const after = fstatSync(fd);
|
|
71
|
+
const finalNamed = lstatSync(artifactPath(root, path));
|
|
72
|
+
if (bytes !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs ||
|
|
73
|
+
!finalNamed.isFile() || finalNamed.ino !== before.ino || finalNamed.dev !== before.dev || finalNamed.size !== before.size || finalNamed.mtimeMs !== before.mtimeMs || finalNamed.ctimeMs !== before.ctimeMs)
|
|
74
|
+
throw Error('Artifact changed while hashing');
|
|
75
|
+
return { sha256: hash.digest('hex'), bytes };
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
return { problem: `Artifact cannot be bound: ${error.message}` };
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
if (fd !== undefined) {
|
|
82
|
+
try {
|
|
83
|
+
closeSync(fd);
|
|
84
|
+
}
|
|
85
|
+
catch { /* Preserve the snapshot failure. */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function startDelivery(root, manifest, acceptanceDigest, options = {}) {
|
|
90
|
+
const budget = snapshotBudget(options);
|
|
91
|
+
return { acceptanceDigest, artifacts: (manifest?.delivery?.artifacts ?? []).map(item => snapshotArtifact(root, item.path, budget)) };
|
|
92
|
+
}
|
|
93
|
+
export function finishDelivery(root, manifest, start, fingerprint, criteria, options = {}) {
|
|
94
|
+
const budget = snapshotBudget(options);
|
|
95
|
+
const snapshots = (manifest?.delivery?.artifacts ?? []).map((artifact, index) => {
|
|
96
|
+
const before = start.artifacts[index], after = snapshotArtifact(root, artifact.path, budget);
|
|
97
|
+
const stable = !!before?.sha256 && before.sha256 === after.sha256;
|
|
98
|
+
const problem = before?.problem ?? after.problem ?? (!stable ? 'Artifact changed during verification; rebuild and check the stable artifact again' : undefined);
|
|
99
|
+
if (problem)
|
|
100
|
+
criteria.push({ id: `delivery-artifact-${index + 1}`, text: `Artifact ${artifact.path} remained stable`, commands: [], status: 'failed', summary: problem });
|
|
101
|
+
return { artifact, before, after, stable, problem };
|
|
102
|
+
});
|
|
103
|
+
// Recheck source and cancellation after the final artifact I/O, before assigning proof status.
|
|
104
|
+
const bindingProblem = options.checkIntegrity?.() ?? snapshots.find(item => item.problem)?.problem;
|
|
105
|
+
const statusFor = (ids) => {
|
|
106
|
+
const statuses = ids.flatMap(id => { const matches = criteria.filter(criterion => criterion.id === id); return matches.length ? matches.map(item => item.status) : ['unverified']; });
|
|
107
|
+
return statuses.some(status => status === 'failed') ? 'failed' : !bindingProblem && statuses.every(status => status === 'passed') ? 'passed' : 'unverified';
|
|
108
|
+
};
|
|
109
|
+
const artifacts = snapshots.map(({ artifact, before, after, stable, problem }) => {
|
|
110
|
+
return { ...artifact, binding: 'declared-criteria', status: problem ? 'failed' : statusFor(artifact.criteria), stable, sha256: before?.sha256, bytes: before?.bytes, afterSha256: after.sha256, summary: problem ?? bindingProblem ?? 'Artifact hashes matched before and after checking its declared criteria; the project commands define how this artifact is exercised' };
|
|
111
|
+
});
|
|
112
|
+
const requirements = manifest?.criteria.map(item => item.id) ?? [];
|
|
113
|
+
return {
|
|
114
|
+
version: 1, sourceFingerprint: fingerprint, acceptanceDigest: start.acceptanceDigest, ...(bindingProblem ? { bindingProblem } : {}),
|
|
115
|
+
environment: { platform: process.platform, architecture: process.arch, nodeVersion: process.version, declared: manifest?.delivery?.environment },
|
|
116
|
+
artifacts, journeys: (manifest?.delivery?.journeys ?? []).map(journey => ({ ...journey, status: statusFor(journey.criteria) })),
|
|
117
|
+
passedRequirementIds: requirements.filter(id => statusFor([id]) === 'passed'),
|
|
118
|
+
failedRequirementIds: requirements.filter(id => statusFor([id]) === 'failed'),
|
|
119
|
+
unverifiedRequirementIds: requirements.filter(id => statusFor([id]) === 'unverified'),
|
|
120
|
+
};
|
|
121
|
+
}
|