mandrel 1.68.0 → 1.70.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 +1 -1
- package/.agents/docs/agentrc-reference.json +1 -2
- package/.agents/docs/configuration.md +2 -4
- package/.agents/docs/workflows.md +1 -1
- package/.agents/schemas/agentrc.schema.json +1 -5
- package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
- package/.agents/scripts/agents-update-preflight.js +235 -0
- package/.agents/scripts/apply-quality-bootstrap.js +79 -0
- package/.agents/scripts/audit-labels-bootstrap.js +52 -30
- package/.agents/scripts/audit-to-stories.js +54 -0
- package/.agents/scripts/bootstrap.js +13 -3
- package/.agents/scripts/epic-deliver-preflight.js +30 -13
- package/.agents/scripts/epic-deliver-prepare.js +40 -53
- package/.agents/scripts/epic-execute-record-wave.js +119 -133
- package/.agents/scripts/generate-config-docs.js +189 -94
- package/.agents/scripts/lib/audit-suite/findings.js +0 -4
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
- package/.agents/scripts/lib/baseline-snapshot.js +163 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +13 -5
- package/.agents/scripts/lib/config/baselines.js +0 -20
- package/.agents/scripts/lib/config/explain.js +0 -2
- package/.agents/scripts/lib/config/limits.js +19 -8
- package/.agents/scripts/lib/config/temp-paths.js +0 -31
- package/.agents/scripts/lib/config-settings-schema.js +1 -2
- package/.agents/scripts/lib/crap-utils.js +281 -0
- package/.agents/scripts/lib/maintainability-utils.js +32 -9
- package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
- package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -162
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
- package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
- package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
- package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
- package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
- package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
- package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
- package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
- package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
- package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
- package/.agents/scripts/lib/story-adjacency.js +14 -10
- package/.agents/scripts/lib/story-body/story-body.js +142 -65
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
- package/.agents/scripts/lib/test-tiers.js +13 -7
- package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
- package/.agents/scripts/lib/wave-runner/tick.js +446 -216
- package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
- package/.agents/scripts/lint-label-vocabulary.js +1 -1
- package/.agents/scripts/providers/github/issues.js +48 -0
- package/.agents/scripts/providers/github.js +1 -0
- package/.agents/scripts/stories-wave-tick.js +262 -161
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/agents-update.md +205 -28
- package/.agents/workflows/deliver.md +12 -9
- package/.agents/workflows/helpers/deliver-epic.md +126 -90
- package/.agents/workflows/helpers/deliver-stories.md +131 -85
- package/.agents/workflows/helpers/plan-epic.md +13 -10
- package/.agents/workflows/plan.md +1 -1
- package/README.md +20 -0
- package/docs/CHANGELOG.md +46 -0
- package/lib/cli/registry.js +49 -6
- package/lib/cli/update.js +335 -332
- package/package.json +16 -11
- package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
|
@@ -2,17 +2,28 @@
|
|
|
2
2
|
* epic-run-state-store — stateless functions for reading and writing the
|
|
3
3
|
* `epic-run-state` structured comment used by `/deliver`.
|
|
4
4
|
*
|
|
5
|
+
* Story #4155 (Epic #4151) — the Epic `/deliver` runtime cut over from
|
|
6
|
+
* the wave-batch scheduler to the continuous ready-set core
|
|
7
|
+
* (`lib/wave-runner/ready-set.js`). The checkpoint shrank with it: it no
|
|
8
|
+
* longer carries `currentWave`, `plan[][]`, `totalWaves`, or the
|
|
9
|
+
* per-wave `waves[]` aggregation. The durable run state is now a flat
|
|
10
|
+
* **per-Story status map** (`stories: { [storyId]: { status, title?,
|
|
11
|
+
* blockerCommentId? } }`) plus the run-level `concurrencyCap` (the
|
|
12
|
+
* GLOBAL in-flight cap the ready-set selector honours), `phase`,
|
|
13
|
+
* `startedAt`, and `manualInterventions[]`. There is no resume-pointer
|
|
14
|
+
* to reconcile — the ready-set core re-derives adjacency and readiness
|
|
15
|
+
* from live Story bodies/labels on every tick, so the checkpoint only
|
|
16
|
+
* records terminal Story outcomes (for the auto-merge predicate, branch
|
|
17
|
+
* cleanup, and the operator rollup) and the run-level knobs.
|
|
18
|
+
*
|
|
5
19
|
* This module is the function-based replacement for the legacy
|
|
6
20
|
* `Checkpointer` class that previously lived at
|
|
7
|
-
* `./epic-runner/checkpointer.js`.
|
|
8
|
-
*
|
|
9
|
-
* preserved byte-for-byte. Story #2423 (Epic #2307) deleted the class
|
|
10
|
-
* file; the class API survives as a tests-only fixture at
|
|
21
|
+
* `./epic-runner/checkpointer.js`. Story #2423 (Epic #2307) deleted the
|
|
22
|
+
* class file; the class API survives as a tests-only fixture at
|
|
11
23
|
* `tests/fixtures/epic-run-state-store.js`.
|
|
12
24
|
*
|
|
13
25
|
* The comment is identified by a stable HTML marker so it can be overwritten
|
|
14
|
-
* idempotently across orchestrator restarts. The body is a fenced JSON block
|
|
15
|
-
* following the schema in tech spec #323.
|
|
26
|
+
* idempotently across orchestrator restarts. The body is a fenced JSON block.
|
|
16
27
|
*/
|
|
17
28
|
|
|
18
29
|
import { assertValidDeliverPhase } from './epic-runner/deliver-phases.js';
|
|
@@ -20,7 +31,15 @@ import { parseFencedJsonComment } from './structured-comment-parser.js';
|
|
|
20
31
|
import { findStructuredComment, upsertStructuredComment } from './ticketing.js';
|
|
21
32
|
|
|
22
33
|
export const EPIC_RUN_STATE_TYPE = 'epic-run-state';
|
|
23
|
-
export const CHECKPOINT_SCHEMA_VERSION =
|
|
34
|
+
export const CHECKPOINT_SCHEMA_VERSION = 2;
|
|
35
|
+
|
|
36
|
+
/** Terminal / in-progress per-Story statuses persisted on the checkpoint. */
|
|
37
|
+
export const STORY_STATUSES = Object.freeze([
|
|
38
|
+
'pending',
|
|
39
|
+
'done',
|
|
40
|
+
'blocked',
|
|
41
|
+
'failed',
|
|
42
|
+
]);
|
|
24
43
|
|
|
25
44
|
// Re-export the phase enum + index helper so downstream importers continue
|
|
26
45
|
// to use this module as a single import target.
|
|
@@ -40,6 +59,56 @@ function assertEpicId(epicId) {
|
|
|
40
59
|
}
|
|
41
60
|
}
|
|
42
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Normalize an inbound Story id (accepts the ticket `id` shape, the raw
|
|
64
|
+
* GitHub `number` shape, and a bare integer) to a positive integer, or
|
|
65
|
+
* `null` when it is absent / non-positive / non-integer.
|
|
66
|
+
*
|
|
67
|
+
* @param {object|number|string} entry
|
|
68
|
+
* @returns {number|null}
|
|
69
|
+
*/
|
|
70
|
+
function storyIdOf(entry) {
|
|
71
|
+
if (typeof entry === 'number') {
|
|
72
|
+
return Number.isInteger(entry) && entry > 0 ? entry : null;
|
|
73
|
+
}
|
|
74
|
+
if (!entry || typeof entry !== 'object') {
|
|
75
|
+
const n = Number(entry);
|
|
76
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
77
|
+
}
|
|
78
|
+
const raw = entry.id ?? entry.storyId ?? entry.number;
|
|
79
|
+
const id = Number(raw);
|
|
80
|
+
return Number.isInteger(id) && id > 0 ? id : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the initial per-Story status map from a list of Story records (or
|
|
85
|
+
* ids). Every Story seeds at `status: 'pending'`; an optional `title` is
|
|
86
|
+
* carried through when the record supplies one so the operator rollup and
|
|
87
|
+
* branch-cleanup surfaces have a label without a second fetch. Keys are the
|
|
88
|
+
* positive-integer Story ids as strings (JSON object keys are strings);
|
|
89
|
+
* shapeless / non-positive entries are dropped.
|
|
90
|
+
*
|
|
91
|
+
* Pure helper — exported for unit tests.
|
|
92
|
+
*
|
|
93
|
+
* @param {Array<object|number>} stories
|
|
94
|
+
* @returns {Record<string, { status: string, title?: string }>}
|
|
95
|
+
*/
|
|
96
|
+
export function buildStoryStatusMap(stories) {
|
|
97
|
+
const out = {};
|
|
98
|
+
for (const entry of Array.isArray(stories) ? stories : []) {
|
|
99
|
+
const id = storyIdOf(entry);
|
|
100
|
+
if (id === null) continue;
|
|
101
|
+
const record = { status: 'pending' };
|
|
102
|
+
const title =
|
|
103
|
+
entry && typeof entry === 'object' && typeof entry.title === 'string'
|
|
104
|
+
? entry.title
|
|
105
|
+
: undefined;
|
|
106
|
+
if (title) record.title = title;
|
|
107
|
+
out[String(id)] = record;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
43
112
|
/**
|
|
44
113
|
* Read and parse the checkpoint. Returns null if the comment is missing or
|
|
45
114
|
* unparseable (callers treat null as "start fresh").
|
|
@@ -60,7 +129,7 @@ export async function read({ provider, epicId } = {}) {
|
|
|
60
129
|
|
|
61
130
|
/**
|
|
62
131
|
* Overwrite the checkpoint with `state`. Idempotent — callers may invoke
|
|
63
|
-
* freely per
|
|
132
|
+
* freely per tick; the marker-scoped upsert deletes the prior comment.
|
|
64
133
|
*
|
|
65
134
|
* @param {{ provider: import('../ITicketingProvider.js').ITicketingProvider, epicId: number, state: object }} opts
|
|
66
135
|
*/
|
|
@@ -78,38 +147,48 @@ export async function write({ provider, epicId, state } = {}) {
|
|
|
78
147
|
}
|
|
79
148
|
|
|
80
149
|
/**
|
|
81
|
-
* Initial checkpoint for a brand-new run. Idempotent against re-dispatch
|
|
82
|
-
* when
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* `
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
150
|
+
* Initial checkpoint for a brand-new run. Idempotent against re-dispatch:
|
|
151
|
+
* when an existing checkpoint is found and the persisted `concurrencyCap`
|
|
152
|
+
* matches the incoming value, the existing state is returned verbatim (no
|
|
153
|
+
* rewrite) so a re-prepare preserves `startedAt`, prior Story statuses, and
|
|
154
|
+
* `manualInterventions`. When the cap differs (an operator re-tuned the
|
|
155
|
+
* global in-flight cap) it is refreshed in place; the Story status map is
|
|
156
|
+
* **merged** so any Story that already reached a terminal status keeps it
|
|
157
|
+
* while newly-discovered Stories are seeded at `pending`. Prepare owns the
|
|
158
|
+
* Story set (it overwrites it on every run) but never clobbers recorded
|
|
159
|
+
* progress.
|
|
90
160
|
*
|
|
91
|
-
* @param {{
|
|
161
|
+
* @param {{
|
|
162
|
+
* provider: import('../ITicketingProvider.js').ITicketingProvider,
|
|
163
|
+
* epicId: number,
|
|
164
|
+
* storyIds: Array<object|number>,
|
|
165
|
+
* concurrencyCap: number,
|
|
166
|
+
* }} opts
|
|
167
|
+
* `concurrencyCap` is the GLOBAL in-flight cap the ready-set selector
|
|
168
|
+
* honours (`selectReadySet({ globalCap })`).
|
|
92
169
|
*/
|
|
93
170
|
export async function initialize({
|
|
94
171
|
provider,
|
|
95
172
|
epicId,
|
|
96
|
-
|
|
173
|
+
storyIds,
|
|
97
174
|
concurrencyCap,
|
|
98
175
|
} = {}) {
|
|
99
176
|
assertProvider(provider);
|
|
100
177
|
assertEpicId(epicId);
|
|
178
|
+
const seededStories = buildStoryStatusMap(storyIds);
|
|
101
179
|
const existing = await read({ provider, epicId });
|
|
102
180
|
if (existing) {
|
|
181
|
+
const mergedStories = mergeStoryStatuses(existing.stories, seededStories);
|
|
103
182
|
if (
|
|
104
|
-
existing.
|
|
105
|
-
existing.
|
|
183
|
+
existing.concurrencyCap === concurrencyCap &&
|
|
184
|
+
storyMapsEqual(existing.stories, mergedStories)
|
|
106
185
|
) {
|
|
107
186
|
return existing;
|
|
108
187
|
}
|
|
109
188
|
return write({
|
|
110
189
|
provider,
|
|
111
190
|
epicId,
|
|
112
|
-
state: { ...existing,
|
|
191
|
+
state: { ...existing, concurrencyCap, stories: mergedStories },
|
|
113
192
|
});
|
|
114
193
|
}
|
|
115
194
|
return write({
|
|
@@ -118,119 +197,133 @@ export async function initialize({
|
|
|
118
197
|
state: {
|
|
119
198
|
epicId,
|
|
120
199
|
startedAt: new Date().toISOString(),
|
|
121
|
-
currentWave: 0,
|
|
122
|
-
totalWaves,
|
|
123
200
|
concurrencyCap,
|
|
124
201
|
phase: 'prepare',
|
|
125
|
-
|
|
126
|
-
blockerHistory: [],
|
|
202
|
+
stories: seededStories,
|
|
127
203
|
manualInterventions: [],
|
|
128
204
|
},
|
|
129
205
|
});
|
|
130
206
|
}
|
|
131
207
|
|
|
132
208
|
/**
|
|
133
|
-
*
|
|
134
|
-
* a
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* closed/merged Stories). The recomputed plan is therefore *shorter* and
|
|
140
|
-
* **re-indexed from 0** — `plan[0]` is the next ready wave. The preserved
|
|
141
|
-
* checkpoint, however, still carries the prior `currentWave` (e.g. `2`)
|
|
142
|
-
* and a `waves[]` history keyed to the *old* index space. `wave-tick.js`
|
|
143
|
-
* then indexes `plan[currentWave]` into the new plan and dispatches the
|
|
144
|
-
* wrong wave — silently skipping the Stories that are actually ready.
|
|
145
|
-
*
|
|
146
|
-
* Prepare already owns the `plan` field (it overwrites it on every run),
|
|
147
|
-
* so it must equally own the pointer that indexes into that plan. This
|
|
148
|
-
* helper is the single point of reconciliation:
|
|
149
|
-
*
|
|
150
|
-
* - When the recomputed `nextPlan` is **structurally identical** to the
|
|
151
|
-
* persisted `priorPlan` (an idempotent re-prepare with no Story
|
|
152
|
-
* completed since the last run), the pointer is preserved verbatim so
|
|
153
|
-
* in-flight wave progress is not lost.
|
|
154
|
-
* - When the recomputed `nextPlan` **differs** (a Story merged → the
|
|
155
|
-
* plan got shorter / re-indexed), the pointer is reset: `currentWave`
|
|
156
|
-
* to `0` (the new plan's index space starts at the first not-done
|
|
157
|
-
* wave) and `waves[]` to `[]` (the prior history references the old
|
|
158
|
-
* index space and would mis-key `readGateFailures`).
|
|
159
|
-
*
|
|
160
|
-
* Plan equality is compared on the Story-id matrix only — `title` /
|
|
161
|
-
* `worktree` churn on an otherwise-identical plan must not trip a reset.
|
|
209
|
+
* Merge a freshly-seeded Story status map onto a persisted one. Every Story
|
|
210
|
+
* present in either map appears in the result; when a Story exists in the
|
|
211
|
+
* prior map its recorded status / blockerCommentId win (recorded progress is
|
|
212
|
+
* never lost), while its `title` is refreshed from the incoming seed when the
|
|
213
|
+
* seed supplies one. Stories present only in the incoming seed are added at
|
|
214
|
+
* their seeded (`pending`) status. Pure — exported for unit tests.
|
|
162
215
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* @
|
|
166
|
-
* currentWave?: number,
|
|
167
|
-
* waves?: Array<unknown>,
|
|
168
|
-
* }} checkpoint The persisted checkpoint fields to reconcile.
|
|
169
|
-
* @param {Array<Array<{ id?: number, storyId?: number, number?: number }>>} priorPlan
|
|
170
|
-
* The plan currently persisted on the checkpoint (may be undefined on a
|
|
171
|
-
* first run).
|
|
172
|
-
* @param {Array<Array<{ id?: number, storyId?: number, number?: number }>>} nextPlan
|
|
173
|
-
* The freshly-recomputed plan prepare is about to persist.
|
|
174
|
-
* @returns {{ currentWave: number, waves: Array<unknown> }} The reconciled
|
|
175
|
-
* pointer fields. Always returns concrete values so the caller can spread
|
|
176
|
-
* them onto the checkpoint payload unconditionally.
|
|
216
|
+
* @param {Record<string, object>|undefined} prior
|
|
217
|
+
* @param {Record<string, object>} incoming
|
|
218
|
+
* @returns {Record<string, object>}
|
|
177
219
|
*/
|
|
178
|
-
export function
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
220
|
+
export function mergeStoryStatuses(prior, incoming) {
|
|
221
|
+
const priorMap = prior && typeof prior === 'object' ? prior : {};
|
|
222
|
+
const seedMap = incoming && typeof incoming === 'object' ? incoming : {};
|
|
223
|
+
const out = {};
|
|
224
|
+
for (const key of new Set([
|
|
225
|
+
...Object.keys(priorMap),
|
|
226
|
+
...Object.keys(seedMap),
|
|
227
|
+
])) {
|
|
228
|
+
const priorEntry = priorMap[key];
|
|
229
|
+
const seedEntry = seedMap[key];
|
|
230
|
+
if (priorEntry && typeof priorEntry === 'object') {
|
|
231
|
+
const merged = { ...priorEntry };
|
|
232
|
+
if (seedEntry && typeof seedEntry.title === 'string') {
|
|
233
|
+
merged.title = seedEntry.title;
|
|
234
|
+
}
|
|
235
|
+
out[key] = merged;
|
|
236
|
+
} else {
|
|
237
|
+
out[key] = seedEntry;
|
|
238
|
+
}
|
|
185
239
|
}
|
|
186
|
-
|
|
187
|
-
// 0-indexed over the remaining not-done waves. Reset the pointer and
|
|
188
|
-
// drop the stale history so `wave-tick.js` reads `plan[0]`.
|
|
189
|
-
return { currentWave: 0, waves: [] };
|
|
240
|
+
return out;
|
|
190
241
|
}
|
|
191
242
|
|
|
192
243
|
/**
|
|
193
|
-
*
|
|
194
|
-
* `
|
|
195
|
-
*
|
|
196
|
-
* positions. `title` / `worktree` fields are ignored so cosmetic churn on
|
|
197
|
-
* an otherwise-identical plan does not register as a change.
|
|
244
|
+
* Structural equality on two Story status maps — same key set and, per key,
|
|
245
|
+
* the same `status`, `title`, and `blockerCommentId`. Used by `initialize`
|
|
246
|
+
* to decide whether an idempotent re-prepare needs a rewrite. Pure.
|
|
198
247
|
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* @param {Array<Array<object>>|undefined} a
|
|
202
|
-
* @param {Array<Array<object>>|undefined} b
|
|
248
|
+
* @param {Record<string, object>|undefined} a
|
|
249
|
+
* @param {Record<string, object>|undefined} b
|
|
203
250
|
* @returns {boolean}
|
|
204
251
|
*/
|
|
205
|
-
function
|
|
206
|
-
const left =
|
|
207
|
-
const right =
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
252
|
+
function storyMapsEqual(a, b) {
|
|
253
|
+
const left = a && typeof a === 'object' ? a : {};
|
|
254
|
+
const right = b && typeof b === 'object' ? b : {};
|
|
255
|
+
const keys = Object.keys(left);
|
|
256
|
+
if (keys.length !== Object.keys(right).length) return false;
|
|
257
|
+
for (const key of keys) {
|
|
258
|
+
const l = left[key] ?? {};
|
|
259
|
+
const r = right[key];
|
|
260
|
+
if (!r) return false;
|
|
261
|
+
if (
|
|
262
|
+
l.status !== r.status ||
|
|
263
|
+
l.title !== r.title ||
|
|
264
|
+
l.blockerCommentId !== r.blockerCommentId
|
|
265
|
+
) {
|
|
266
|
+
return false;
|
|
215
267
|
}
|
|
216
268
|
}
|
|
217
269
|
return true;
|
|
218
270
|
}
|
|
219
271
|
|
|
220
272
|
/**
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
273
|
+
* Record a per-Story terminal (or in-progress) status on the checkpoint.
|
|
274
|
+
* Reads the current state first, splices the single Story's record into the
|
|
275
|
+
* `stories` map, and re-writes. Other Stories and all run-level fields are
|
|
276
|
+
* preserved verbatim. Tolerant of a legacy/absent `stories` map (treated as
|
|
277
|
+
* empty) so a checkpoint that predates a field is upgraded in place.
|
|
225
278
|
*
|
|
226
|
-
* @param {
|
|
227
|
-
*
|
|
279
|
+
* @param {{
|
|
280
|
+
* provider: import('../ITicketingProvider.js').ITicketingProvider,
|
|
281
|
+
* epicId: number,
|
|
282
|
+
* storyId: number,
|
|
283
|
+
* status: string,
|
|
284
|
+
* title?: string,
|
|
285
|
+
* blockerCommentId?: string|number|null,
|
|
286
|
+
* }} opts
|
|
287
|
+
* @returns {Promise<object>} the persisted state
|
|
228
288
|
*/
|
|
229
|
-
function
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
289
|
+
export async function recordStoryStatus({
|
|
290
|
+
provider,
|
|
291
|
+
epicId,
|
|
292
|
+
storyId,
|
|
293
|
+
status,
|
|
294
|
+
title,
|
|
295
|
+
blockerCommentId,
|
|
296
|
+
} = {}) {
|
|
297
|
+
assertProvider(provider);
|
|
298
|
+
assertEpicId(epicId);
|
|
299
|
+
const id = storyIdOf(storyId);
|
|
300
|
+
if (id === null) {
|
|
301
|
+
throw new TypeError(
|
|
302
|
+
'recordStoryStatus: storyId must be a positive integer',
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (!STORY_STATUSES.includes(status)) {
|
|
306
|
+
throw new RangeError(
|
|
307
|
+
`recordStoryStatus: status "${status}" must be one of: ${STORY_STATUSES.join(', ')}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
const existing = (await read({ provider, epicId })) ?? {};
|
|
311
|
+
const stories =
|
|
312
|
+
existing.stories && typeof existing.stories === 'object'
|
|
313
|
+
? { ...existing.stories }
|
|
314
|
+
: {};
|
|
315
|
+
const prior = stories[String(id)] ?? {};
|
|
316
|
+
const record = { ...prior, status };
|
|
317
|
+
if (typeof title === 'string' && title) record.title = title;
|
|
318
|
+
if (status === 'blocked' && blockerCommentId != null) {
|
|
319
|
+
record.blockerCommentId = String(blockerCommentId);
|
|
320
|
+
}
|
|
321
|
+
stories[String(id)] = record;
|
|
322
|
+
return write({
|
|
323
|
+
provider,
|
|
324
|
+
epicId,
|
|
325
|
+
state: { ...existing, stories },
|
|
326
|
+
});
|
|
234
327
|
}
|
|
235
328
|
|
|
236
329
|
/**
|
|
@@ -37,20 +37,6 @@ export function escapePipes(s) {
|
|
|
37
37
|
return String(s).replace(/\|/g, '\\|');
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
* Render a millisecond duration as a compact human-readable string. Used
|
|
42
|
-
* in the wave-elapsed header to keep the snapshot tight.
|
|
43
|
-
*/
|
|
44
|
-
export function formatElapsed(ms) {
|
|
45
|
-
const s = Math.max(0, Math.floor(ms / 1000));
|
|
46
|
-
const h = Math.floor(s / 3600);
|
|
47
|
-
const m = Math.floor((s % 3600) / 60);
|
|
48
|
-
const sec = s % 60;
|
|
49
|
-
if (h) return `${h}h ${m}m`;
|
|
50
|
-
if (m) return `${m}m ${sec}s`;
|
|
51
|
-
return `${sec}s`;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
40
|
/**
|
|
55
41
|
* Derive the high-level state classification for a single ticket. Reads
|
|
56
42
|
* the canonical `agent::*` label set first, then falls back to the GitHub
|
|
@@ -69,35 +55,6 @@ export function deriveState(ticket, AGENT_LABELS) {
|
|
|
69
55
|
return 'unknown';
|
|
70
56
|
}
|
|
71
57
|
|
|
72
|
-
/**
|
|
73
|
-
* Build the markdown table for the per-poll progress snapshot. Switches
|
|
74
|
-
* between a 3-column (ID / State / Title) and a 4-column (Wave / ID /
|
|
75
|
-
* State / Title) form depending on whether any row carries a wave index —
|
|
76
|
-
* the wider form fires when the reporter has a plan set and is rendering
|
|
77
|
-
* every wave instead of just the current one.
|
|
78
|
-
*/
|
|
79
|
-
export function renderProgressTable(rows) {
|
|
80
|
-
const includeWaveCol = rows.some((r) => Number.isInteger(r.wave));
|
|
81
|
-
if (includeWaveCol) {
|
|
82
|
-
return [
|
|
83
|
-
'| Wave | ID | State | Title |',
|
|
84
|
-
'|---|---|---|---|',
|
|
85
|
-
...rows.map(
|
|
86
|
-
(r) =>
|
|
87
|
-
`| ${r.wave + 1} | #${r.id} | ${STATE_EMOJI[r.state] ?? ''} ${r.state} | ${escapePipes(r.title)} |`,
|
|
88
|
-
),
|
|
89
|
-
].join('\n');
|
|
90
|
-
}
|
|
91
|
-
return [
|
|
92
|
-
'| ID | State | Title |',
|
|
93
|
-
'|---|---|---|',
|
|
94
|
-
...rows.map(
|
|
95
|
-
(r) =>
|
|
96
|
-
`| #${r.id} | ${STATE_EMOJI[r.state] ?? ''} ${r.state} | ${escapePipes(r.title)} |`,
|
|
97
|
-
),
|
|
98
|
-
].join('\n');
|
|
99
|
-
}
|
|
100
|
-
|
|
101
58
|
/**
|
|
102
59
|
* Declarative descriptor table that drives the Notable bullet block. Each
|
|
103
60
|
* descriptor names a row state, the emoji prefix to render, and the
|
|
@@ -196,86 +153,36 @@ export async function renderNotable({ rows, detectors = [], wave, logger }) {
|
|
|
196
153
|
return items.join('\n');
|
|
197
154
|
}
|
|
198
155
|
|
|
199
|
-
/**
|
|
200
|
-
* Compose the full per-poll snapshot body. Pure with respect to the
|
|
201
|
-
* supplied state — the caller passes the resolved `rows`, the plan/wave
|
|
202
|
-
* context, the optional aggregated phase-timings block, and the now()
|
|
203
|
-
* clock; the renderer assembles header + table + Notable + phase-timings.
|
|
204
|
-
*/
|
|
205
|
-
export async function renderProgressBody({
|
|
206
|
-
rows,
|
|
207
|
-
plan,
|
|
208
|
-
currentWave,
|
|
209
|
-
epicStartedAt,
|
|
210
|
-
now,
|
|
211
|
-
detectors,
|
|
212
|
-
phaseSummariesBlock,
|
|
213
|
-
logger,
|
|
214
|
-
}) {
|
|
215
|
-
const done = rows.filter((r) => r.state === 'done').length;
|
|
216
|
-
const total = rows.length;
|
|
217
|
-
const totalWaves = plan?.length ?? currentWave?.totalWaves ?? '?';
|
|
218
|
-
const currentWaveNum = currentWave
|
|
219
|
-
? currentWave.index + 1
|
|
220
|
-
: (plan?.length ?? '?');
|
|
221
|
-
const waveLabel = `Wave ${currentWaveNum}/${totalWaves}`;
|
|
222
|
-
const elapsedSrc = epicStartedAt ?? currentWave?.startedAt ?? null;
|
|
223
|
-
const elapsed = elapsedSrc
|
|
224
|
-
? ` · ${formatElapsed(now() - new Date(elapsedSrc))} elapsed`
|
|
225
|
-
: '';
|
|
226
|
-
|
|
227
|
-
const header = `### 📊 Progress — ${waveLabel} · ${done}/${total} closed${elapsed}`;
|
|
228
|
-
const table = renderProgressTable(rows);
|
|
229
|
-
const notable = await renderNotable({
|
|
230
|
-
rows,
|
|
231
|
-
detectors,
|
|
232
|
-
wave: currentWave,
|
|
233
|
-
logger,
|
|
234
|
-
});
|
|
235
|
-
const parts = [header, '', table, '', '**Notable**', notable];
|
|
236
|
-
if (phaseSummariesBlock) parts.push('', phaseSummariesBlock);
|
|
237
|
-
return parts.join('\n');
|
|
238
|
-
}
|
|
239
|
-
|
|
240
156
|
/**
|
|
241
157
|
* Render and upsert the rolled-up `epic-run-progress` comment on the Epic.
|
|
242
158
|
*
|
|
243
|
-
* Called by `/deliver`
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
159
|
+
* Called by `/deliver`'s per-Story status recorder
|
|
160
|
+
* (`epic-execute-record-wave.js`) after each recorder beat. Story #4155
|
|
161
|
+
* (Epic #4151) — the Epic `/deliver` runtime cut over from the wave-batch
|
|
162
|
+
* scheduler to the continuous ready-set core, so the rollup is a **flat
|
|
163
|
+
* per-Story table** keyed by the checkpoint's `stories` status map, not a
|
|
164
|
+
* wave-grouped table. There is no `currentWave` / `totalWaves` / `waves[]`
|
|
165
|
+
* in the payload any more.
|
|
250
166
|
*
|
|
251
|
-
* The payload schema
|
|
252
|
-
* #902:
|
|
167
|
+
* The payload schema:
|
|
253
168
|
*
|
|
254
169
|
* {
|
|
255
170
|
* "kind": "epic-run-progress",
|
|
256
171
|
* "epicId": <number>,
|
|
257
|
-
* "
|
|
258
|
-
* "totalWaves": <number>,
|
|
259
|
-
* "waves": [ { wave, concurrencyCap?, stories[] } ],
|
|
172
|
+
* "stories": [ { id, title?, state, blockerCommentId? } ],
|
|
260
173
|
* "startedAt"?: "<iso8601>",
|
|
261
174
|
* "updatedAt": "<iso8601>"
|
|
262
175
|
* }
|
|
263
176
|
*
|
|
264
177
|
* The function does not re-derive Story state from labels — it trusts the
|
|
265
|
-
* `
|
|
266
|
-
*
|
|
178
|
+
* `stories` map supplied by the caller (the checkpoint's recorded per-Story
|
|
179
|
+
* statuses).
|
|
267
180
|
*
|
|
268
181
|
* @param {{
|
|
269
182
|
* provider: import('../../../ITicketingProvider.js').ITicketingProvider,
|
|
270
183
|
* epicId: number,
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* concurrencyCap?: number,
|
|
274
|
-
* stories?: Array<{ id: number, title?: string, state?: string,
|
|
275
|
-
* blockerCommentId?: string }>,
|
|
276
|
-
* }>,
|
|
277
|
-
* currentWave: number,
|
|
278
|
-
* totalWaves: number,
|
|
184
|
+
* stories: Record<string, { status?: string, title?: string,
|
|
185
|
+
* blockerCommentId?: string }>,
|
|
279
186
|
* startedAt?: string,
|
|
280
187
|
* now?: () => Date,
|
|
281
188
|
* }} args
|
|
@@ -285,9 +192,7 @@ export async function renderProgressBody({
|
|
|
285
192
|
export async function upsertEpicRunProgress({
|
|
286
193
|
provider,
|
|
287
194
|
epicId,
|
|
288
|
-
|
|
289
|
-
currentWave,
|
|
290
|
-
totalWaves,
|
|
195
|
+
stories,
|
|
291
196
|
startedAt,
|
|
292
197
|
now = () => new Date(),
|
|
293
198
|
} = {}) {
|
|
@@ -300,73 +205,44 @@ export async function upsertEpicRunProgress({
|
|
|
300
205
|
if (!Number.isInteger(epicIdNum) || epicIdNum <= 0) {
|
|
301
206
|
throw new TypeError('upsertEpicRunProgress requires a numeric epicId');
|
|
302
207
|
}
|
|
303
|
-
const
|
|
304
|
-
if (!Number.isInteger(totalWavesNum) || totalWavesNum < 0) {
|
|
305
|
-
throw new TypeError(
|
|
306
|
-
'upsertEpicRunProgress requires a non-negative integer totalWaves',
|
|
307
|
-
);
|
|
308
|
-
}
|
|
309
|
-
const currentWaveNum = Number(currentWave);
|
|
310
|
-
if (!Number.isInteger(currentWaveNum) || currentWaveNum < 0) {
|
|
311
|
-
throw new TypeError(
|
|
312
|
-
'upsertEpicRunProgress requires a non-negative integer currentWave',
|
|
313
|
-
);
|
|
314
|
-
}
|
|
315
|
-
const wavesArr = Array.isArray(waves) ? waves : [];
|
|
208
|
+
const statusMap = stories && typeof stories === 'object' ? stories : {};
|
|
316
209
|
|
|
317
210
|
const updatedAt = now().toISOString();
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
211
|
+
const rows = Object.entries(statusMap)
|
|
212
|
+
.map(([key, rec]) => {
|
|
213
|
+
const id = Number(key);
|
|
214
|
+
const state = String(rec?.status ?? 'pending');
|
|
215
|
+
const row = { id, title: String(rec?.title ?? ''), state };
|
|
216
|
+
if (rec?.blockerCommentId != null) {
|
|
217
|
+
row.blockerCommentId = String(rec.blockerCommentId);
|
|
218
|
+
}
|
|
219
|
+
return row;
|
|
220
|
+
})
|
|
221
|
+
.filter((r) => Number.isInteger(r.id) && r.id > 0)
|
|
222
|
+
.sort((a, b) => a.id - b.id);
|
|
329
223
|
|
|
330
224
|
const payload = {
|
|
331
225
|
kind: EPIC_RUN_PROGRESS_TYPE,
|
|
332
226
|
epicId: epicIdNum,
|
|
333
|
-
|
|
334
|
-
totalWaves: totalWavesNum,
|
|
335
|
-
waves: normalizedWaves,
|
|
227
|
+
stories: rows,
|
|
336
228
|
updatedAt,
|
|
337
229
|
};
|
|
338
230
|
if (typeof startedAt === 'string' && startedAt) {
|
|
339
231
|
payload.startedAt = startedAt;
|
|
340
232
|
}
|
|
341
233
|
|
|
342
|
-
const totalStories =
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
);
|
|
346
|
-
const doneStories = normalizedWaves.reduce(
|
|
347
|
-
(acc, w) => acc + w.stories.filter((s) => s?.state === 'done').length,
|
|
348
|
-
0,
|
|
349
|
-
);
|
|
350
|
-
const header = `### 📊 Epic Progress — Wave ${Math.min(currentWaveNum + 1, Math.max(totalWavesNum, 1))}/${totalWavesNum || '?'} · ${doneStories}/${totalStories} stories done`;
|
|
234
|
+
const totalStories = rows.length;
|
|
235
|
+
const doneStories = rows.filter((s) => s.state === 'done').length;
|
|
236
|
+
const header = `### 📊 Epic Progress — ${doneStories}/${totalStories} stories done`;
|
|
351
237
|
|
|
352
|
-
const tableLines = ['|
|
|
353
|
-
if (
|
|
354
|
-
tableLines.push('| — |
|
|
238
|
+
const tableLines = ['| ID | State | Title |', '|---|---|---|'];
|
|
239
|
+
if (rows.length === 0) {
|
|
240
|
+
tableLines.push('| — | _(no stories yet)_ | — |');
|
|
355
241
|
} else {
|
|
356
|
-
for (const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
for (const s of w.stories) {
|
|
362
|
-
const state = String(s?.state ?? 'unknown');
|
|
363
|
-
const emoji = STATE_EMOJI[state] ?? '';
|
|
364
|
-
const id = Number(s?.id ?? 0);
|
|
365
|
-
const title = escapePipes(truncate(String(s?.title ?? ''), 60));
|
|
366
|
-
tableLines.push(
|
|
367
|
-
`| ${w.wave + 1} | #${id} | ${emoji} ${state} | ${title} |`,
|
|
368
|
-
);
|
|
369
|
-
}
|
|
242
|
+
for (const s of rows) {
|
|
243
|
+
const emoji = STATE_EMOJI[s.state] ?? '';
|
|
244
|
+
const title = escapePipes(truncate(s.title, 60));
|
|
245
|
+
tableLines.push(`| #${s.id} | ${emoji} ${s.state} | ${title} |`);
|
|
370
246
|
}
|
|
371
247
|
}
|
|
372
248
|
|