mandrel 2.22.0 → 2.23.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/docs/configuration.md +1 -0
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/scripts/diagnose-friction.js +95 -4
- package/.agents/scripts/lib/config-settings-schema-delivery.js +8 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +29 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +18 -6
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +76 -4
- package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
- package/.agents/workflows/helpers/plan-reference.md +40 -0
- package/.agents/workflows/plan.md +21 -16
- package/docs/CHANGELOG.md +14 -0
- package/package.json +1 -1
|
@@ -291,6 +291,7 @@ top-level keys are validation errors.
|
|
|
291
291
|
| `feedbackLoop` | No | `object` | — | Nested configuration block. |
|
|
292
292
|
| `feedbackLoop.auditResultsAutoFile` | No | `boolean` | `true` | When true (default), the close-time audit-results graduator auto-files non-blocking audit-results findings as follow-up issues routed by source classification. Set to false to suppress auto-filing; findings remain accessible in the structured comments on the Story. |
|
|
293
293
|
| `feedbackLoop.retroProposals` | No | `boolean` | `true` | When true (default), the retro auto-files its actionable routed proposals as meta::<framework-gap\|consumer-improvement> + friction::<category> issues via the graduator pre-parsed-findings seam, and the rendered retro sections list the filed issue numbers instead of paste-ready gh command stanzas. Set to false to fall back to the command stanzas. |
|
|
294
|
+
| `feedbackLoop.frictionWindowDays` | No | `integer` | `30` | How many days back the run-scope friction recurrence window reaches (Story #4850). The window spans every surviving per-Story signal stream rather than the triggering run's own Stories, so that a defect firing once per Story can reach the actionable threshold; this bounds it by age so a defect fixed weeks ago stops re-routing. Rows older than the bound — and rows carrying no readable timestamp — are excluded and counted on the roll-up step result. Default 30. |
|
|
294
295
|
| `auditToStories` | No | `object` | — | Nested configuration block. |
|
|
295
296
|
| `auditToStories.severityFloor` | No | `"critical"` \| `"high"` \| `"medium"` \| `"low"` \| `"all"` | `"high"` | Minimum severity a finding must meet to be proposed as a Story on an unattended `/audit-to-stories --auto` sweep (Story #4626). Default high. |
|
|
296
297
|
| `auditToStories.autoComment` | No | `boolean` | `true` | When true (default), `/audit-to-stories --auto` posts a re-detected comment on an already-open matched Issue instead of silently skipping it. |
|
|
@@ -1430,6 +1430,12 @@
|
|
|
1430
1430
|
"type": "boolean",
|
|
1431
1431
|
"default": true,
|
|
1432
1432
|
"description": "When true (default), the retro auto-files its actionable routed proposals as meta::<framework-gap|consumer-improvement> + friction::<category> issues via the graduator pre-parsed-findings seam, and the rendered retro sections list the filed issue numbers instead of paste-ready gh command stanzas. Set to false to fall back to the command stanzas."
|
|
1433
|
+
},
|
|
1434
|
+
"frictionWindowDays": {
|
|
1435
|
+
"type": "integer",
|
|
1436
|
+
"minimum": 1,
|
|
1437
|
+
"default": 30,
|
|
1438
|
+
"description": "How many days back the run-scope friction recurrence window reaches (Story #4850). The window spans every surviving per-Story signal stream rather than the triggering run's own Stories, so that a defect firing once per Story can reach the actionable threshold; this bounds it by age so a defect fixed weeks ago stops re-routing. Rows older than the bound — and rows carrying no readable timestamp — are excluded and counted on the roll-up step result. Default 30."
|
|
1433
1439
|
}
|
|
1434
1440
|
},
|
|
1435
1441
|
"additionalProperties": false
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { spawnSync } from 'node:child_process';
|
|
33
33
|
import crypto from 'node:crypto';
|
|
34
|
+
import { constants as osConstants } from 'node:os';
|
|
34
35
|
import { getLimits, resolveConfig } from './lib/config-resolver.js';
|
|
35
36
|
import { Logger } from './lib/Logger.js';
|
|
36
37
|
import { appendSignal } from './lib/observability/signals-writer.js';
|
|
@@ -97,6 +98,72 @@ function classifyFrictionCategory(errorOutput) {
|
|
|
97
98
|
return { category: matched.category, remediation: matched.remediation };
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* `spawnSync`'s own `timeout` option kills the child with `SIGTERM`, so a
|
|
103
|
+
* SIGTERM observed here almost always means the interceptor's configured
|
|
104
|
+
* `executionTimeoutMs` bound fired. Any other signal — a `SIGKILL` from the
|
|
105
|
+
* OOM killer, an operator `kill -9` — originated outside this process.
|
|
106
|
+
*
|
|
107
|
+
* @type {string}
|
|
108
|
+
*/
|
|
109
|
+
const INTERCEPTOR_TIMEOUT_SIGNAL = 'SIGTERM';
|
|
110
|
+
|
|
111
|
+
/** Shell convention for "the process died by signal N": exit `128 + N`. */
|
|
112
|
+
const SIGNAL_EXIT_BASE = 128;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Describe a child that never exited normally — `status === null`, Node's
|
|
116
|
+
* documented representation of "did not exit normally". The raw status must
|
|
117
|
+
* never reach `process.exit`, because `process.exit(null)` exits **0**: the
|
|
118
|
+
* interceptor would report success for a command it just watched get killed
|
|
119
|
+
* (Story #4851).
|
|
120
|
+
*
|
|
121
|
+
* Which signal fired is the diagnostic value: SIGTERM points at the
|
|
122
|
+
* interceptor's own bound, anything else at the host. Recording that plus the
|
|
123
|
+
* bound itself is what makes the row actionable to a consumer who cannot edit
|
|
124
|
+
* the materialized framework tree.
|
|
125
|
+
*
|
|
126
|
+
* Deliberately module-local and pure — exporting it for tests would fail the
|
|
127
|
+
* `--production` dead-exports ratchet, and folding it into `main` would spend
|
|
128
|
+
* the file's per-file maintainability-delta headroom. The CLI contract is the
|
|
129
|
+
* seam the unit tests drive.
|
|
130
|
+
*
|
|
131
|
+
* @param {{signal: (string|null), error?: {message?: string}}} result A
|
|
132
|
+
* `spawnSync` result whose `status` is `null`.
|
|
133
|
+
* @param {number} executionTimeoutMs The resolved interceptor bound, in ms.
|
|
134
|
+
* @returns {{category: string, remediation: string, details: object,
|
|
135
|
+
* preview: string, exitCode: number}}
|
|
136
|
+
*/
|
|
137
|
+
function describeAbnormalExit(result, executionTimeoutMs) {
|
|
138
|
+
const signal = typeof result.signal === 'string' ? result.signal : null;
|
|
139
|
+
if (signal === null) {
|
|
140
|
+
return {
|
|
141
|
+
category: FRICTION_DEFAULT.category,
|
|
142
|
+
remediation: FRICTION_DEFAULT.remediation,
|
|
143
|
+
details: {
|
|
144
|
+
killedBySignal: null,
|
|
145
|
+
killOrigin: 'spawn-failure',
|
|
146
|
+
executionTimeoutMs,
|
|
147
|
+
},
|
|
148
|
+
preview: `Command did not exit normally and reported no signal: ${result.error?.message ?? 'spawn produced no exit status'}.`,
|
|
149
|
+
exitCode: 1,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const timedOut = signal === INTERCEPTOR_TIMEOUT_SIGNAL;
|
|
154
|
+
const killOrigin = timedOut ? 'interceptor-timeout' : 'external';
|
|
155
|
+
const signum = osConstants.signals[signal];
|
|
156
|
+
return {
|
|
157
|
+
category: timedOut ? 'Execution Timeout' : 'Execution Killed',
|
|
158
|
+
remediation: timedOut
|
|
159
|
+
? ` - ${signal} matches the interceptor's own executionTimeoutMs bound (${executionTimeoutMs}ms), so the command was almost certainly cut off rather than broken. Split it into smaller steps, or raise the bound.`
|
|
160
|
+
: ` - ${signal} originated outside the interceptor — the executionTimeoutMs bound (${executionTimeoutMs}ms) did not fire, so suspect an OOM kill or a hard kill from the host. Reduce the command's memory footprint or give the host more headroom.`,
|
|
161
|
+
details: { killedBySignal: signal, killOrigin, executionTimeoutMs },
|
|
162
|
+
preview: `Command terminated by signal ${signal} (${killOrigin}); executionTimeoutMs=${executionTimeoutMs}.`,
|
|
163
|
+
exitCode: Number.isInteger(signum) ? SIGNAL_EXIT_BASE + signum : 1,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
100
167
|
function toIntOrNull(value) {
|
|
101
168
|
if (value == null) return null;
|
|
102
169
|
const n = Number.parseInt(String(value), 10);
|
|
@@ -121,6 +188,7 @@ function buildFrictionSignal({
|
|
|
121
188
|
category,
|
|
122
189
|
commandStr,
|
|
123
190
|
errorPreview,
|
|
191
|
+
terminationDetails = null,
|
|
124
192
|
}) {
|
|
125
193
|
return {
|
|
126
194
|
kind: 'friction',
|
|
@@ -133,11 +201,14 @@ function buildFrictionSignal({
|
|
|
133
201
|
// and always null.
|
|
134
202
|
taskId: null,
|
|
135
203
|
category,
|
|
204
|
+
// `emitter.command` is what `classifySignalSource` step 1 scans, so the
|
|
205
|
+
// command-scan stays authoritative for `source`: a consumer command killed
|
|
206
|
+
// by its own host remains consumer-actionable (Story #4851).
|
|
136
207
|
emitter: {
|
|
137
208
|
tool: 'diagnose-friction.js',
|
|
138
209
|
command: commandStr,
|
|
139
210
|
},
|
|
140
|
-
details: { errorPreview },
|
|
211
|
+
details: { errorPreview, ...(terminationDetails ?? {}) },
|
|
141
212
|
};
|
|
142
213
|
}
|
|
143
214
|
|
|
@@ -176,10 +247,22 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
176
247
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
177
248
|
|
|
178
249
|
if (result.status !== 0) {
|
|
250
|
+
// A `null` status means the child never exited normally; the cause lives in
|
|
251
|
+
// `result.signal`, not in the status.
|
|
252
|
+
const abnormal =
|
|
253
|
+
result.status === null
|
|
254
|
+
? describeAbnormalExit(result, executionTimeoutMs)
|
|
255
|
+
: null;
|
|
256
|
+
// With both streams empty an abnormal termination names its signal; the
|
|
257
|
+
// `Unknown exit code` fallback is therefore reachable only with a real
|
|
258
|
+
// numeric status, never as `Unknown exit code null`.
|
|
259
|
+
const noOutputFallback = abnormal
|
|
260
|
+
? abnormal.preview
|
|
261
|
+
: `Unknown exit code ${result.status}`;
|
|
179
262
|
const errorOutput = (
|
|
180
263
|
result.stderr ||
|
|
181
264
|
result.stdout ||
|
|
182
|
-
|
|
265
|
+
noOutputFallback
|
|
183
266
|
).trim();
|
|
184
267
|
const errorPreview = errorOutput.substring(0, 500);
|
|
185
268
|
|
|
@@ -188,7 +271,12 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
188
271
|
'Command failed. Appending friction signal to NDJSON stream...',
|
|
189
272
|
);
|
|
190
273
|
|
|
191
|
-
|
|
274
|
+
// An abnormal termination classifies itself: the marker scan reads output
|
|
275
|
+
// the kill may have truncated (or never produced), so it cannot name the
|
|
276
|
+
// signal.
|
|
277
|
+
const classified = classifyFrictionCategory(errorOutput);
|
|
278
|
+
const category = abnormal?.category ?? classified.category;
|
|
279
|
+
const remediation = abnormal?.remediation ?? classified.remediation;
|
|
192
280
|
|
|
193
281
|
const { storyId: resolvedStoryId, epicId: resolvedEpicId } =
|
|
194
282
|
resolveContextIds({ storyId, epicId }, config);
|
|
@@ -199,6 +287,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
199
287
|
category,
|
|
200
288
|
commandStr,
|
|
201
289
|
errorPreview,
|
|
290
|
+
terminationDetails: abnormal?.details ?? null,
|
|
202
291
|
});
|
|
203
292
|
|
|
204
293
|
// Story #2874 — accept story-only context (no parent Epic). When
|
|
@@ -236,7 +325,9 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
236
325
|
Logger.error(remediation);
|
|
237
326
|
Logger.error('----------------------------------------\n');
|
|
238
327
|
|
|
239
|
-
process.exit(result.status)
|
|
328
|
+
// Never `process.exit(result.status)` on a null status — that exits 0 and
|
|
329
|
+
// reports success for a killed command.
|
|
330
|
+
process.exit(abnormal?.exitCode ?? result.status);
|
|
240
331
|
} else {
|
|
241
332
|
process.exit(0);
|
|
242
333
|
}
|
|
@@ -322,12 +322,20 @@ const REVIEW_SCHEMA = {
|
|
|
322
322
|
* issues via the graduator pre-parsed-findings seam, and the rendered retro
|
|
323
323
|
* sections list the filed issue numbers instead of paste-ready `gh` command
|
|
324
324
|
* stanzas; set it to `false` to fall back to the command stanzas.
|
|
325
|
+
*
|
|
326
|
+
* `frictionWindowDays` (Story #4850) bounds the run-scope friction recurrence
|
|
327
|
+
* window by row age. The window deliberately spans every surviving signal
|
|
328
|
+
* stream rather than the triggering run's own Stories — that is what lets a
|
|
329
|
+
* once-per-Story systemic defect reach the ≥ 2 actionable threshold — which
|
|
330
|
+
* left it unbounded in time, so a defect fixed weeks ago kept re-routing. An
|
|
331
|
+
* integer ≥ 1; unset means 30 days.
|
|
325
332
|
*/
|
|
326
333
|
const FEEDBACK_LOOP_SCHEMA = {
|
|
327
334
|
type: 'object',
|
|
328
335
|
properties: {
|
|
329
336
|
auditResultsAutoFile: { type: 'boolean' },
|
|
330
337
|
retroProposals: { type: 'boolean' },
|
|
338
|
+
frictionWindowDays: { type: 'integer', minimum: 1 },
|
|
331
339
|
},
|
|
332
340
|
additionalProperties: false,
|
|
333
341
|
};
|
|
@@ -355,9 +355,18 @@ export async function emitCloseRecoveredFriction({ storyId, config } = {}) {
|
|
|
355
355
|
* `tool-degraded` from the scoped-lint runner from one out of lens
|
|
356
356
|
* materialization. It is descriptive, never a routing key.
|
|
357
357
|
*
|
|
358
|
+
* `ts` joined the shape in Story #4850, and it joined it **here** rather than
|
|
359
|
+
* in a second read beside the run-scope gather. The composer had no notion of
|
|
360
|
+
* *when* its corpus happened, so it borrowed the triggering run as the window
|
|
361
|
+
* and titled every proposal with a claim the evidence block below it already
|
|
362
|
+
* contradicted. Carrying the timestamp through the one shared normalizer is
|
|
363
|
+
* what lets the recurrence window be both bounded and describable; re-reading
|
|
364
|
+
* it beside one of the two gathers is precisely the drift that made the
|
|
365
|
+
* recovery-netting unreachable in Story #4649.
|
|
366
|
+
*
|
|
358
367
|
* @param {unknown} parsed One parsed NDJSON row.
|
|
359
368
|
* @param {number} fallbackStoryId Stream owner, used when the row has none.
|
|
360
|
-
* @returns {{ category: string, source: 'framework'|'consumer', storyId: number, tool: string, details: object }|null}
|
|
369
|
+
* @returns {{ category: string, source: 'framework'|'consumer', storyId: number, tool: string, ts: string|null, details: object }|null}
|
|
361
370
|
*/
|
|
362
371
|
export function normalizeGatheredSignal(parsed, fallbackStoryId) {
|
|
363
372
|
if (!parsed || typeof parsed !== 'object') return null;
|
|
@@ -374,6 +383,7 @@ export function normalizeGatheredSignal(parsed, fallbackStoryId) {
|
|
|
374
383
|
source: parsed.source === 'framework' ? 'framework' : 'consumer',
|
|
375
384
|
storyId: Number.isInteger(recordStoryId) ? recordStoryId : fallbackStoryId,
|
|
376
385
|
tool: typeof emitterTool === 'string' ? emitterTool.trim() : '',
|
|
386
|
+
ts: parsableTimestamp(parsed.ts),
|
|
377
387
|
details:
|
|
378
388
|
parsed.details && typeof parsed.details === 'object'
|
|
379
389
|
? parsed.details
|
|
@@ -381,6 +391,24 @@ export function normalizeGatheredSignal(parsed, fallbackStoryId) {
|
|
|
381
391
|
};
|
|
382
392
|
}
|
|
383
393
|
|
|
394
|
+
/**
|
|
395
|
+
* The row's `ts` when it is a string a `Date` can read, else `null`.
|
|
396
|
+
*
|
|
397
|
+
* `signal-event.schema.json` requires `ts`, and both producers stamp
|
|
398
|
+
* `new Date().toISOString()` — but the gathers read whatever survives on disk,
|
|
399
|
+
* including rows a truncated write left half-formed. Resolving to `null`
|
|
400
|
+
* rather than guessing a time is what lets the recurrence window exclude an
|
|
401
|
+
* undateable row explicitly instead of aging it in as "recent".
|
|
402
|
+
*
|
|
403
|
+
* @param {unknown} value
|
|
404
|
+
* @returns {string|null}
|
|
405
|
+
*/
|
|
406
|
+
function parsableTimestamp(value) {
|
|
407
|
+
if (typeof value !== 'string') return null;
|
|
408
|
+
const trimmed = value.trim();
|
|
409
|
+
return Number.isFinite(Date.parse(trimmed)) ? trimmed : null;
|
|
410
|
+
}
|
|
411
|
+
|
|
384
412
|
/**
|
|
385
413
|
* Pure predicate: is this signal a recovery marker for its own category?
|
|
386
414
|
* Shared with the retro composer so the "recovered" discriminator is read
|
|
Binary file
|
|
@@ -557,22 +557,29 @@ async function executeFollowUpRollup({
|
|
|
557
557
|
// Shared with the story-scoped gather (Story #4649): `storyId` + `details`
|
|
558
558
|
// are what the composer's recovery-netting keys on, and two hand-rolled
|
|
559
559
|
// copies of this loop are how they got dropped in the first place.
|
|
560
|
-
const signals = await gatherRunFrictionSignals(
|
|
560
|
+
const { signals, window: frictionWindow } = await gatherRunFrictionSignals(
|
|
561
|
+
stories,
|
|
562
|
+
config,
|
|
563
|
+
);
|
|
561
564
|
const repos = resolveFollowUpRepos(config);
|
|
562
565
|
const primaryId = Number(stories[0]);
|
|
566
|
+
// Story #4850 — `runToken` and `anchorStoryIds` are INPUTS. This used to
|
|
567
|
+
// compose with the primary Story's numeric id standing in for the run and
|
|
568
|
+
// then rewrite the rendered title/body by regex over a `plan-run \d+`
|
|
569
|
+
// substring, which meant the composer's own wording could not be changed
|
|
570
|
+
// without silently breaking the patch. `anchorStoryIds` is what lets the
|
|
571
|
+
// composer tell a corpus confined to this run from one spanning the whole
|
|
572
|
+
// surviving window, so it never titles the latter as if it were the former.
|
|
563
573
|
const proposals = composeRoutedProposals({
|
|
564
574
|
anchorId: Number.isInteger(primaryId) ? primaryId : 1,
|
|
565
575
|
anchorKind: 'run',
|
|
576
|
+
runToken: String(planRunId ?? ''),
|
|
577
|
+
anchorStoryIds: stories,
|
|
566
578
|
frameworkRepo: repos.frameworkRepo,
|
|
567
579
|
consumerRepo: repos.consumerRepo,
|
|
568
580
|
signals,
|
|
569
581
|
unresolvedBlockedEvents: [],
|
|
570
582
|
});
|
|
571
|
-
// Patch titles to mention the plan-run token (anchorKind run uses numeric id).
|
|
572
|
-
for (const item of [...proposals.framework, ...proposals.consumer]) {
|
|
573
|
-
item.title = item.title.replace(/plan-run \d+/, `plan-run ${planRunId}`);
|
|
574
|
-
item.body = item.body.replace(/plan-run \d+/g, `plan-run ${planRunId}`);
|
|
575
|
-
}
|
|
576
583
|
const graduated = await graduateFn({
|
|
577
584
|
epicId: primaryId,
|
|
578
585
|
provider,
|
|
@@ -619,6 +626,11 @@ async function executeFollowUpRollup({
|
|
|
619
626
|
signalCount: signals.length,
|
|
620
627
|
storyCount: stories.length,
|
|
621
628
|
filed: graduated.filed?.length ?? 0,
|
|
629
|
+
// Story #4850 — the recurrence window the gather actually applied, and what
|
|
630
|
+
// it dropped. `signalCount` alone cannot distinguish "the window is bounded
|
|
631
|
+
// at 30 days and 40 rows aged out" from "nothing older exists", and an
|
|
632
|
+
// operator triaging a roll-up needs to know which corpus produced it.
|
|
633
|
+
frictionWindow,
|
|
622
634
|
// Story #4828 — everything below is what the roll-up saw and what became
|
|
623
635
|
// of it. The pre-#4828 result reported `signalCount` and `filed` and
|
|
624
636
|
// nothing in between, so nine signals routing into one proposal whose
|
|
@@ -26,6 +26,33 @@ import { upsertStructuredComment } from './ticketing.js';
|
|
|
26
26
|
|
|
27
27
|
export const FOLLOW_UPS_COMMENT_TYPE = 'follow-ups';
|
|
28
28
|
|
|
29
|
+
/** Milliseconds in one day — the unit `frictionWindowDays` is expressed in. */
|
|
30
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
/** Window bound applied when `frictionWindowDays` is unset. */
|
|
33
|
+
const DEFAULT_FRICTION_WINDOW_DAYS = 30;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How many days back the run-scope recurrence window reaches (Story #4850).
|
|
37
|
+
*
|
|
38
|
+
* Defaults to 30 rather than to "unbounded": the widened cross-run window
|
|
39
|
+
* exists to let a once-per-Story defect reach the ≥ 2 threshold, and 30 days is
|
|
40
|
+
* long enough for that while short enough that a defect fixed last month stops
|
|
41
|
+
* re-routing. An absent, non-integer, or sub-1 value takes the default — the
|
|
42
|
+
* runtime AJV in `config-settings-schema-delivery.js` rejects those at load, so
|
|
43
|
+
* reaching this fallback means the config never went through the validator.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} [config]
|
|
46
|
+
* @returns {number}
|
|
47
|
+
*/
|
|
48
|
+
function resolveFrictionWindowDays(config) {
|
|
49
|
+
const raw = config?.delivery?.feedbackLoop?.frictionWindowDays;
|
|
50
|
+
const days = Number(raw);
|
|
51
|
+
return Number.isInteger(days) && days >= 1
|
|
52
|
+
? days
|
|
53
|
+
: DEFAULT_FRICTION_WINDOW_DAYS;
|
|
54
|
+
}
|
|
55
|
+
|
|
29
56
|
/**
|
|
30
57
|
* @param {object} [config]
|
|
31
58
|
* @returns {{ frameworkRepo: string, consumerRepo: string, currentRepo: { owner: string, repo: string } }}
|
|
@@ -145,18 +172,55 @@ function signalIdentity(parsed, file, lineNumber) {
|
|
|
145
172
|
* Unusable ids are skipped rather than throwing — a roll-up must not fail the
|
|
146
173
|
* epilogue over one malformed entry.
|
|
147
174
|
*
|
|
175
|
+
* **Bounded by age, not by run (Story #4850).** Widening the window to the
|
|
176
|
+
* whole surviving temp tree also made it unbounded in *time*: a defect fixed
|
|
177
|
+
* weeks ago kept its occurrences on disk and kept re-routing forever, burying
|
|
178
|
+
* a genuine new regression underneath a historical ledger. Rows older than
|
|
179
|
+
* `delivery.feedbackLoop.frictionWindowDays` (default 30) are excluded, as are
|
|
180
|
+
* rows carrying no `ts` a `Date` can read — excluding an undateable row is the
|
|
181
|
+
* direction that fails toward under-counting, and under-counting fails toward
|
|
182
|
+
* not filing. Both exclusions are **counted and reported**, so a caller can
|
|
183
|
+
* tell a bounded window from an unbounded one without reading prose.
|
|
184
|
+
*
|
|
185
|
+
* A recovery marker is written after the incident it cancels, so a marker can
|
|
186
|
+
* never fall outside a window its incident is inside — the netting cannot be
|
|
187
|
+
* broken by the age floor.
|
|
188
|
+
*
|
|
148
189
|
* @param {Array<number|string>} storyIds The run's own Stories.
|
|
149
190
|
* @param {object} [config]
|
|
150
|
-
* @
|
|
191
|
+
* @param {{ now?: number }} [clock] Injected epoch-ms seam so a test can pin
|
|
192
|
+
* the window without touching the system clock.
|
|
193
|
+
* @returns {Promise<{
|
|
194
|
+
* signals: Array<{ category: string, source: 'framework'|'consumer', storyId: number, ts: string|null, details: object }>,
|
|
195
|
+
* window: { days: number, cutoff: string, excludedStale: number, excludedUnparseable: number },
|
|
196
|
+
* }>}
|
|
151
197
|
*/
|
|
152
|
-
export async function gatherRunFrictionSignals(
|
|
198
|
+
export async function gatherRunFrictionSignals(
|
|
199
|
+
storyIds,
|
|
200
|
+
config,
|
|
201
|
+
{ now = Date.now() } = {},
|
|
202
|
+
) {
|
|
203
|
+
const days = resolveFrictionWindowDays(config);
|
|
204
|
+
const cutoffMs = now - days * MS_PER_DAY;
|
|
153
205
|
const signals = [];
|
|
154
206
|
const seen = new Set();
|
|
207
|
+
let excludedStale = 0;
|
|
208
|
+
let excludedUnparseable = 0;
|
|
155
209
|
const take = (parsed, fallbackStoryId, identity) => {
|
|
156
210
|
if (seen.has(identity)) return;
|
|
157
211
|
seen.add(identity);
|
|
158
212
|
const signal = normalizeGatheredSignal(parsed, fallbackStoryId);
|
|
159
|
-
if (signal)
|
|
213
|
+
if (!signal) return;
|
|
214
|
+
const ms = signal.ts === null ? Number.NaN : Date.parse(signal.ts);
|
|
215
|
+
if (!Number.isFinite(ms)) {
|
|
216
|
+
excludedUnparseable += 1;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (ms < cutoffMs) {
|
|
220
|
+
excludedStale += 1;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
signals.push(signal);
|
|
160
224
|
};
|
|
161
225
|
|
|
162
226
|
for (const raw of Array.isArray(storyIds) ? storyIds : []) {
|
|
@@ -178,7 +242,15 @@ export async function gatherRunFrictionSignals(storyIds, config) {
|
|
|
178
242
|
config,
|
|
179
243
|
);
|
|
180
244
|
|
|
181
|
-
return
|
|
245
|
+
return {
|
|
246
|
+
signals,
|
|
247
|
+
window: {
|
|
248
|
+
days,
|
|
249
|
+
cutoff: new Date(cutoffMs).toISOString(),
|
|
250
|
+
excludedStale,
|
|
251
|
+
excludedUnparseable,
|
|
252
|
+
},
|
|
253
|
+
};
|
|
182
254
|
}
|
|
183
255
|
|
|
184
256
|
/**
|
|
@@ -155,7 +155,7 @@ The serialized \`body\` string renders these markdown sections (in order):
|
|
|
155
155
|
- **Observed-behavior claims open with \`Current state (verified <date>)\`.** Any Spec claim about how the codebase behaves today MUST open with that preamble (e.g. \`Current state (verified 2026-07-17): …\`) so a reader can tell a verified observation from an assumption, and can tell when the observation went stale.
|
|
156
156
|
- **Intent-then-proxy acceptance shape.** When an acceptance item verifies through a proxy check (a grep, a file-exists probe, an exit-code test), state the intent clause before the proxy check — what outcome the check stands in for — so the proxy never becomes the goal (e.g. "the workflow names hygiene findings as re-author input: \`grep -n "textHygiene" …\` exits 0").
|
|
157
157
|
- **Slicing checkpoints are one line each.** Each \`## Slicing\` checkpoint is a single line naming the checkpoint; implementation detail lives in \`## Spec\`, never duplicated into Slicing. A Slicing section outweighing its Spec is a defect the text-hygiene lint flags.
|
|
158
|
-
- **Bodies record decisions, never questions to the operator.** Never persist an open question ("Flag if…", "TBD", "confirm with the operator") into a Story body — the executing sub-agent is non-interactive and cannot answer it.
|
|
158
|
+
- **Bodies record decisions, never questions to the operator.** Never persist an open question ("Flag if…", "TBD", "confirm with the operator") into a Story body — the executing sub-agent is non-interactive and cannot answer it. Triage each unknown by who can resolve it: an AFK-shaped unknown (a fact in docs, a third-party API surface, observable repo behavior) MUST be resolved by your own research before authoring — never restated as an assumption; only a HITL-shaped unknown (a genuine product or architecture call the operator owns) may be restated as a declarative Key Assumption the agent can act on, stating the default chosen (a decision-made-by-default).
|
|
159
159
|
- **non_goals** (OPTIONAL, in body string as the \`## Non-Goals\` section): A short list of capabilities or changes this Story explicitly does NOT deliver — an advisory negative-scope bound that fences the executing agent away from adjacent work. It is **advisory and NON-GATING**: the validator does not require, count, or reject on it, and an absent or empty section renders nothing. Use the EXACT single-word hyphenated heading spelling \`## Non-Goals\` (a space-separated heading like \`## Out of Scope\` is NOT recognized by the parser and will be dropped). Reach for it when a Story's negative boundary is non-obvious from its \`acceptance[]\` alone; omit it otherwise.
|
|
160
160
|
|
|
161
161
|
#### SPEC PROSE CONTRACT — state the contract, not the implementation:
|
|
@@ -36,6 +36,46 @@ whole surface exists to remove.
|
|
|
36
36
|
Mixed ids and prose in one invocation is a **hard error**: refuse and ask which
|
|
37
37
|
was meant, rather than guessing a mode and doing the wrong work.
|
|
38
38
|
|
|
39
|
+
## Unknown triage — AFK vs HITL
|
|
40
|
+
|
|
41
|
+
Every open question interrogation surfaces is triaged by **who can resolve
|
|
42
|
+
it**, not parked in one bucket (a shape borrowed from the Wayfinder skill's
|
|
43
|
+
HITL/AFK ticket typing):
|
|
44
|
+
|
|
45
|
+
- **AFK** (away from keyboard — the agent resolves it alone): the answer is a
|
|
46
|
+
fact something already records — third-party docs, a dependency's API
|
|
47
|
+
surface, observable behavior of this repo. Research it during interrogation
|
|
48
|
+
(per `.agents/instructions.md` § 1.C) and fold the answer into the plan as a
|
|
49
|
+
verified claim. An AFK unknown never becomes a Key Assumption — an
|
|
50
|
+
assumption standing in for a checkable fact is just an unchecked fact.
|
|
51
|
+
- **HITL** (human in the loop — only the operator can resolve it): a genuine
|
|
52
|
+
product or architecture call — what to support, what to drop, which
|
|
53
|
+
trade-off to prefer. Nothing the agent reads can answer it; presenting a
|
|
54
|
+
researched recommendation is fine, deciding is not.
|
|
55
|
+
|
|
56
|
+
Boundary examples: *"does library X support streaming?"* is AFK (read its
|
|
57
|
+
docs); *"should we drop Node 18 support?"* is HITL (a support-policy call);
|
|
58
|
+
*"does our CLI already validate this flag?"* is AFK (read the code);
|
|
59
|
+
*"which of two valid schema shapes should the new field use?"* is HITL when
|
|
60
|
+
both fit — but first verify it is not settled by an existing convention,
|
|
61
|
+
which would make it AFK.
|
|
62
|
+
|
|
63
|
+
**Attended runs** present the HITL list at Gate #1 as "needs your decision",
|
|
64
|
+
one line each, alongside the sharpened intent. **Under `--yes`** nobody is at
|
|
65
|
+
the keyboard: AFK unknowns are researched exactly as in an attended run, and
|
|
66
|
+
each HITL unknown degrades to a declarative Key Assumption that names the
|
|
67
|
+
default chosen and marks it a decision-made-by-default, e.g.:
|
|
68
|
+
|
|
69
|
+
> **Key Assumption (decision-made-by-default):** new-style envelopes only;
|
|
70
|
+
> re-emitting legacy envelopes was ruled out by default, not by the operator.
|
|
71
|
+
|
|
72
|
+
(Keep the assumption itself declarative — "flag if wrong" phrasing trips the
|
|
73
|
+
open-question hygiene lint, and the deliverer cannot answer it anyway.)
|
|
74
|
+
|
|
75
|
+
The marker keeps the operator's undelegated decisions findable after the
|
|
76
|
+
fact: reviewing a `--yes` plan means scanning its decisions-made-by-default,
|
|
77
|
+
not re-deriving which assumptions were really the agent's to make.
|
|
78
|
+
|
|
39
79
|
## Gate #1 → the light path (in-session handoff)
|
|
40
80
|
|
|
41
81
|
On a confirmed `deliverLightSuggestion`, `/plan` routes into
|
|
@@ -14,7 +14,7 @@ description:
|
|
|
14
14
|
|
|
15
15
|
Single planning path — there is no Epic/Story router, no scope-triage
|
|
16
16
|
`epic|story` verdict. **Derive the mode from what the operator typed, announce
|
|
17
|
-
it, then act
|
|
17
|
+
it, then act**:
|
|
18
18
|
|
|
19
19
|
| Invocation | Mode | Behavior |
|
|
20
20
|
| --- | --- | --- |
|
|
@@ -24,11 +24,10 @@ it, then act**; there is nothing for them to remember:
|
|
|
24
24
|
| `/plan 4712[,4713…]` | tickets | Fetch issue(s), analyze into proper Stories (prefer N=1 rewrite). |
|
|
25
25
|
| `/plan 4712`, already delivered | amends | Amend a shipped Story from a **delta envelope**, not a re-interrogation. |
|
|
26
26
|
|
|
27
|
-
**Resolving a bare id.**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
wasted run. Ask **only** for an open Story already at `agent::ready`.
|
|
27
|
+
**Resolving a bare id.** Read live state rather than asking: `agent::done` can
|
|
28
|
+
only be amended, an open unplanned issue can only be planned. **Announce the
|
|
29
|
+
derivation** — "4712 is `agent::done` → amending" — so a wrong read costs one
|
|
30
|
+
correction. Ask **only** for an open Story already at `agent::ready`.
|
|
32
31
|
|
|
33
32
|
`--body` is **not** a `/plan` entry; persist goes through `plan-persist.js`.
|
|
34
33
|
|
|
@@ -62,19 +61,26 @@ node .agents/scripts/plan-context.js --seed "<seed>" \
|
|
|
62
61
|
|
|
63
62
|
**Always pass `--out`.** Persist auto-discovers the envelope from `--plan-dir`
|
|
64
63
|
and derives source ids from its `sourceTickets[]`; the CLI also writes
|
|
65
|
-
**`stories.template.json`**,
|
|
64
|
+
**`stories.template.json`**, step 2's skeleton.
|
|
66
65
|
|
|
67
66
|
The envelope carries docs context, the story-author
|
|
68
67
|
prompt, `sourceTickets[]`, `duplicates[]` (open **Stories**, never Epics), and
|
|
69
68
|
advisory `complexitySignals` (**no routing authority**). A trivial scope earns
|
|
70
69
|
`--route-downgrade-reason "<why>"` at persist — shape-validated, failing closed
|
|
71
|
-
to `full` ([detail](helpers/plan-reference.md)).
|
|
72
|
-
|
|
70
|
+
to `full` ([detail](helpers/plan-reference.md)).
|
|
71
|
+
|
|
72
|
+
**Triage each unknown by resolver**
|
|
73
|
+
([detail](helpers/plan-reference.md)): an **AFK** unknown (research settles
|
|
74
|
+
it) is resolved before authoring, never assumed; a **HITL** unknown (an
|
|
75
|
+
operator call) goes to Gate #1 as "needs your decision". Under `--yes`, do
|
|
76
|
+
not ask free-form operator questions — AFK unknowns are still researched;
|
|
77
|
+
only HITL unknowns land in Key Assumptions, each marked a
|
|
78
|
+
decision-made-by-default.
|
|
73
79
|
|
|
74
80
|
**Gate #1** — STOP to confirm the sharpened plan intent and any
|
|
75
81
|
duplicate-candidate review. Under `--yes`, auto-proceed.
|
|
76
82
|
|
|
77
|
-
|
|
83
|
+
On a truthy `deliverLightSuggestion.suggested`, offer —
|
|
78
84
|
**advisory, never an automatic reroute** — to deliver the seed instead of
|
|
79
85
|
planning it. On confirm, route **in this session** into
|
|
80
86
|
[`helpers/deliver-light.md`](helpers/deliver-light.md), filling its gate from
|
|
@@ -88,9 +94,9 @@ A truthy `complexitySignals.uiSurface` marks a UI-touching plan: name
|
|
|
88
94
|
### 2. Author
|
|
89
95
|
|
|
90
96
|
**One-shot authoring.** Start from `stories.template.json`; author
|
|
91
|
-
`stories.json` in one pass.
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
`stories.json` in one pass. `body` is a markdown string **or** a structured
|
|
98
|
+
object; persist parses either, serializes the canonical markdown, and syncs
|
|
99
|
+
top-level `acceptance[]` /
|
|
94
100
|
`verify[]` into it — never dual-author those lists.
|
|
95
101
|
|
|
96
102
|
**Grounding = your reads + Phase 8.** Nothing inventories the repo for you:
|
|
@@ -149,9 +155,8 @@ node .agents/scripts/plan-persist.js \
|
|
|
149
155
|
[--source-tickets 123,456]
|
|
150
156
|
```
|
|
151
157
|
|
|
152
|
-
At lite shape, `--chain-on-clean` chains
|
|
153
|
-
one round-trip
|
|
154
|
-
review round-trip.
|
|
158
|
+
At lite shape, `--chain-on-clean` chains a clean dry-run into the real persist
|
|
159
|
+
in one round-trip; a full plan keeps its review round-trip.
|
|
155
160
|
|
|
156
161
|
Persist creates `type::story` issue(s) plus a `plan-run::<id>` grouping label
|
|
157
162
|
(**metadata only**); N>1 `depends_on` edges become `blocked by #<id>` footers.
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [2.23.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.22.0...mandrel-v2.23.0) (2026-07-30)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
* **plan:** triage interrogation unknowns by resolver — AFK research vs HITL operator decision (refs [#4845](https://github.com/dsj1984/mandrel/issues/4845)) ([#4846](https://github.com/dsj1984/mandrel/issues/4846)) ([3cfcd00](https://github.com/dsj1984/mandrel/commit/3cfcd00f3885fc588bdf2bb4597118a790f9b270))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
* **diagnose-friction:** name the signal that killed a child instead of reporting "Unknown exit code null" and exiting 0 ([#4851](https://github.com/dsj1984/mandrel/issues/4851)) ([#4853](https://github.com/dsj1984/mandrel/issues/4853)) ([1985be1](https://github.com/dsj1984/mandrel/commit/1985be1b97d751d094acf7dd371b97124039cc6d))
|
|
16
|
+
* **rollup:** describe the friction corpus by its own window, not the triggering run (refs [#4850](https://github.com/dsj1984/mandrel/issues/4850)) ([#4852](https://github.com/dsj1984/mandrel/issues/4852)) ([d866697](https://github.com/dsj1984/mandrel/commit/d8666976be30d218115b9db32602359d55379f88))
|
|
17
|
+
* **rollup:** track anchorKind in the triggering-anchor label ([#4854](https://github.com/dsj1984/mandrel/issues/4854)) ([b7e4ddf](https://github.com/dsj1984/mandrel/commit/b7e4ddf8c362f7c31c2abd73ee181a94f78f6dd2))
|
|
18
|
+
|
|
5
19
|
## [2.22.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.21.0...mandrel-v2.22.0) (2026-07-30)
|
|
6
20
|
|
|
7
21
|
|
package/package.json
CHANGED