mandrel 1.58.0 → 1.59.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/.agents/README.md +89 -87
- package/.agents/docs/SDLC.md +11 -7
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/audit-rules.json +20 -0
- package/.agents/scripts/acceptance-eval.js +20 -3
- package/.agents/scripts/assert-branch.js +1 -3
- package/.agents/scripts/bootstrap.js +1 -1
- package/.agents/scripts/check-arch-cycles.js +360 -0
- package/.agents/scripts/coverage-capture.js +24 -3
- package/.agents/scripts/epic-deliver-preflight.js +5 -3
- package/.agents/scripts/epic-deliver-prepare.js +12 -4
- package/.agents/scripts/epic-execute-record-wave.js +1 -1
- package/.agents/scripts/evidence-gate.js +1 -1
- package/.agents/scripts/git-rebase-and-resolve.js +1 -1
- package/.agents/scripts/hierarchy-gate.js +34 -14
- package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
- package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
- package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
- package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
- package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
- package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
- package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
- package/.agents/scripts/lib/baselines/writer.js +1 -1
- package/.agents/scripts/lib/close-validation/commands.js +188 -0
- package/.agents/scripts/lib/close-validation/gates.js +235 -0
- package/.agents/scripts/lib/close-validation/process.js +101 -0
- package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
- package/.agents/scripts/lib/close-validation/runner.js +325 -0
- package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
- package/.agents/scripts/lib/config/quality.js +6 -6
- package/.agents/scripts/lib/config-resolver.js +2 -5
- package/.agents/scripts/lib/coverage-capture.js +147 -4
- package/.agents/scripts/lib/cpu-pool.js +14 -0
- package/.agents/scripts/lib/crap-utils.js +6 -11
- package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
- package/.agents/scripts/lib/git-utils.js +24 -22
- package/.agents/scripts/lib/maintainability-engine.js +1 -1
- package/.agents/scripts/lib/maintainability-utils.js +4 -187
- package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
- package/.agents/scripts/lib/orchestration/code-review.js +90 -77
- package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
- package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
- package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
- package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
- package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
- package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
- package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
- package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
- package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
- package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
- package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
- package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
- package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
- package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
- package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
- package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
- package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
- package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
- package/.agents/scripts/lib/project-root.js +17 -0
- package/.agents/scripts/lib/story-adjacency.js +76 -0
- package/.agents/scripts/lib/story-lifecycle.js +1 -1
- package/.agents/scripts/lib/transpile.js +93 -0
- package/.agents/scripts/lib/wave-runner/tick.js +4 -153
- package/.agents/scripts/lib/workers/crap-worker.js +1 -1
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
- package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
- package/.agents/scripts/providers/github/tickets.js +110 -6
- package/.agents/scripts/run-lint.js +9 -0
- package/.agents/scripts/run-tests.js +24 -4
- package/.agents/scripts/stories-wave-tick.js +8 -5
- package/.agents/scripts/story-init.js +149 -10
- package/.agents/scripts/sync-branch-from-base.js +1 -1
- package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
- package/.agents/workflows/audit-documentation.md +226 -0
- package/.agents/workflows/epic-deliver.md +16 -23
- package/.agents/workflows/epic-plan.md +1 -1
- package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
- package/.agents/workflows/helpers/single-story-deliver.md +2 -1
- package/.agents/workflows/onboard.md +4 -3
- package/.agents/workflows/story-deliver.md +1 -1
- package/README.md +13 -8
- package/lib/cli/init.js +336 -0
- package/package.json +2 -1
- package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
- package/.agents/scripts/lib/close-validation.js +0 -897
- package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
- package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
- package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
- package/.agents/scripts/lib/task-utils.js +0 -26
- package/.agents/scripts/story-deliver-prepare.js +0 -267
|
@@ -321,7 +321,13 @@ export async function planEpic(
|
|
|
321
321
|
Logger.warn(
|
|
322
322
|
`[Epic Planner] Epic #${epicId} already has all requested planning artifacts. Aborting to prevent duplicates. Use --force to re-plan.`,
|
|
323
323
|
);
|
|
324
|
-
return
|
|
324
|
+
return {
|
|
325
|
+
persisted: false,
|
|
326
|
+
reason: 'already-planned',
|
|
327
|
+
prdId: existing.prd,
|
|
328
|
+
techSpecId: existing.techSpec,
|
|
329
|
+
acceptanceSpecId: existing.acceptanceSpec,
|
|
330
|
+
};
|
|
325
331
|
}
|
|
326
332
|
// Under --force we now OVERWRITE the canonical context tickets in place
|
|
327
333
|
// (same issue numbers, refreshed bodies) rather than closing + recreating
|
|
@@ -388,8 +394,18 @@ export async function planEpic(
|
|
|
388
394
|
if (acceptanceSpecId !== null) {
|
|
389
395
|
artifactLines.push(`- [ ] Acceptance Spec: #${acceptanceSpecId}`);
|
|
390
396
|
}
|
|
397
|
+
// Idempotent append (Story #4019): strip any pre-existing
|
|
398
|
+
// `## Planning Artifacts` section before re-appending. The `--force`
|
|
399
|
+
// path already stripped it in `healAndCleanupArtifacts`, but the
|
|
400
|
+
// partial-recovery rerun (e.g. PRD present, Tech Spec missing) reaches
|
|
401
|
+
// here with a body that may still carry a stale section — without the
|
|
402
|
+
// strip, every rerun stacked a duplicate section onto the Epic body.
|
|
391
403
|
const appendBody = `\n\n## Planning Artifacts\n${artifactLines.join('\n')}\n`;
|
|
392
|
-
const
|
|
404
|
+
const strippedBody = epic.body.replace(
|
|
405
|
+
/\n*## Planning Artifacts[\s\S]*$/,
|
|
406
|
+
'',
|
|
407
|
+
);
|
|
408
|
+
const newBody = strippedBody + appendBody;
|
|
393
409
|
|
|
394
410
|
/** @type {{ add?: string[], remove?: string[] }} */
|
|
395
411
|
const labelMutations = {};
|
|
@@ -411,4 +427,12 @@ export async function planEpic(
|
|
|
411
427
|
|
|
412
428
|
Logger.info(`[Epic Planner] Epic #${epicId} updated successfully.`);
|
|
413
429
|
Logger.info(`[Epic Planner] Planning pipeline complete!`);
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
persisted: true,
|
|
433
|
+
reason: force ? 'force-replan' : 'persisted',
|
|
434
|
+
prdId,
|
|
435
|
+
techSpecId,
|
|
436
|
+
acceptanceSpecId,
|
|
437
|
+
};
|
|
414
438
|
}
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import { PROJECT_ROOT } from '../../../config-resolver.js';
|
|
11
10
|
import { Logger } from '../../../Logger.js';
|
|
12
11
|
import { AGENT_LABELS, TYPE_LABELS } from '../../../label-constants.js';
|
|
13
12
|
import { cleanupPhaseTempFiles } from '../../../plan-phase-cleanup.js';
|
|
13
|
+
import { PROJECT_ROOT } from '../../../project-root.js';
|
|
14
14
|
import { acquireEpicPlanLease } from '../../epic-plan-lease-guard.js';
|
|
15
15
|
import {
|
|
16
16
|
initialize as initializePlanState,
|
|
@@ -119,7 +119,7 @@ export async function runSpecPhase(
|
|
|
119
119
|
|
|
120
120
|
await initializePlanState({ provider, epicId });
|
|
121
121
|
|
|
122
|
-
await planEpic(
|
|
122
|
+
const planResult = await planEpic(
|
|
123
123
|
epicId,
|
|
124
124
|
provider,
|
|
125
125
|
{ prdContent, techSpecContent, acceptanceSpecContent },
|
|
@@ -129,6 +129,7 @@ export async function runSpecPhase(
|
|
|
129
129
|
planningRisk,
|
|
130
130
|
},
|
|
131
131
|
);
|
|
132
|
+
const specChanged = planResult?.persisted !== false;
|
|
132
133
|
|
|
133
134
|
const afterPlan = await provider.getEpic(epicId);
|
|
134
135
|
const prdId = afterPlan.linkedIssues?.prd ?? null;
|
|
@@ -199,10 +200,27 @@ export async function runSpecPhase(
|
|
|
199
200
|
Logger.info(`[epic-plan-spec] Review routing: ${reviewRouting.decision}.`);
|
|
200
201
|
Logger.info(`[epic-plan-spec] ${reviewRouting.operatorMessage}`);
|
|
201
202
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
203
|
+
// Story #4019 (refining #3905): a spec-phase rerun that changed nothing
|
|
204
|
+
// (planEpic short-circuited on `already-planned`) MUST NOT demote a
|
|
205
|
+
// fully-decomposed `agent::ready` Epic back to `agent::review-spec` —
|
|
206
|
+
// there is no new spec content to review. The demotion fires only when
|
|
207
|
+
// the spec actually persisted/changed, or when the Epic is not at
|
|
208
|
+
// `agent::ready` (where the flip is the normal forward transition).
|
|
209
|
+
const epicLabels = afterPlan.labels ?? [];
|
|
210
|
+
const skipDemotion = !specChanged && epicLabels.includes(AGENT_LABELS.READY);
|
|
211
|
+
let labelTransition;
|
|
212
|
+
if (skipDemotion) {
|
|
213
|
+
labelTransition = 'kept-ready';
|
|
214
|
+
Logger.info(
|
|
215
|
+
`[epic-plan-spec] Spec unchanged (${planResult?.reason ?? 'already-planned'}) and Epic #${epicId} is ${AGENT_LABELS.READY} — keeping ${AGENT_LABELS.READY} (no demotion to ${AGENT_LABELS.REVIEW_SPEC}).`,
|
|
216
|
+
);
|
|
217
|
+
} else {
|
|
218
|
+
labelTransition = 'review-spec';
|
|
219
|
+
Logger.info(
|
|
220
|
+
`[epic-plan-spec] Flipping Epic #${epicId} to ${AGENT_LABELS.REVIEW_SPEC}...`,
|
|
221
|
+
);
|
|
222
|
+
await setEpicLabel(provider, epicId, AGENT_LABELS.REVIEW_SPEC);
|
|
223
|
+
}
|
|
206
224
|
|
|
207
225
|
const cleanup = await cleanupPhaseTempFiles({ phase: 'spec', epicId });
|
|
208
226
|
|
|
@@ -231,5 +249,7 @@ export async function runSpecPhase(
|
|
|
231
249
|
freshness,
|
|
232
250
|
planningRisk,
|
|
233
251
|
reviewRouting,
|
|
252
|
+
specChanged,
|
|
253
|
+
labelTransition,
|
|
234
254
|
};
|
|
235
255
|
}
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
* Throws if no open Stories are found.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { parseBlockedBy } from '../../../dependency-parser.js';
|
|
23
22
|
import { computeWaves } from '../../../Graph.js';
|
|
24
23
|
import { TYPE_LABELS } from '../../../label-constants.js';
|
|
24
|
+
import { buildStoryAdjacency } from '../../../story-adjacency.js';
|
|
25
25
|
import { WaveScheduler } from '../wave-scheduler.js';
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -116,30 +116,17 @@ function normalizeWavesForEmit(waves) {
|
|
|
116
116
|
* Convert an ordered list of story tickets into the adjacency/taskMap shape
|
|
117
117
|
* that `Graph.computeWaves()` expects.
|
|
118
118
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* object (present in fixture / test payloads; optional in live GitHub
|
|
125
|
-
* payloads).
|
|
126
|
-
* Only edges to other stories in this Epic are retained — foreign IDs are
|
|
127
|
-
* dropped so the DAG stays closed over the scheduled set.
|
|
119
|
+
* The adjacency comes from the shared story-level builder
|
|
120
|
+
* (`lib/story-adjacency.js#buildStoryAdjacency`), which owns the
|
|
121
|
+
* dependency-source ordering contract (body `blocked by` references via
|
|
122
|
+
* `parseBlockedBy`, then explicit `dependencies[]`) and drops foreign
|
|
123
|
+
* edges so the DAG stays closed over the scheduled set.
|
|
128
124
|
*/
|
|
129
125
|
function buildStoryDag(stories) {
|
|
130
|
-
const adjacency =
|
|
126
|
+
const adjacency = buildStoryAdjacency(stories);
|
|
131
127
|
const taskMap = new Map();
|
|
132
|
-
const storyIds = new Set(stories.map((s) => Number(s.id ?? s.number)));
|
|
133
128
|
for (const s of stories) {
|
|
134
129
|
const id = Number(s.id ?? s.number);
|
|
135
|
-
const fromBody = parseBlockedBy(s.body ?? '');
|
|
136
|
-
const fromField = Array.isArray(s.dependencies)
|
|
137
|
-
? s.dependencies.map(Number)
|
|
138
|
-
: [];
|
|
139
|
-
const merged = [...new Set([...fromBody, ...fromField])]
|
|
140
|
-
.map(Number)
|
|
141
|
-
.filter((dep) => dep !== id && storyIds.has(dep));
|
|
142
|
-
adjacency.set(id, merged);
|
|
143
130
|
taskMap.set(id, { ...s, id });
|
|
144
131
|
}
|
|
145
132
|
return { adjacency, taskMap };
|
|
@@ -24,7 +24,7 @@ import { EPIC_RUN_PROGRESS_TYPE, STATE_EMOJI } from './signals.js';
|
|
|
24
24
|
* (`…`) when the string was longer. Returns the empty string for any
|
|
25
25
|
* falsy input so table cells never render `undefined`/`null`.
|
|
26
26
|
*/
|
|
27
|
-
|
|
27
|
+
function truncate(s, n) {
|
|
28
28
|
if (!s) return '';
|
|
29
29
|
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
30
30
|
}
|
|
@@ -272,7 +272,6 @@ export async function renderProgressBody({
|
|
|
272
272
|
* wave: number,
|
|
273
273
|
* concurrencyCap?: number,
|
|
274
274
|
* stories?: Array<{ id: number, title?: string, state?: string,
|
|
275
|
-
* tasksDone?: number, tasksTotal?: number,
|
|
276
275
|
* blockerCommentId?: string }>,
|
|
277
276
|
* }>,
|
|
278
277
|
* currentWave: number,
|
|
@@ -100,7 +100,6 @@ export function phaseToState(phase) {
|
|
|
100
100
|
* storyId: number,
|
|
101
101
|
* branch?: string,
|
|
102
102
|
* phase: 'init'|'implementing'|'closing'|'blocked'|'done',
|
|
103
|
-
* tasks?: [{ id, title?, state, commitSha? }],
|
|
104
103
|
* title?: string,
|
|
105
104
|
* updatedAt?: string,
|
|
106
105
|
* }
|
|
@@ -109,16 +108,11 @@ export function parseStoryRunProgressComment(comment) {
|
|
|
109
108
|
const payload = parseFencedJsonComment(comment);
|
|
110
109
|
if (!payload || typeof payload !== 'object') return null;
|
|
111
110
|
const phase = typeof payload.phase === 'string' ? payload.phase : undefined;
|
|
112
|
-
const tasks = Array.isArray(payload.tasks) ? payload.tasks : [];
|
|
113
|
-
const tasksTotal = tasks.length;
|
|
114
|
-
const tasksDone = tasks.filter((t) => t && t.state === 'done').length;
|
|
115
111
|
return {
|
|
116
112
|
storyId: Number(payload.storyId),
|
|
117
113
|
title: typeof payload.title === 'string' ? payload.title : '',
|
|
118
114
|
phase,
|
|
119
115
|
state: phaseToState(phase),
|
|
120
|
-
tasksDone,
|
|
121
|
-
tasksTotal,
|
|
122
116
|
};
|
|
123
117
|
}
|
|
124
118
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { resolveComponents } from '../../../baselines/components.js';
|
|
3
|
+
import { componentOrder } from './_bullet-format.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared component-regression walk for the progress-signal drift detectors
|
|
7
|
+
* (Story #3984). `crap-drift.js` and `maintainability-drift.js` previously
|
|
8
|
+
* duplicated this baseline-rollup → per-component compare → bullet-list
|
|
9
|
+
* shape verbatim; the only divergence was the breach comparator (CRAP is
|
|
10
|
+
* "lower is better", maintainability is "higher is better") and the bullet
|
|
11
|
+
* text. Both detectors now parameterize this walk.
|
|
12
|
+
*
|
|
13
|
+
* Pure — the caller loads the baseline via `lib/baselines/reader.js#load(...)`
|
|
14
|
+
* and passes the resulting `{ rollup }` plus the gate config block.
|
|
15
|
+
*
|
|
16
|
+
* Only components whose rollup breaches the configured floor surface — a
|
|
17
|
+
* breach in a component-scoped floor does NOT report against `*` unless
|
|
18
|
+
* `*` itself breaches. This keeps the rollout narrowly targeted: when an
|
|
19
|
+
* operator wires up a per-component floor for `api`, regressing `api`
|
|
20
|
+
* names `api` — not `*`.
|
|
21
|
+
*
|
|
22
|
+
* @param {{
|
|
23
|
+
* rollup?: Record<string, Record<string, number>>,
|
|
24
|
+
* gateConfig?: { floors?: Record<string, Record<string, number>> } & object,
|
|
25
|
+
* }} params
|
|
26
|
+
* @param {{
|
|
27
|
+
* isBreach: (value: number, floor: number) => boolean,
|
|
28
|
+
* formatBullet: (name: string, axis: string, value: number, floor: number) => string,
|
|
29
|
+
* }} spec
|
|
30
|
+
* @returns {string[]}
|
|
31
|
+
*/
|
|
32
|
+
export function walkComponentRegressions(params = {}, spec) {
|
|
33
|
+
const rollup = params.rollup ?? {};
|
|
34
|
+
const gateConfig = params.gateConfig ?? {};
|
|
35
|
+
const floors = gateConfig.floors ?? {};
|
|
36
|
+
const components = resolveComponents(gateConfig);
|
|
37
|
+
const names = new Set([
|
|
38
|
+
...Object.keys(components),
|
|
39
|
+
...Object.keys(floors),
|
|
40
|
+
...Object.keys(rollup),
|
|
41
|
+
]);
|
|
42
|
+
const bullets = [];
|
|
43
|
+
for (const name of [...names].sort(componentOrder)) {
|
|
44
|
+
const aggregate = rollup[name];
|
|
45
|
+
if (!aggregate || typeof aggregate !== 'object') continue;
|
|
46
|
+
const floor = floors[name] ?? floors['*'];
|
|
47
|
+
if (!floor || typeof floor !== 'object') continue;
|
|
48
|
+
for (const axis of Object.keys(floor).sort()) {
|
|
49
|
+
const target = floor[axis];
|
|
50
|
+
const value = aggregate[axis];
|
|
51
|
+
if (typeof target !== 'number' || !Number.isFinite(target)) continue;
|
|
52
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
|
|
53
|
+
if (!spec.isBreach(value, target)) continue;
|
|
54
|
+
bullets.push(spec.formatBullet(name, axis, value, target));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return bullets;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Shared wave-start snapshot persistence for the drift detectors. Both
|
|
62
|
+
* detectors persist a `{ capturedAt, ...metadata, scores }` JSON document
|
|
63
|
+
* under `<cwd>/<baselineDir>/<filename>` so a resumed epic run can re-read
|
|
64
|
+
* the wave-start anchor rather than lose it, and both treat persistence as
|
|
65
|
+
* best-effort (the in-memory baseline still works when a write fails).
|
|
66
|
+
*
|
|
67
|
+
* @param {{
|
|
68
|
+
* fs: { readFileSync?: Function, writeFileSync?: Function, mkdirSync?: Function },
|
|
69
|
+
* baselinePath: string,
|
|
70
|
+
* metadata?: Record<string, unknown>,
|
|
71
|
+
* }} opts
|
|
72
|
+
*/
|
|
73
|
+
export function createSnapshotStore({ fs, baselinePath, metadata = {} }) {
|
|
74
|
+
return {
|
|
75
|
+
persist(scores) {
|
|
76
|
+
if (!fs.writeFileSync) return;
|
|
77
|
+
try {
|
|
78
|
+
fs.mkdirSync?.(path.dirname(baselinePath), { recursive: true });
|
|
79
|
+
fs.writeFileSync(
|
|
80
|
+
baselinePath,
|
|
81
|
+
JSON.stringify(
|
|
82
|
+
{ capturedAt: new Date().toISOString(), ...metadata, scores },
|
|
83
|
+
null,
|
|
84
|
+
2,
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
} catch {
|
|
88
|
+
// persistence is best-effort; the in-memory baseline still works
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
load() {
|
|
93
|
+
if (!fs.readFileSync) return null;
|
|
94
|
+
try {
|
|
95
|
+
const raw = fs.readFileSync(baselinePath, 'utf-8');
|
|
96
|
+
const parsed = JSON.parse(raw);
|
|
97
|
+
return parsed?.scores ?? null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import nodeFs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { resolveComponents } from '../../../baselines/components.js';
|
|
4
3
|
import { loadCoverage as defaultLoadCoverage } from '../../../coverage-utils.js';
|
|
5
4
|
import { calculateCrapForSource } from '../../../crap-engine.js';
|
|
6
|
-
import {
|
|
5
|
+
import { formatNumber } from './_bullet-format.js';
|
|
6
|
+
import {
|
|
7
|
+
createSnapshotStore,
|
|
8
|
+
walkComponentRegressions,
|
|
9
|
+
} from './component-drift.js';
|
|
7
10
|
|
|
8
11
|
const DEFAULT_THRESHOLD = 5.0;
|
|
9
12
|
const DEFAULT_CEILING = 30;
|
|
@@ -43,11 +46,10 @@ export function coverageKeyMatches(key, suffix) {
|
|
|
43
46
|
*
|
|
44
47
|
* 🧨 crap: <component> <axis> <value> > floor <floor>
|
|
45
48
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* `*` itself breaches.
|
|
49
|
-
*
|
|
50
|
-
* names `api` — not `*`.
|
|
49
|
+
* CRAP is a "lower is better" gate — every axis breach reports when
|
|
50
|
+
* `value > floor`. Component-scoped breaches do NOT trigger a `*` bullet
|
|
51
|
+
* unless `*` itself breaches. The walk itself is the shared
|
|
52
|
+
* `component-drift.js` helper (Story #3984).
|
|
51
53
|
*
|
|
52
54
|
* @param {{
|
|
53
55
|
* rollup?: Record<string, Record<string, number>>,
|
|
@@ -56,34 +58,11 @@ export function coverageKeyMatches(key, suffix) {
|
|
|
56
58
|
* @returns {string[]}
|
|
57
59
|
*/
|
|
58
60
|
export function detectComponentRegressions(params = {}) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
...Object.keys(components),
|
|
65
|
-
...Object.keys(floors),
|
|
66
|
-
...Object.keys(rollup),
|
|
67
|
-
]);
|
|
68
|
-
const bullets = [];
|
|
69
|
-
// CRAP is a "lower is better" gate — every axis under crap is ≤ floor.
|
|
70
|
-
for (const name of [...names].sort(componentOrder)) {
|
|
71
|
-
const aggregate = rollup[name];
|
|
72
|
-
if (!aggregate || typeof aggregate !== 'object') continue;
|
|
73
|
-
const floor = floors[name] ?? floors['*'];
|
|
74
|
-
if (!floor || typeof floor !== 'object') continue;
|
|
75
|
-
for (const axis of Object.keys(floor).sort()) {
|
|
76
|
-
const cap = floor[axis];
|
|
77
|
-
const value = aggregate[axis];
|
|
78
|
-
if (typeof cap !== 'number' || !Number.isFinite(cap)) continue;
|
|
79
|
-
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
|
|
80
|
-
if (value <= cap) continue;
|
|
81
|
-
bullets.push(
|
|
82
|
-
`🧨 crap: ${name} ${axis} ${formatNumber(value)} > floor ${formatNumber(cap)}`,
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return bullets;
|
|
61
|
+
return walkComponentRegressions(params, {
|
|
62
|
+
isBreach: (value, cap) => value > cap,
|
|
63
|
+
formatBullet: (name, axis, value, cap) =>
|
|
64
|
+
`🧨 crap: ${name} ${axis} ${formatNumber(value)} > floor ${formatNumber(cap)}`,
|
|
65
|
+
});
|
|
87
66
|
}
|
|
88
67
|
|
|
89
68
|
/**
|
|
@@ -141,6 +120,11 @@ export function createCrapDriftDetector(opts = {}) {
|
|
|
141
120
|
const baselineDir = opts.baselineDir ?? '.agents/state';
|
|
142
121
|
const baselinePath = path.join(cwd, baselineDir, BASELINE_FILENAME);
|
|
143
122
|
const logger = opts.logger ?? null;
|
|
123
|
+
const store = createSnapshotStore({
|
|
124
|
+
fs,
|
|
125
|
+
baselinePath,
|
|
126
|
+
metadata: { ceiling, threshold },
|
|
127
|
+
});
|
|
144
128
|
|
|
145
129
|
let baseline = null;
|
|
146
130
|
|
|
@@ -220,39 +204,13 @@ export function createCrapDriftDetector(opts = {}) {
|
|
|
220
204
|
}
|
|
221
205
|
}
|
|
222
206
|
baseline = snapshot;
|
|
223
|
-
|
|
224
|
-
try {
|
|
225
|
-
fs.mkdirSync?.(path.dirname(baselinePath), { recursive: true });
|
|
226
|
-
fs.writeFileSync(
|
|
227
|
-
baselinePath,
|
|
228
|
-
JSON.stringify(
|
|
229
|
-
{
|
|
230
|
-
capturedAt: new Date().toISOString(),
|
|
231
|
-
ceiling,
|
|
232
|
-
threshold,
|
|
233
|
-
scores: snapshot,
|
|
234
|
-
},
|
|
235
|
-
null,
|
|
236
|
-
2,
|
|
237
|
-
),
|
|
238
|
-
);
|
|
239
|
-
} catch {
|
|
240
|
-
// persistence is best-effort; the in-memory baseline still works
|
|
241
|
-
}
|
|
242
|
-
}
|
|
207
|
+
store.persist(snapshot);
|
|
243
208
|
return snapshot;
|
|
244
209
|
},
|
|
245
210
|
|
|
246
211
|
loadBaseline() {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const raw = fs.readFileSync(baselinePath, 'utf-8');
|
|
250
|
-
const parsed = JSON.parse(raw);
|
|
251
|
-
baseline = parsed?.scores ?? null;
|
|
252
|
-
return baseline;
|
|
253
|
-
} catch {
|
|
254
|
-
return null;
|
|
255
|
-
}
|
|
212
|
+
baseline = store.load();
|
|
213
|
+
return baseline;
|
|
256
214
|
},
|
|
257
215
|
|
|
258
216
|
async detect() {
|
package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import nodeFs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { resolveComponents } from '../../../baselines/components.js';
|
|
5
4
|
import { calculateForSource } from '../../../maintainability-engine.js';
|
|
6
|
-
import {
|
|
5
|
+
import { formatNumber } from './_bullet-format.js';
|
|
6
|
+
import {
|
|
7
|
+
createSnapshotStore,
|
|
8
|
+
walkComponentRegressions,
|
|
9
|
+
} from './component-drift.js';
|
|
7
10
|
|
|
8
11
|
const DEFAULT_THRESHOLD = 2.0;
|
|
9
12
|
// Distinct from the canonical ratchet baseline at `baselines/maintainability.json`
|
|
@@ -12,6 +15,35 @@ const DEFAULT_THRESHOLD = 2.0;
|
|
|
12
15
|
// grep for the canonical baseline no longer hits the snapshot.
|
|
13
16
|
const BASELINE_FILENAME = 'wave-mi-snapshot.json';
|
|
14
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Detect per-component maintainability regressions from a baseline rollup
|
|
20
|
+
* against the gate's configured floors. Pure — the caller loads the
|
|
21
|
+
* baseline via `lib/baselines/reader.js#load('maintainability')` and passes
|
|
22
|
+
* the resulting `{ rollup }` plus the gate config block.
|
|
23
|
+
*
|
|
24
|
+
* Bullet shape (Task #1919, Epic #1786):
|
|
25
|
+
*
|
|
26
|
+
* 📉 maintainability: <component> <axis> <value> < floor <floor>
|
|
27
|
+
*
|
|
28
|
+
* Maintainability is "higher is better" — every axis breach reports when
|
|
29
|
+
* `value < floor`. Component-scoped breaches do NOT trigger a `*` bullet
|
|
30
|
+
* unless `*` itself breaches. The walk itself is the shared
|
|
31
|
+
* `component-drift.js` helper (Story #3984).
|
|
32
|
+
*
|
|
33
|
+
* @param {{
|
|
34
|
+
* rollup?: Record<string, Record<string, number>>,
|
|
35
|
+
* gateConfig?: { floors?: Record<string, Record<string, number>> } & object,
|
|
36
|
+
* }} params
|
|
37
|
+
* @returns {string[]}
|
|
38
|
+
*/
|
|
39
|
+
export function detectComponentRegressions(params = {}) {
|
|
40
|
+
return walkComponentRegressions(params, {
|
|
41
|
+
isBreach: (value, target) => value < target,
|
|
42
|
+
formatBullet: (name, axis, value, target) =>
|
|
43
|
+
`📉 maintainability: ${name} ${axis} ${formatNumber(value)} < floor ${formatNumber(target)}`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
15
47
|
/**
|
|
16
48
|
* Detects per-file maintainability drop versus a wave-start baseline.
|
|
17
49
|
*
|
|
@@ -40,56 +72,6 @@ const BASELINE_FILENAME = 'wave-mi-snapshot.json';
|
|
|
40
72
|
* baselineDir?: string, // directory (under cwd) to persist snapshot
|
|
41
73
|
* }} [opts]
|
|
42
74
|
*/
|
|
43
|
-
/**
|
|
44
|
-
* Detect per-component maintainability regressions from a baseline rollup
|
|
45
|
-
* against the gate's configured floors. Pure — the caller loads the
|
|
46
|
-
* baseline via `lib/baselines/reader.js#load('maintainability')` and passes
|
|
47
|
-
* the resulting `{ rollup }` plus the gate config block.
|
|
48
|
-
*
|
|
49
|
-
* Bullet shape (Task #1919, Epic #1786):
|
|
50
|
-
*
|
|
51
|
-
* 📉 maintainability: <component> <axis> <value> < floor <floor>
|
|
52
|
-
*
|
|
53
|
-
* Maintainability is "higher is better" — every axis breach reports when
|
|
54
|
-
* `value < floor`. Component-scoped breaches do NOT trigger a `*` bullet
|
|
55
|
-
* unless `*` itself breaches.
|
|
56
|
-
*
|
|
57
|
-
* @param {{
|
|
58
|
-
* rollup?: Record<string, Record<string, number>>,
|
|
59
|
-
* gateConfig?: { floors?: Record<string, Record<string, number>> } & object,
|
|
60
|
-
* }} params
|
|
61
|
-
* @returns {string[]}
|
|
62
|
-
*/
|
|
63
|
-
export function detectComponentRegressions(params = {}) {
|
|
64
|
-
const rollup = params.rollup ?? {};
|
|
65
|
-
const gateConfig = params.gateConfig ?? {};
|
|
66
|
-
const floors = gateConfig.floors ?? {};
|
|
67
|
-
const components = resolveComponents(gateConfig);
|
|
68
|
-
const names = new Set([
|
|
69
|
-
...Object.keys(components),
|
|
70
|
-
...Object.keys(floors),
|
|
71
|
-
...Object.keys(rollup),
|
|
72
|
-
]);
|
|
73
|
-
const bullets = [];
|
|
74
|
-
for (const name of [...names].sort(componentOrder)) {
|
|
75
|
-
const aggregate = rollup[name];
|
|
76
|
-
if (!aggregate || typeof aggregate !== 'object') continue;
|
|
77
|
-
const floor = floors[name] ?? floors['*'];
|
|
78
|
-
if (!floor || typeof floor !== 'object') continue;
|
|
79
|
-
for (const axis of Object.keys(floor).sort()) {
|
|
80
|
-
const target = floor[axis];
|
|
81
|
-
const value = aggregate[axis];
|
|
82
|
-
if (typeof target !== 'number' || !Number.isFinite(target)) continue;
|
|
83
|
-
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
|
|
84
|
-
if (value >= target) continue;
|
|
85
|
-
bullets.push(
|
|
86
|
-
`📉 maintainability: ${name} ${axis} ${formatNumber(value)} < floor ${formatNumber(target)}`,
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return bullets;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
75
|
export function createMaintainabilityDriftDetector(opts = {}) {
|
|
94
76
|
const fs = opts.fs ?? nodeFs;
|
|
95
77
|
const cwd = opts.cwd ?? process.cwd();
|
|
@@ -100,6 +82,7 @@ export function createMaintainabilityDriftDetector(opts = {}) {
|
|
|
100
82
|
: DEFAULT_THRESHOLD;
|
|
101
83
|
const baselineDir = opts.baselineDir ?? '.agents/state';
|
|
102
84
|
const baselinePath = path.join(cwd, baselineDir, BASELINE_FILENAME);
|
|
85
|
+
const store = createSnapshotStore({ fs, baselinePath });
|
|
103
86
|
|
|
104
87
|
let baseline = null;
|
|
105
88
|
|
|
@@ -126,34 +109,13 @@ export function createMaintainabilityDriftDetector(opts = {}) {
|
|
|
126
109
|
if (s != null) snapshot[f] = s;
|
|
127
110
|
}
|
|
128
111
|
baseline = snapshot;
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
fs.mkdirSync?.(path.dirname(baselinePath), { recursive: true });
|
|
132
|
-
fs.writeFileSync(
|
|
133
|
-
baselinePath,
|
|
134
|
-
JSON.stringify(
|
|
135
|
-
{ capturedAt: new Date().toISOString(), scores: snapshot },
|
|
136
|
-
null,
|
|
137
|
-
2,
|
|
138
|
-
),
|
|
139
|
-
);
|
|
140
|
-
} catch {
|
|
141
|
-
// persistence is best-effort; the in-memory baseline still works
|
|
142
|
-
}
|
|
143
|
-
}
|
|
112
|
+
store.persist(snapshot);
|
|
144
113
|
return snapshot;
|
|
145
114
|
},
|
|
146
115
|
|
|
147
116
|
loadBaseline() {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const raw = fs.readFileSync(baselinePath, 'utf-8');
|
|
151
|
-
const parsed = JSON.parse(raw);
|
|
152
|
-
baseline = parsed?.scores ?? null;
|
|
153
|
-
return baseline;
|
|
154
|
-
} catch {
|
|
155
|
-
return null;
|
|
156
|
-
}
|
|
117
|
+
baseline = store.load();
|
|
118
|
+
return baseline;
|
|
157
119
|
},
|
|
158
120
|
|
|
159
121
|
async detect() {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* The renderer survives because its `{ body, payload }` output still feeds
|
|
15
15
|
* two non-comment contracts: the `renderedBody` markdown the `/story-deliver`
|
|
16
|
-
* and `single-story-deliver` CLIs (`story-phase.js`, `story-
|
|
16
|
+
* and `single-story-deliver` CLIs (`story-phase.js`, the inline `story-init.js` prepare step)
|
|
17
17
|
* relay to chat so the operator sees the phase table inline, and the snapshot
|
|
18
18
|
* payload returned in those CLIs' JSON envelopes. `upsertStoryRunProgress`
|
|
19
19
|
* therefore renders-only: it computes the body/payload and (optionally) mirrors
|
|
@@ -90,7 +90,7 @@ const STORY_PHASE_STATUS_EMOJI = {
|
|
|
90
90
|
/**
|
|
91
91
|
* Build the canonical default `phases[]` array for a freshly-initialized
|
|
92
92
|
* 3-tier Story snapshot. All entries are `pending`; timestamps are null.
|
|
93
|
-
* Exported so call sites (story-
|
|
93
|
+
* Exported so call sites (the story-init prepare step, story-phase) and
|
|
94
94
|
* tests can build the same shape without re-implementing it.
|
|
95
95
|
*
|
|
96
96
|
* @returns {Array<{ name: string, status: 'pending', startedAt: null, endedAt: null }>}
|
|
@@ -9,8 +9,6 @@
|
|
|
9
9
|
* "storyId": <number>,
|
|
10
10
|
* "status": "done" | "blocked" | "failed",
|
|
11
11
|
* "phase": "init|implementing|closing|blocked|done",
|
|
12
|
-
* "tasksDone": <number>,
|
|
13
|
-
* "tasksTotal": <number>,
|
|
14
12
|
* "branchDeleted": <boolean>,
|
|
15
13
|
* "blockerCommentId": <string|null>,
|
|
16
14
|
* "detail": <string|undefined>,
|
|
@@ -119,8 +117,6 @@ function validateStoryReturnShape(obj) {
|
|
|
119
117
|
}
|
|
120
118
|
const value = { storyId, status };
|
|
121
119
|
if (typeof obj.phase === 'string') value.phase = obj.phase;
|
|
122
|
-
if (Number.isInteger(obj.tasksDone)) value.tasksDone = obj.tasksDone;
|
|
123
|
-
if (Number.isInteger(obj.tasksTotal)) value.tasksTotal = obj.tasksTotal;
|
|
124
120
|
if (typeof obj.branchDeleted === 'boolean') {
|
|
125
121
|
value.branchDeleted = obj.branchDeleted;
|
|
126
122
|
}
|
|
@@ -143,8 +139,8 @@ function quote(text) {
|
|
|
143
139
|
* Story ticket's live state when the sub-agent return cannot be trusted.
|
|
144
140
|
*
|
|
145
141
|
* The result is always conservative — `status: 'failed'` unless the live
|
|
146
|
-
* ticket carries `agent::done` (or `state: 'closed'`). The phase
|
|
147
|
-
*
|
|
142
|
+
* ticket carries `agent::done` (or `state: 'closed'`). The phase is
|
|
143
|
+
* best-effort, sourced from the Story's `story-run-progress` comment;
|
|
148
144
|
* absence of the comment is non-fatal.
|
|
149
145
|
*
|
|
150
146
|
* Story #3907 — a Story carrying `agent::blocked` reconciles to
|
|
@@ -163,8 +159,6 @@ function quote(text) {
|
|
|
163
159
|
* storyId: number,
|
|
164
160
|
* status: 'done' | 'blocked' | 'failed',
|
|
165
161
|
* phase?: string,
|
|
166
|
-
* tasksDone?: number,
|
|
167
|
-
* tasksTotal?: number,
|
|
168
162
|
* blockerCommentId?: string,
|
|
169
163
|
* reconciledFromGitHub: true,
|
|
170
164
|
* reconcileError?: string,
|
|
@@ -223,8 +217,8 @@ export async function reconcileStoryFromGitHub({ provider, storyId } = {}) {
|
|
|
223
217
|
}
|
|
224
218
|
}
|
|
225
219
|
|
|
226
|
-
// Cross-look the story-run-progress comment for phase
|
|
227
|
-
//
|
|
220
|
+
// Cross-look the story-run-progress comment for the phase when present.
|
|
221
|
+
// Story #3909 retired the per-Story story-run-progress *comment*
|
|
228
222
|
// (the redundant mid-flight surface), so this read now usually finds nothing
|
|
229
223
|
// and the reconciled row degrades to label-only — which is fine: the labels
|
|
230
224
|
// are the authoritative state. Failure here is non-fatal.
|
|
@@ -237,12 +231,6 @@ export async function reconcileStoryFromGitHub({ provider, storyId } = {}) {
|
|
|
237
231
|
const payload = comment ? parseFencedJsonComment(comment) : null;
|
|
238
232
|
if (payload && typeof payload === 'object') {
|
|
239
233
|
if (typeof payload.phase === 'string') out.phase = payload.phase;
|
|
240
|
-
if (Array.isArray(payload.tasks)) {
|
|
241
|
-
out.tasksTotal = payload.tasks.length;
|
|
242
|
-
out.tasksDone = payload.tasks.filter(
|
|
243
|
-
(t) => t && t.state === 'done',
|
|
244
|
-
).length;
|
|
245
|
-
}
|
|
246
234
|
}
|
|
247
235
|
} catch (err) {
|
|
248
236
|
out.reconcileError = err?.message ?? String(err);
|