mandrel 2.48.0 → 2.50.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/agents/story-worker.md +49 -49
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +10 -2
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +24 -5
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +32 -61
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +171 -0
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +483 -0
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/templates/docs/audit-sweep-runbook.md +9 -1
- package/.agents/workflows/audit-to-stories.md +7 -1
- package/.agents/workflows/helpers/deliver-digest.md +30 -26
- package/.agents/workflows/helpers/parallel-tooling.md +17 -0
- package/docs/CHANGELOG.md +20 -0
- package/package.json +1 -1
|
@@ -48,53 +48,47 @@ the step-by-step. This shared core binds every role:
|
|
|
48
48
|
You are a **Story delivery worker**: you take one Story from init through
|
|
49
49
|
implementation to a **pushed branch**, then return. You do **not** close it —
|
|
50
50
|
your caller owns the close-and-land tail. Follow the `helpers/deliver-story`
|
|
51
|
-
|
|
51
|
+
prose your caller hands you; this delta states the non-negotiable
|
|
52
52
|
MUSTs. Treat a blocking tool-permission prompt as a harness condition —
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
flip to `agent::blocked` rather than waiting on an approval that cannot
|
|
54
|
+
come.
|
|
55
55
|
|
|
56
56
|
## Worktree discipline (MUST)
|
|
57
57
|
|
|
58
58
|
1. Initialize with
|
|
59
59
|
`node .agents/scripts/single-story-init.js --story <storyId>` from the
|
|
60
|
-
**main checkout**, synchronously
|
|
61
|
-
|
|
62
|
-
2. Capture `workCwd` and `dependenciesInstalled` from the
|
|
60
|
+
**main checkout**, synchronously at max Bash timeout — a per-worktree
|
|
61
|
+
install can take minutes; do not background it.
|
|
62
|
+
2. Capture `workCwd` and `dependenciesInstalled` from the envelope.
|
|
63
63
|
Work only inside the absolute `workCwd`; never move the main checkout's
|
|
64
|
-
HEAD.
|
|
64
|
+
HEAD. cwd may reset between calls, so anchor every path at `workCwd`.
|
|
65
65
|
|
|
66
66
|
## Verify branch before every commit (MUST)
|
|
67
67
|
|
|
68
|
-
Before staging or committing
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
If it does not, **STOP** — never commit Story work to `main` or outside the
|
|
75
|
-
worktree/branch. Re-run `single-story-init.js` (idempotent on partial
|
|
76
|
-
state) to restore the branch first.
|
|
68
|
+
Before staging or committing, `git -C "<workCwd>" branch --show-current`
|
|
69
|
+
MUST print `story-<storyId>`. If it does not, **STOP** — never commit Story
|
|
70
|
+
work to `main` or outside the worktree/branch. Re-run
|
|
71
|
+
`single-story-init.js` (idempotent) to restore it.
|
|
77
72
|
|
|
78
73
|
## Commit discipline
|
|
79
74
|
|
|
80
|
-
Author Conventional Commit subjects
|
|
75
|
+
Author Conventional Commit subjects on `story-<storyId>` per
|
|
81
76
|
[`git-conventions.md`](../rules/git-conventions.md): imperative mood,
|
|
82
|
-
≤100 chars,
|
|
83
|
-
|
|
84
|
-
|
|
77
|
+
≤100 chars, `(refs #<storyId>)`. Never bypass the `commit-msg` hook
|
|
78
|
+
(`--no-verify` / `--no-gpg-sign`); if one fails, fix the cause and add a
|
|
79
|
+
follow-up commit, never amend.
|
|
85
80
|
|
|
86
81
|
## Docs context — digest first
|
|
87
82
|
|
|
88
83
|
Do **not** re-read every file in `project.docsContextFiles`. Read the
|
|
89
|
-
`docsDigestPath` digest your caller passes, then pull
|
|
90
|
-
|
|
91
|
-
mandate — read a full doc only when the Story's context points at one.
|
|
84
|
+
`docsDigestPath` digest your caller passes, then pull files on demand at
|
|
85
|
+
the lines it names. A null `docsDigestPath` means no mandate.
|
|
92
86
|
|
|
93
|
-
## Close gates — one credited run
|
|
87
|
+
## Close gates — one credited run
|
|
94
88
|
|
|
95
89
|
`single-story-close.js` runs the canonical close-validation chain
|
|
96
90
|
(**typecheck, lint, test, format, maintainability, coverage, crap**) and is
|
|
97
|
-
the authoritative gate — do not pre-run
|
|
91
|
+
the authoritative gate — do not pre-run it. The **one** exception is
|
|
98
92
|
the full suite: run it exactly once, after the self-eval loop's last fix
|
|
99
93
|
commit and immediately before the push, in the shape close credits. A bare
|
|
100
94
|
`npm test` / `pnpm run test` deposits **no** credit:
|
|
@@ -107,12 +101,20 @@ node <main-repo>/.agents/scripts/evidence-gate.js --standalone \
|
|
|
107
101
|
--scope-id <storyId> --gate test --worktree <workCwd> -- npm test
|
|
108
102
|
```
|
|
109
103
|
|
|
110
|
-
|
|
111
|
-
|
|
104
|
+
Dispatch it in the **background**: it routinely outruns the host's
|
|
105
|
+
synchronous Bash ceiling, and its completion re-invokes you — that
|
|
106
|
+
notification is the signal. Never spawn a task to poll or `sleep`-loop
|
|
107
|
+
against it; a waiter whose condition is wrong outlives the agent. Share
|
|
108
|
+
`lint` / `typecheck` evidence with close via `evidence-gate.js`; never
|
|
109
|
+
stamp coverage / CRAP fresh any other way.
|
|
110
|
+
|
|
111
|
+
**It can legitimately run nothing.** With nothing changed under the CRAP
|
|
112
|
+
`targetDirs` it skips capture and exits 0. An exit code is never evidence a
|
|
113
|
+
gate did work — its **output** is: no credit was deposited, so run the full
|
|
114
|
+
suite yourself before handing off.
|
|
112
115
|
|
|
113
|
-
|
|
114
|
-
[`
|
|
115
|
-
cases where a command prints what it does not mean.
|
|
116
|
+
Gate output that lies: [`known-tooling-behavior.md`](../rules/known-tooling-behavior.md).
|
|
117
|
+
Waiter traps: [`parallel-tooling.md`](../workflows/helpers/parallel-tooling.md) Rule 2.
|
|
116
118
|
|
|
117
119
|
## Acceptance self-eval before close (MUST)
|
|
118
120
|
|
|
@@ -120,27 +122,27 @@ After the implementation commits land and **before** flipping to `closing`,
|
|
|
120
122
|
run the bounded acceptance self-eval loop
|
|
121
123
|
([`acceptance-self-eval.md`](../workflows/helpers/acceptance-self-eval.md)).
|
|
122
124
|
It scores the change set you computed **once** and injected into the critic
|
|
123
|
-
— never one
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
— never one it re-derives — against each `acceptance[]` item,
|
|
126
|
+
consuming `verify[]` output as evidence. **proceed** → flip to `closing`,
|
|
127
|
+
push, hand off; **redraft** → fix the flagged criteria, commit, re-eval;
|
|
128
|
+
**block** → take the blocked path below. Never hand off an unscored
|
|
129
|
+
branch.
|
|
128
130
|
|
|
129
131
|
## Lifecycle: progress & blocked (MUST)
|
|
130
132
|
|
|
131
|
-
- **Progress.**
|
|
133
|
+
- **Progress.** One terse line per phase transition (e.g.
|
|
132
134
|
`Story #<id>: implementing → closing`).
|
|
133
|
-
- **Blocked.** When you
|
|
135
|
+
- **Blocked.** When you cannot proceed, transition the Story to
|
|
134
136
|
`agent::blocked`, post a `friction` comment naming the decision needed
|
|
135
137
|
(or the unmet criteria and their evidence), and **exit non-zero**.
|
|
136
|
-
**Never fall silent** — a stalled child
|
|
137
|
-
|
|
138
|
+
**Never fall silent** — a stalled child with no label and no commit is
|
|
139
|
+
indistinguishable from a dead one.
|
|
138
140
|
|
|
139
|
-
## Land or block — the only sanctioned landing (
|
|
141
|
+
## Land or block — the only sanctioned landing (MUST)
|
|
140
142
|
|
|
141
|
-
The
|
|
142
|
-
`remoteVerified` is `false`,
|
|
143
|
-
|
|
143
|
+
The init envelope carries `remoteVerified` + `remoteProbe`. When
|
|
144
|
+
`remoteVerified` is `false`, flip to `agent::blocked` quoting
|
|
145
|
+
`remoteProbe.detail` and stop. A PR opened by
|
|
144
146
|
`single-story-close.js` is the only sanctioned landing.
|
|
145
147
|
|
|
146
148
|
## Your turn ends at a pushed branch (MUST)
|
|
@@ -148,14 +150,12 @@ quoting `remoteProbe.detail` and stop. A PR opened by
|
|
|
148
150
|
You do **not** run close. Push `story-<storyId>` to `origin` — confirming
|
|
149
151
|
the remote ref moved — and return. The dispatching orchestrator runs
|
|
150
152
|
`single-story-close.js` in its own session, serialized against your
|
|
151
|
-
siblings. Do not open the PR,
|
|
152
|
-
|
|
153
|
-
path above rather than returning a hand-off you cannot back.
|
|
153
|
+
siblings. Do not open the PR, flip `agent::done`, or spawn a child to close
|
|
154
|
+
on your behalf. If the push fails, take the blocked path above.
|
|
154
155
|
|
|
155
156
|
## Return contract — the hand-off report
|
|
156
157
|
|
|
157
158
|
A short, literal hand-off your caller can act on: Story id, `workCwd`,
|
|
158
159
|
branch, pushed head SHA, self-eval verdict, `verify[]` evidence. Say plainly
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
unlanded Story look landed.
|
|
160
|
+
the branch is pushed and unclosed. Never hand-compose a terminal envelope —
|
|
161
|
+
inventing one makes an unlanded Story look landed.
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* - the **creator** — `audit-labels-bootstrap.js`, which ran `gh label create`
|
|
9
9
|
* over the `audit::<lens>` list and nothing else;
|
|
10
10
|
* - the **generator** — `audit-to-stories/build-story-body.js`, which emitted
|
|
11
|
-
* `type::story`, `
|
|
12
|
-
*
|
|
11
|
+
* `type::story`, `audit::<lens>` and — as a bare string literal —
|
|
12
|
+
* `risk::high`.
|
|
13
13
|
*
|
|
14
14
|
* `risk::high` was therefore generated by the filer and defined by **no**
|
|
15
15
|
* taxonomy: not `LABEL_TAXONOMY` (the repo-wide bootstrap), not the audit
|
|
@@ -136,6 +136,14 @@ const AUDIT_STORY_AXIS_LABELS = Object.freeze([
|
|
|
136
136
|
color: hex(LABEL_COLORS.TYPE),
|
|
137
137
|
description: 'Story work item',
|
|
138
138
|
},
|
|
139
|
+
// Created, never generated — the one deliberate asymmetry in this module.
|
|
140
|
+
// The filer emits no `agent::` state (see `STATIC_LABELS` in
|
|
141
|
+
// build-story-body.js): a freshly-filed audit Story is not delivery-ready,
|
|
142
|
+
// and `/mandrel-plan` stamps this label as its terminal flip after
|
|
143
|
+
// enrichment. The bootstrap still creates it so that flip has a label to
|
|
144
|
+
// apply on a repo where only the audit bootstrap has been run. The drift
|
|
145
|
+
// this module guards runs the other way — a generator inventing names the
|
|
146
|
+
// creator never made — so a creator-only entry is safe (Story #5229).
|
|
139
147
|
{
|
|
140
148
|
name: AGENT_LABELS.READY,
|
|
141
149
|
color: hex(LABEL_COLORS.AGENT),
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* Pure: returns { title, body, labels }. Labels carry one canonical
|
|
11
11
|
* `audit::<lens>` per distinct source report represented in the merge
|
|
12
12
|
* (derived from each finding's `sourceReport` basename, NEVER from the
|
|
13
|
-
* fine-grained `dimension` text — see Story #4195), plus
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* fine-grained `dimension` text — see Story #4195), plus `type::story` and
|
|
14
|
+
* (when any finding is Critical) `risk::high`. Deliberately **no `agent::`
|
|
15
|
+
* state label** — see {@link STATIC_LABELS}.
|
|
16
16
|
*
|
|
17
17
|
* The body is serialized via the canonical story-body serializer
|
|
18
18
|
* (`.agents/scripts/lib/story-body/story-body.js`) so the output round-trips
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import path from 'node:path';
|
|
26
|
-
import {
|
|
26
|
+
import { RISK_LABELS, TYPE_LABELS } from '../label-constants.js';
|
|
27
27
|
import { serialize } from '../story-body/story-body.js';
|
|
28
28
|
import { definesAuditLabel } from './audit-label-taxonomy.js';
|
|
29
29
|
import { auditLabelsForFindings } from './audit-lenses.js';
|
|
@@ -32,7 +32,26 @@ import {
|
|
|
32
32
|
renderSemanticKeyFooter,
|
|
33
33
|
} from './finding-adapter.js';
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
// The story-axis labels every generated audit Story carries, and the one it
|
|
36
|
+
// deliberately does NOT: an `agent::` state.
|
|
37
|
+
//
|
|
38
|
+
// This used to be `[TYPE_LABELS.STORY, AGENT_LABELS.READY]`, which put every
|
|
39
|
+
// Story the sweep filed one step from execution. `agent::ready` is precisely
|
|
40
|
+
// what `/mandrel-deliver` reads as "available for pickup", and the bodies
|
|
41
|
+
// reaching this function are audit prose — a synthesized acceptance line per
|
|
42
|
+
// finding and an Agent Prompts section that is empty whenever the lens
|
|
43
|
+
// captured no `agentPrompt`. Filing them ready contradicted the sweep's own
|
|
44
|
+
// shipped runbook, whose enrich-before-you-deliver step says not to point
|
|
45
|
+
// `/mandrel-deliver` at a freshly-filed audit Story; the label said the
|
|
46
|
+
// opposite of the prose, and the label is the half a machine reads.
|
|
47
|
+
//
|
|
48
|
+
// The state axis therefore belongs to whoever enriches the finding:
|
|
49
|
+
// `/mandrel-plan` stamps `agent::ready` as its terminal flip once the Story
|
|
50
|
+
// has a `## Spec`, real acceptance criteria and runnable verify lines. An
|
|
51
|
+
// absent `agent::` label is a legal initial state — `isValidTransition`
|
|
52
|
+
// treats a null from-state as the initial-entry edge and permits any target —
|
|
53
|
+
// so nothing downstream has to special-case the gap (Story #5229).
|
|
54
|
+
const STATIC_LABELS = Object.freeze([TYPE_LABELS.STORY]);
|
|
36
55
|
|
|
37
56
|
// The verify[] contract every generated audit Story carries. These commands
|
|
38
57
|
// exist in this repo's harness (package.json scripts) so the Story satisfies
|
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
* `temp/standalone/stories/story-<id>/validation-evidence.json`, so a
|
|
18
18
|
* second close at unchanged HEAD short-circuits the already-passed gates.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
20
|
+
* Pre-gate self-heal (Stories #4250, #5224). Two best-effort steps run on the
|
|
21
|
+
* Story branch before the check-only gates score it — the scoped format
|
|
22
|
+
* autofix and the upward maintainability write-back — so whatever they commit
|
|
23
|
+
* is part of what the gates see and part of the branch's own PR. Both live in
|
|
24
|
+
* [`pre-gate-steps.js`](pre-gate-steps.js); neither can fail a close, because
|
|
25
|
+
* an authoritative gate for each runs immediately below.
|
|
26
26
|
*
|
|
27
27
|
* Bounded gate output (Story #4736). Every gate line goes to the run's
|
|
28
28
|
* `gate-log.js` sink — an artifact under the gitignored temp tree — instead
|
|
@@ -40,25 +40,24 @@
|
|
|
40
40
|
* `*:update` + `baseline-refresh:` remedy — advisory only, so the close
|
|
41
41
|
* verdict is unchanged.
|
|
42
42
|
*
|
|
43
|
-
* `runCloseValidation`, `buildDefaultGates
|
|
44
|
-
* are accepted as injected dependencies so
|
|
45
|
-
* bindings win in tests that mock the upstream
|
|
43
|
+
* `runCloseValidation`, `buildDefaultGates` and the pre-gate steps (whole, or
|
|
44
|
+
* their two individual collaborators) are accepted as injected dependencies so
|
|
45
|
+
* the parent CLI's cache-busted bindings win in tests that mock the upstream
|
|
46
|
+
* module URLs.
|
|
46
47
|
*/
|
|
47
48
|
|
|
48
49
|
import { buildDefaultGates as defaultBuildDefaultGates } from '../../../close-validation/gates.js';
|
|
49
50
|
import { runCloseValidation as defaultRunCloseValidation } from '../../../close-validation/runner.js';
|
|
50
|
-
import { Logger } from '../../../Logger.js';
|
|
51
|
-
import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../story-close/format-autofix.js';
|
|
52
51
|
import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
|
|
52
|
+
import { runPreGateSteps as defaultRunPreGateSteps } from './pre-gate-steps.js';
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
55
|
* Run the close-validation gate chain. Throws on first gate failure.
|
|
56
56
|
*
|
|
57
|
-
* Order
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* with a log line rather than failing.
|
|
57
|
+
* Order: pre-gate self-heal steps → close-validation gates. The steps scope
|
|
58
|
+
* to the `baseBranch...storyBranch` diff, commit inside the Story worktree,
|
|
59
|
+
* and are best-effort — a missing `storyBranch` (resume/legacy callers) skips
|
|
60
|
+
* them with a log line rather than failing.
|
|
62
61
|
*
|
|
63
62
|
* Gates are built from the canonical resolved config (`buildDefaultGates`
|
|
64
63
|
* reads `project.commands` and `delivery.quality.gates.crap.enabled`); the
|
|
@@ -76,7 +75,9 @@ import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
|
|
|
76
75
|
* progress: (tag: string, msg: string) => void,
|
|
77
76
|
* runCloseValidation?: typeof defaultRunCloseValidation,
|
|
78
77
|
* buildDefaultGates?: typeof defaultBuildDefaultGates,
|
|
79
|
-
*
|
|
78
|
+
* runPreGateSteps?: typeof defaultRunPreGateSteps,
|
|
79
|
+
* runScopedFormatAutofix?: Function,
|
|
80
|
+
* runBaselineUpwardWriteback?: Function,
|
|
80
81
|
* createGateLogSink?: typeof defaultCreateGateLogSink,
|
|
81
82
|
* }} args
|
|
82
83
|
* @returns {Promise<{ gates: Record<string, 'passed'|'skipped'> }>} Per-gate
|
|
@@ -94,52 +95,22 @@ export async function runCloseValidationPhase({
|
|
|
94
95
|
progress,
|
|
95
96
|
runCloseValidation = defaultRunCloseValidation,
|
|
96
97
|
buildDefaultGates = defaultBuildDefaultGates,
|
|
97
|
-
|
|
98
|
+
runPreGateSteps = defaultRunPreGateSteps,
|
|
99
|
+
runScopedFormatAutofix,
|
|
100
|
+
runBaselineUpwardWriteback,
|
|
98
101
|
createGateLogSink = defaultCreateGateLogSink,
|
|
99
102
|
}) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// missing ref) must never abort close — the format check gate downstream
|
|
112
|
-
// is the source of truth for "is the tree formatted". We log and proceed.
|
|
113
|
-
try {
|
|
114
|
-
const autofix = runScopedFormatAutofix({
|
|
115
|
-
cwd,
|
|
116
|
-
worktreePath,
|
|
117
|
-
storyId,
|
|
118
|
-
baseBranch,
|
|
119
|
-
storyBranch,
|
|
120
|
-
config,
|
|
121
|
-
logger: Logger,
|
|
122
|
-
});
|
|
123
|
-
if (autofix?.committed) {
|
|
124
|
-
progress(
|
|
125
|
-
'FORMAT',
|
|
126
|
-
`✅ Auto-applied format fix committed as ${autofix.sha} on ${storyBranch}.`,
|
|
127
|
-
);
|
|
128
|
-
} else {
|
|
129
|
-
progress(
|
|
130
|
-
'FORMAT',
|
|
131
|
-
`⏭ No format-autofix commit (${autofix?.reason ?? 'clean'}).`,
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
} catch (err) {
|
|
135
|
-
progress(
|
|
136
|
-
'FORMAT',
|
|
137
|
-
`⚠️ scoped format-autofix failed (close continues; format gate is authoritative): ${err?.message ?? err}`,
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
} else {
|
|
141
|
-
progress('FORMAT', '⏭ Skipped scoped format-autofix (no story branch).');
|
|
142
|
-
}
|
|
103
|
+
await runPreGateSteps({
|
|
104
|
+
cwd,
|
|
105
|
+
worktreePath,
|
|
106
|
+
storyId,
|
|
107
|
+
baseBranch,
|
|
108
|
+
storyBranch,
|
|
109
|
+
config,
|
|
110
|
+
progress,
|
|
111
|
+
runScopedFormatAutofix,
|
|
112
|
+
runBaselineUpwardWriteback,
|
|
113
|
+
});
|
|
143
114
|
|
|
144
115
|
progress(
|
|
145
116
|
'VALIDATE',
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* phases/pre-gate-steps.js — the self-heal steps the standalone close runs on
|
|
3
|
+
* the Story branch *before* the check-only gate chain scores it.
|
|
4
|
+
*
|
|
5
|
+
* Two steps live here, and they share one contract that is the reason they
|
|
6
|
+
* share a module: each may author a commit on `story-<id>`, each must run
|
|
7
|
+
* ahead of the gates so its commit is part of what the gates score and part of
|
|
8
|
+
* the branch's own PR, and **neither may ever fail the close**. Downstream
|
|
9
|
+
* there is always an authoritative gate — `biome ci` for formatting,
|
|
10
|
+
* `check-baselines` for the ratchet — so a failure here is a missed
|
|
11
|
+
* opportunity to self-heal, never a verdict.
|
|
12
|
+
*
|
|
13
|
+
* 1. **Scoped format-autofix** (Story #4250) — run the formatter over the
|
|
14
|
+
* `baseBranch...storyBranch` diff and fold any rewrite into a
|
|
15
|
+
* `fix(story-close):` commit, so benign JSON/YAML drift that lint-staged
|
|
16
|
+
* does not glob never reaches the check-only format gate.
|
|
17
|
+
* 2. **Upward baseline write-back** (Story #5224) — persist the
|
|
18
|
+
* maintainability rows this branch improved on files it touched, as a
|
|
19
|
+
* `baseline-refresh:` commit, so the committed baseline stops falling
|
|
20
|
+
* behind the tree in the upward direction.
|
|
21
|
+
*
|
|
22
|
+
* Extracted from `close-validation.js` when the second step landed. The phase
|
|
23
|
+
* module's job is the gate chain, the evidence keyspace and the gate-log sink;
|
|
24
|
+
* carrying two multi-branch best-effort wrappers inline alongside that was the
|
|
25
|
+
* mass that made it the file it was. Both collaborators stay injectable — the
|
|
26
|
+
* parent CLI's cache-busted bindings must win in tests that mock the upstream
|
|
27
|
+
* module URLs — and are threaded through from the phase unchanged.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { Logger } from '../../../Logger.js';
|
|
31
|
+
import { runBaselineUpwardWriteback as defaultRunBaselineUpwardWriteback } from '../../story-close/baseline-upward-writeback.js';
|
|
32
|
+
import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../story-close/format-autofix.js';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run the scoped formatter self-heal.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} ctx the shared step context (see {@link runPreGateSteps})
|
|
38
|
+
* @returns {void}
|
|
39
|
+
*/
|
|
40
|
+
function formatAutofixStep({
|
|
41
|
+
cwd,
|
|
42
|
+
worktreePath,
|
|
43
|
+
storyId,
|
|
44
|
+
baseBranch,
|
|
45
|
+
storyBranch,
|
|
46
|
+
config,
|
|
47
|
+
progress,
|
|
48
|
+
runScopedFormatAutofix,
|
|
49
|
+
}) {
|
|
50
|
+
progress(
|
|
51
|
+
'FORMAT',
|
|
52
|
+
`Running scoped format-autofix on ${baseBranch}...${storyBranch}${worktreePath ? ` in ${worktreePath}` : ''}...`,
|
|
53
|
+
);
|
|
54
|
+
const autofix = runScopedFormatAutofix({
|
|
55
|
+
cwd,
|
|
56
|
+
worktreePath,
|
|
57
|
+
storyId,
|
|
58
|
+
baseBranch,
|
|
59
|
+
storyBranch,
|
|
60
|
+
config,
|
|
61
|
+
logger: Logger,
|
|
62
|
+
});
|
|
63
|
+
progress(
|
|
64
|
+
'FORMAT',
|
|
65
|
+
autofix?.committed
|
|
66
|
+
? `✅ Auto-applied format fix committed as ${autofix.sha} on ${storyBranch}.`
|
|
67
|
+
: `⏭ No format-autofix commit (${autofix?.reason ?? 'clean'}).`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Run the upward maintainability write-back.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} ctx the shared step context (see {@link runPreGateSteps})
|
|
75
|
+
* @returns {Promise<void>}
|
|
76
|
+
*/
|
|
77
|
+
async function baselineWritebackStep({
|
|
78
|
+
cwd,
|
|
79
|
+
worktreePath,
|
|
80
|
+
storyId,
|
|
81
|
+
baseBranch,
|
|
82
|
+
storyBranch,
|
|
83
|
+
config,
|
|
84
|
+
progress,
|
|
85
|
+
runBaselineUpwardWriteback,
|
|
86
|
+
}) {
|
|
87
|
+
const writeback = await runBaselineUpwardWriteback({
|
|
88
|
+
cwd,
|
|
89
|
+
worktreePath,
|
|
90
|
+
storyId,
|
|
91
|
+
baseBranch,
|
|
92
|
+
storyBranch,
|
|
93
|
+
config,
|
|
94
|
+
logger: Logger,
|
|
95
|
+
});
|
|
96
|
+
progress(
|
|
97
|
+
'BASELINE',
|
|
98
|
+
writeback?.committed
|
|
99
|
+
? `✅ Wrote back ${writeback.improvedPaths?.length ?? 0} improved maintainability row(s) as ${writeback.sha} on ${storyBranch}.`
|
|
100
|
+
: `⏭ No baseline write-back (${writeback?.reason ?? 'nothing to write'}).`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Run one step, absorbing any throw into a progress line.
|
|
106
|
+
*
|
|
107
|
+
* The absorption is the point, not laziness about error handling: every step
|
|
108
|
+
* here is the *refresh* half of a loop whose *enforcement* half runs
|
|
109
|
+
* immediately afterwards. A step that could abort the close would convert a
|
|
110
|
+
* self-heal opportunity into an outage, and would do it on the path with the
|
|
111
|
+
* least operator attention.
|
|
112
|
+
*
|
|
113
|
+
* @param {{ tag: string, label: string, progress: Function, run: () => Promise<void>|void }} opts
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
*/
|
|
116
|
+
async function bestEffort({ tag, label, progress, run }) {
|
|
117
|
+
try {
|
|
118
|
+
await run();
|
|
119
|
+
} catch (err) {
|
|
120
|
+
progress(
|
|
121
|
+
tag,
|
|
122
|
+
`⚠️ ${label} failed (close continues; the gate chain is authoritative): ${err?.message ?? err}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Run every pre-gate self-heal step for a standalone Story close.
|
|
129
|
+
*
|
|
130
|
+
* Both steps commit to `story-<id>`, so both are skipped — with a log line, not
|
|
131
|
+
* a throw — when the caller has no `storyBranch`. That is the resume/legacy
|
|
132
|
+
* path, which has no branch to commit onto and must not trip an exception for
|
|
133
|
+
* saying so.
|
|
134
|
+
*
|
|
135
|
+
* @param {{
|
|
136
|
+
* cwd: string,
|
|
137
|
+
* worktreePath: string|null,
|
|
138
|
+
* storyId: number,
|
|
139
|
+
* baseBranch: string,
|
|
140
|
+
* storyBranch?: string,
|
|
141
|
+
* config: object,
|
|
142
|
+
* progress: (tag: string, msg: string) => void,
|
|
143
|
+
* runScopedFormatAutofix?: typeof defaultRunScopedFormatAutofix,
|
|
144
|
+
* runBaselineUpwardWriteback?: typeof defaultRunBaselineUpwardWriteback,
|
|
145
|
+
* }} args
|
|
146
|
+
* @returns {Promise<void>}
|
|
147
|
+
*/
|
|
148
|
+
export async function runPreGateSteps({
|
|
149
|
+
runScopedFormatAutofix = defaultRunScopedFormatAutofix,
|
|
150
|
+
runBaselineUpwardWriteback = defaultRunBaselineUpwardWriteback,
|
|
151
|
+
...ctx
|
|
152
|
+
}) {
|
|
153
|
+
const { storyBranch, progress } = ctx;
|
|
154
|
+
if (!storyBranch) {
|
|
155
|
+
progress('FORMAT', '⏭ Skipped scoped format-autofix (no story branch).');
|
|
156
|
+
progress('BASELINE', '⏭ Skipped baseline write-back (no story branch).');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
await bestEffort({
|
|
160
|
+
tag: 'FORMAT',
|
|
161
|
+
label: 'scoped format-autofix',
|
|
162
|
+
progress,
|
|
163
|
+
run: () => formatAutofixStep({ ...ctx, runScopedFormatAutofix }),
|
|
164
|
+
});
|
|
165
|
+
await bestEffort({
|
|
166
|
+
tag: 'BASELINE',
|
|
167
|
+
label: 'baseline write-back',
|
|
168
|
+
progress,
|
|
169
|
+
run: () => baselineWritebackStep({ ...ctx, runBaselineUpwardWriteback }),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* baseline-upward-writeback.js — persist improved maintainability rows on the
|
|
3
|
+
* branch that earned them (Story #5224).
|
|
4
|
+
*
|
|
5
|
+
* The diff-scoped baseline ratchet only ever reds on a REGRESSION. A branch
|
|
6
|
+
* that *improves* a file it touched is therefore waved through with its
|
|
7
|
+
* committed row left describing the worse, older tree — and nothing on the
|
|
8
|
+
* per-PR path ever writes it back. The only thing that notices is the nightly
|
|
9
|
+
* full-scope re-score (`check-baseline-drift.js`), which had filed the same
|
|
10
|
+
* one-command chore seven times before this module existed.
|
|
11
|
+
*
|
|
12
|
+
* The classifier already does the hard half: `kinds/kind-factory.js#classify`
|
|
13
|
+
* partitions every compared row into `regressions` / `improvements` /
|
|
14
|
+
* `unchanged` / `additions`, and the enforcement path forwards `improvements`
|
|
15
|
+
* all the way to the report. Nothing persisted it. This module is that
|
|
16
|
+
* missing half — run from the close's `close-validation` phase, ahead of the
|
|
17
|
+
* gate chain, so the refreshed row lands in the branch's own PR.
|
|
18
|
+
*
|
|
19
|
+
* Shape borrowed from {@link ../story-close/format-autofix.js#runScopedFormatAutofix}:
|
|
20
|
+
* scope to the branch's changed-file set, fold the writes into one dedicated
|
|
21
|
+
* commit ahead of the gates, log the paths touched, and inject every
|
|
22
|
+
* git / baseline / scoring collaborator so the unit tests never spawn git
|
|
23
|
+
* (`.agents/rules/test-seams.md`).
|
|
24
|
+
*
|
|
25
|
+
* Four constraints bind the design, and every one of them is a "must not":
|
|
26
|
+
*
|
|
27
|
+
* 1. **Only `improvements` are written.** A regression must still fail the
|
|
28
|
+
* gate exactly as it does today. A write-back that could launder one
|
|
29
|
+
* makes the baseline actively worse than leaving it stale, so a regressed
|
|
30
|
+
* row is never in the written set — it is not filtered out downstream, it
|
|
31
|
+
* never enters.
|
|
32
|
+
* 2. **`maintainability` only.** CRAP's drift identity is
|
|
33
|
+
* `path::method@startLine`, which re-keys whenever anything above a
|
|
34
|
+
* method moves, so the same treatment there is churn rather than signal.
|
|
35
|
+
* That is exactly why the nightly watches maintainability alone.
|
|
36
|
+
* 3. **Changed files only — never a full-scope regeneration.** A full-scope
|
|
37
|
+
* write at land time would absorb unrelated drift from other branches
|
|
38
|
+
* into whichever PR happened to land next, and would fight the
|
|
39
|
+
* row-identity merge driver that exists to keep concurrent refreshes
|
|
40
|
+
* apart (Story #5215).
|
|
41
|
+
* 4. **Idempotent and silent.** No empty commit; a second close over the
|
|
42
|
+
* same tree finds nothing left to improve and commits nothing.
|
|
43
|
+
*
|
|
44
|
+
* The authored commit carries the `baseline-refresh:` marker
|
|
45
|
+
* `phases/refresh-ack.js` recognises, so the refreshed rows are vouched for
|
|
46
|
+
* rather than read as fresh drift, and its subject is conventional so
|
|
47
|
+
* commitlint accepts it.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import path from 'node:path';
|
|
51
|
+
|
|
52
|
+
import {
|
|
53
|
+
compare as compareMaintainability,
|
|
54
|
+
projectRow as projectMaintainabilityRow,
|
|
55
|
+
} from '../../baselines/kinds/maintainability.js';
|
|
56
|
+
import {
|
|
57
|
+
_internals as baselineReaderInternals,
|
|
58
|
+
load as loadBaselineEnvelope,
|
|
59
|
+
} from '../../baselines/reader.js';
|
|
60
|
+
import {
|
|
61
|
+
refreshBaseline as defaultRefreshBaseline,
|
|
62
|
+
resolveDefaultScorer,
|
|
63
|
+
} from '../../baselines/refresh-service.js';
|
|
64
|
+
import { getQuality } from '../../config-resolver.js';
|
|
65
|
+
import { gitSync as defaultGitSync } from '../../git-utils.js';
|
|
66
|
+
import { Logger as DefaultLogger } from '../../Logger.js';
|
|
67
|
+
import { currentBranch, listChangedFiles } from './format-autofix.js';
|
|
68
|
+
|
|
69
|
+
const TAG = '[baseline-writeback]';
|
|
70
|
+
|
|
71
|
+
/** The one kind this module touches. See constraint 2 in the preamble. */
|
|
72
|
+
const KIND = 'maintainability';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Absolute drift tolerance when the gate configures none. Mirrors
|
|
76
|
+
* `drift-detector.js`'s `KIND_SPECS.maintainability.defaultTolerance`, so the
|
|
77
|
+
* per-PR write-back and the nightly full-scope check agree on what counts as
|
|
78
|
+
* movement rather than float noise.
|
|
79
|
+
*/
|
|
80
|
+
const DEFAULT_TOLERANCE = 0.5;
|
|
81
|
+
|
|
82
|
+
/** Files the maintainability scorer can measure at all. */
|
|
83
|
+
const SCORABLE = /\.(?:m?[jt]sx?)$/i;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Reasons reported before the step scored anything, so `ran: false` means
|
|
87
|
+
* exactly "a guard stopped this before any work happened" rather than the
|
|
88
|
+
* softer "nothing came of it". `no-scored-rows`, `no-improvements` and
|
|
89
|
+
* `unchanged` are deliberately absent: those are outcomes of a run.
|
|
90
|
+
*/
|
|
91
|
+
const GUARD_REASONS = new Set([
|
|
92
|
+
'gate-disabled',
|
|
93
|
+
'no-changed-files',
|
|
94
|
+
'wrong-branch',
|
|
95
|
+
'no-baseline',
|
|
96
|
+
'no-scorer',
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the gate's absolute tolerance. Anything below it is float noise the
|
|
101
|
+
* ratchet already refuses to red on, so writing it back would be churn — and
|
|
102
|
+
* churn on a file every concurrent branch also touches is the one cost this
|
|
103
|
+
* step must not add.
|
|
104
|
+
*
|
|
105
|
+
* @param {object|undefined} gate resolved `delivery.quality.gates.maintainability`
|
|
106
|
+
* @returns {number}
|
|
107
|
+
*/
|
|
108
|
+
function resolveTolerance(gate) {
|
|
109
|
+
const configured = gate?.tolerance;
|
|
110
|
+
if (configured?.kind === 'absolute') {
|
|
111
|
+
const value = Number(configured.value);
|
|
112
|
+
if (Number.isFinite(value)) return Math.abs(value);
|
|
113
|
+
}
|
|
114
|
+
return DEFAULT_TOLERANCE;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Read the maintainability gate block as DECLARED — `quality.gates[kind]`, not
|
|
119
|
+
* the sibling `quality[kind]` projection. The two differ in exactly the two
|
|
120
|
+
* fields this module reads: the projection drops `enabled` entirely and
|
|
121
|
+
* flattens `tolerance` to a bare number, so reading it would silently make the
|
|
122
|
+
* gate un-disablable and every configured tolerance unreadable. `evaluate.js`
|
|
123
|
+
* receives this same declared block as its `gateBlock`, which is what keeps
|
|
124
|
+
* the write-back's notion of "moved" identical to the gate's.
|
|
125
|
+
*
|
|
126
|
+
* Tolerates a resolver that throws (a malformed config under a tmp cwd). An
|
|
127
|
+
* unresolvable config reads as "framework defaults", which enable the gate —
|
|
128
|
+
* the same reading `projections/advisories.js#isEnabled` applies.
|
|
129
|
+
*
|
|
130
|
+
* @param {object|undefined} config
|
|
131
|
+
* @returns {object|undefined}
|
|
132
|
+
*/
|
|
133
|
+
function resolveGate(config) {
|
|
134
|
+
try {
|
|
135
|
+
return getQuality(config)?.gates?.[KIND];
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Normalise scorer output into the on-disk row shape so scored rows and
|
|
143
|
+
* committed rows are directly comparable. `projectRow` is the same projection
|
|
144
|
+
* the writer applies, which is what makes the two sides comparable at all.
|
|
145
|
+
* A row the writer itself would refuse is dropped rather than compared.
|
|
146
|
+
*
|
|
147
|
+
* @param {Array<object>} rows
|
|
148
|
+
* @returns {Array<{ path: string, mi: number }>}
|
|
149
|
+
*/
|
|
150
|
+
function projectRows(rows) {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const row of rows ?? []) {
|
|
153
|
+
try {
|
|
154
|
+
const projected = projectMaintainabilityRow(row);
|
|
155
|
+
if (Number.isFinite(projected.mi)) out.push(projected);
|
|
156
|
+
} catch {
|
|
157
|
+
// Unprojectable row → not evidence of an improvement.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Select the rows this branch has genuinely improved.
|
|
165
|
+
*
|
|
166
|
+
* The base side is deliberately narrowed to the rows the head side actually
|
|
167
|
+
* scored. `compare()` classifies a base row with no head row through the
|
|
168
|
+
* kind's `removedRowPolicy`, which for maintainability pushes an
|
|
169
|
+
* **improvement** ("the file is gone, so its debt is gone too"). That policy
|
|
170
|
+
* is correct for a full-scope compare and catastrophic for a scoped one: every
|
|
171
|
+
* untouched file in the repo would arrive here as an improvement and be
|
|
172
|
+
* rewritten from a score nobody computed. Narrowing the base to the scored
|
|
173
|
+
* keys means every comparison has both sides, so the removed-row arm cannot
|
|
174
|
+
* fire at all — and the `head === null` guard below makes that structural
|
|
175
|
+
* rather than incidental.
|
|
176
|
+
*
|
|
177
|
+
* `additions` — a scored file with no committed row — is likewise excluded:
|
|
178
|
+
* constraint 1 admits only what the classifier calls an improvement, and a new
|
|
179
|
+
* file's row is the ordinary refresh path's business, not this step's.
|
|
180
|
+
*
|
|
181
|
+
* Pure.
|
|
182
|
+
*
|
|
183
|
+
* @param {{ scoredRows: Array<object>, baselineRows: Array<object>, tolerance: number }} opts
|
|
184
|
+
* @returns {Array<{ path: string, mi: number }>} the head rows to persist
|
|
185
|
+
*/
|
|
186
|
+
function selectImprovedRows({ scoredRows, baselineRows, tolerance }) {
|
|
187
|
+
const scoredPaths = new Set(scoredRows.map((row) => row.path));
|
|
188
|
+
const baseSubset = (baselineRows ?? []).filter((row) =>
|
|
189
|
+
scoredPaths.has(row?.path),
|
|
190
|
+
);
|
|
191
|
+
if (baseSubset.length === 0) return [];
|
|
192
|
+
|
|
193
|
+
const result = compareMaintainability(
|
|
194
|
+
{ rows: scoredRows },
|
|
195
|
+
{ rows: baseSubset },
|
|
196
|
+
);
|
|
197
|
+
const improved = [];
|
|
198
|
+
for (const entry of result?.improvements ?? []) {
|
|
199
|
+
const head = entry?.head;
|
|
200
|
+
const base = entry?.base;
|
|
201
|
+
if (!head || !base) continue;
|
|
202
|
+
if (head.mi - base.mi <= tolerance) continue;
|
|
203
|
+
improved.push(head);
|
|
204
|
+
}
|
|
205
|
+
return improved;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Render the commit body: one line per rewritten row, before → after. The body
|
|
210
|
+
* is the durable record of what the step touched — the `Logger` line scrolls
|
|
211
|
+
* out of a close transcript, this does not — and `check-baseline-drift.js`'s
|
|
212
|
+
* remedy text asks a `baseline-refresh:` commit to carry a non-empty body.
|
|
213
|
+
*
|
|
214
|
+
* @param {Array<{ path: string, mi: number }>} improved
|
|
215
|
+
* @param {Array<object>} baselineRows
|
|
216
|
+
* @returns {string}
|
|
217
|
+
*/
|
|
218
|
+
function buildCommitBody(improved, baselineRows) {
|
|
219
|
+
const priorByPath = new Map(
|
|
220
|
+
(baselineRows ?? []).map((row) => [row?.path, row?.mi]),
|
|
221
|
+
);
|
|
222
|
+
const lines = [
|
|
223
|
+
'Rows the branch improved on files it touched, written back so the',
|
|
224
|
+
'committed baseline stops falling behind the tree in the upward',
|
|
225
|
+
'direction. Scoped to the branch changed set; no regression is rewritten.',
|
|
226
|
+
'',
|
|
227
|
+
];
|
|
228
|
+
for (const row of improved) {
|
|
229
|
+
const before = Number(priorByPath.get(row.path) ?? 0).toFixed(2);
|
|
230
|
+
lines.push(`- ${row.path}: ${before} -> ${row.mi.toFixed(2)}`);
|
|
231
|
+
}
|
|
232
|
+
return lines.join('\n');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Build the commit subject. Conventional (`chore(baselines): …`) so commitlint
|
|
237
|
+
* accepts it, carrying the `baseline-refresh:` marker as a plain substring so
|
|
238
|
+
* `refresh-ack.js#resolveRefreshTrigger` recognises it, and fixed-length in
|
|
239
|
+
* everything but the Story id so it cannot drift past commitlint's 100-char
|
|
240
|
+
* subject cap.
|
|
241
|
+
*
|
|
242
|
+
* @param {number|string} storyId
|
|
243
|
+
* @returns {string}
|
|
244
|
+
*/
|
|
245
|
+
function buildCommitSubject(storyId) {
|
|
246
|
+
return `chore(baselines): baseline-refresh: improved maintainability rows (story #${storyId})`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Stage the single baseline file and commit it. Hooks must run; never pass
|
|
251
|
+
* `--no-verify` (project policy).
|
|
252
|
+
*
|
|
253
|
+
* On a commit failure the written file is restored, because everything
|
|
254
|
+
* downstream — base-sync, the push, the gate chain's own reads — assumes the
|
|
255
|
+
* close left the worktree clean. A half-applied write-back that survives as an
|
|
256
|
+
* uncommitted edit would silently change what the gates score without ever
|
|
257
|
+
* reaching the PR.
|
|
258
|
+
*
|
|
259
|
+
* @param {{ cwd: string, git: Function, relPath: string, subject: string, body: string }} opts
|
|
260
|
+
* @returns {{ sha: string }}
|
|
261
|
+
*/
|
|
262
|
+
function commitBaseline({ cwd, git, relPath, subject, body }) {
|
|
263
|
+
git(['add', '--', relPath], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
264
|
+
try {
|
|
265
|
+
git(['commit', '-m', subject, '-m', body], {
|
|
266
|
+
cwd,
|
|
267
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
268
|
+
});
|
|
269
|
+
} catch (err) {
|
|
270
|
+
git(['checkout', '--', relPath], {
|
|
271
|
+
cwd,
|
|
272
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
273
|
+
});
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
const sha = git(['rev-parse', '--short', 'HEAD'], {
|
|
277
|
+
cwd,
|
|
278
|
+
encoding: 'utf8',
|
|
279
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
280
|
+
});
|
|
281
|
+
return { sha: String(sha ?? '').trim() };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Everything that must hold before the step is allowed to score anything.
|
|
286
|
+
* Returns a skip reason, or `null` to proceed.
|
|
287
|
+
*
|
|
288
|
+
* The branch assertion runs here — before the write, not before the commit —
|
|
289
|
+
* so a mis-wired `worktreePath` can never leave a modified baseline in a tree
|
|
290
|
+
* whose history we then refuse to touch.
|
|
291
|
+
*
|
|
292
|
+
* @param {{ gate: object|undefined, workTree: string, storyBranch: string, git: Function, changed: string[] }} ctx
|
|
293
|
+
* @returns {string|null}
|
|
294
|
+
*/
|
|
295
|
+
function precheck({ gate, workTree, storyBranch, git, changed }) {
|
|
296
|
+
if (gate?.enabled === false) return 'gate-disabled';
|
|
297
|
+
if (changed.length === 0) return 'no-changed-files';
|
|
298
|
+
const onBranch = currentBranch(workTree, git);
|
|
299
|
+
if (onBranch !== storyBranch) return 'wrong-branch';
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Persist improved maintainability rows for the files this branch changed, and
|
|
305
|
+
* fold them into one `baseline-refresh:` commit on the Story branch.
|
|
306
|
+
*
|
|
307
|
+
* Every no-op is reported by name rather than silently: `gate-disabled`,
|
|
308
|
+
* `no-changed-files`, `wrong-branch`, `no-baseline`, `no-scored-rows`,
|
|
309
|
+
* `no-improvements`, `unchanged`. The caller logs the reason and proceeds —
|
|
310
|
+
* this step is never allowed to fail a close, because `check-baselines` is
|
|
311
|
+
* still the gate and this is only the refresh half of the loop.
|
|
312
|
+
*
|
|
313
|
+
* @param {{
|
|
314
|
+
* cwd: string,
|
|
315
|
+
* worktreePath?: string,
|
|
316
|
+
* storyId: number|string,
|
|
317
|
+
* baseBranch: string,
|
|
318
|
+
* storyBranch: string,
|
|
319
|
+
* config?: object,
|
|
320
|
+
* logger?: object,
|
|
321
|
+
* gitSync?: (cwd: string, ...args: string[]) => string,
|
|
322
|
+
* loadBaselineRows?: (opts: { cwd: string }) => Array<object>|null,
|
|
323
|
+
* scoreFiles?: (files: string[], opts: object) => Promise<Array<object>>|Array<object>,
|
|
324
|
+
* refreshBaseline?: typeof defaultRefreshBaseline,
|
|
325
|
+
* resolveWritePath?: (opts: { cwd: string }) => string,
|
|
326
|
+
* }} opts
|
|
327
|
+
* @returns {Promise<{
|
|
328
|
+
* ran: boolean,
|
|
329
|
+
* committed: boolean,
|
|
330
|
+
* sha?: string,
|
|
331
|
+
* improvedPaths?: string[],
|
|
332
|
+
* reason?: string,
|
|
333
|
+
* }>}
|
|
334
|
+
*/
|
|
335
|
+
export async function runBaselineUpwardWriteback({
|
|
336
|
+
cwd,
|
|
337
|
+
worktreePath,
|
|
338
|
+
storyId,
|
|
339
|
+
baseBranch,
|
|
340
|
+
storyBranch,
|
|
341
|
+
config,
|
|
342
|
+
logger = DefaultLogger,
|
|
343
|
+
gitSync = defaultGitSync,
|
|
344
|
+
loadBaselineRows = defaultLoadBaselineRows,
|
|
345
|
+
scoreFiles,
|
|
346
|
+
refreshBaseline = defaultRefreshBaseline,
|
|
347
|
+
resolveWritePath = defaultResolveWritePath,
|
|
348
|
+
} = {}) {
|
|
349
|
+
if (!cwd) throw new Error('runBaselineUpwardWriteback: cwd is required');
|
|
350
|
+
if (!baseBranch)
|
|
351
|
+
throw new Error('runBaselineUpwardWriteback: baseBranch is required');
|
|
352
|
+
if (!storyBranch)
|
|
353
|
+
throw new Error('runBaselineUpwardWriteback: storyBranch is required');
|
|
354
|
+
|
|
355
|
+
const workTree = worktreePath || cwd;
|
|
356
|
+
// The two format-autofix helpers below take git as `(args, opts) => stdout`;
|
|
357
|
+
// `git-utils.gitSync` is `(cwd, ...args) => trimmed stdout` and throws on a
|
|
358
|
+
// non-zero exit. Adapt rather than reach for `node:child_process` directly —
|
|
359
|
+
// the shared surface owns the stdout ceiling, `shell: false` and error
|
|
360
|
+
// normalisation, and `tests/enforcement/child-process-imports.test.js`
|
|
361
|
+
// enforces that.
|
|
362
|
+
const git = (args, opts = {}) => gitSync(opts.cwd ?? workTree, ...args);
|
|
363
|
+
const gate = resolveGate(config);
|
|
364
|
+
|
|
365
|
+
const changed = listChangedFiles({
|
|
366
|
+
cwd: workTree,
|
|
367
|
+
baseBranch,
|
|
368
|
+
storyBranch,
|
|
369
|
+
git,
|
|
370
|
+
}).filter((file) => SCORABLE.test(file));
|
|
371
|
+
|
|
372
|
+
const blocked = precheck({ gate, workTree, storyBranch, git, changed });
|
|
373
|
+
if (blocked) return skip(logger, blocked);
|
|
374
|
+
|
|
375
|
+
const baselineRows = loadBaselineRows({ cwd: workTree });
|
|
376
|
+
if (!Array.isArray(baselineRows) || baselineRows.length === 0) {
|
|
377
|
+
return skip(logger, 'no-baseline');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const score = scoreFiles ?? resolveDefaultScorer(KIND, { cwd: workTree });
|
|
381
|
+
if (typeof score !== 'function') return skip(logger, 'no-scorer');
|
|
382
|
+
const scoredRows = projectRows(
|
|
383
|
+
await score(changed, { kind: KIND, fullScope: false, cwd: workTree }),
|
|
384
|
+
);
|
|
385
|
+
if (scoredRows.length === 0) return skip(logger, 'no-scored-rows');
|
|
386
|
+
|
|
387
|
+
const improved = selectImprovedRows({
|
|
388
|
+
scoredRows,
|
|
389
|
+
baselineRows,
|
|
390
|
+
tolerance: resolveTolerance(gate),
|
|
391
|
+
});
|
|
392
|
+
if (improved.length === 0) return skip(logger, 'no-improvements');
|
|
393
|
+
|
|
394
|
+
return await persist({
|
|
395
|
+
improved,
|
|
396
|
+
baselineRows,
|
|
397
|
+
workTree,
|
|
398
|
+
git,
|
|
399
|
+
storyId,
|
|
400
|
+
logger,
|
|
401
|
+
refreshBaseline,
|
|
402
|
+
resolveWritePath,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Write the selected rows through the one sanctioned write funnel and commit
|
|
408
|
+
* them. The already-computed rows are handed to `refreshBaseline` as its
|
|
409
|
+
* scorer so the files are scored exactly once — the service still owns path
|
|
410
|
+
* canonicalization, `mergeRows` (which preserves every out-of-scope row
|
|
411
|
+
* byte-for-byte), rollup, envelope stamping and the atomic write.
|
|
412
|
+
*
|
|
413
|
+
* @returns {Promise<{ ran: boolean, committed: boolean, sha?: string, improvedPaths?: string[], reason?: string }>}
|
|
414
|
+
*/
|
|
415
|
+
async function persist({
|
|
416
|
+
improved,
|
|
417
|
+
baselineRows,
|
|
418
|
+
workTree,
|
|
419
|
+
git,
|
|
420
|
+
storyId,
|
|
421
|
+
logger,
|
|
422
|
+
refreshBaseline,
|
|
423
|
+
resolveWritePath,
|
|
424
|
+
}) {
|
|
425
|
+
const improvedPaths = improved.map((row) => row.path);
|
|
426
|
+
const writePath = resolveWritePath({ cwd: workTree });
|
|
427
|
+
const { wrote } = await refreshBaseline({
|
|
428
|
+
kind: KIND,
|
|
429
|
+
cwd: workTree,
|
|
430
|
+
writePath,
|
|
431
|
+
scopeFiles: improvedPaths,
|
|
432
|
+
scorer: () => improved,
|
|
433
|
+
});
|
|
434
|
+
// The writer short-circuits on structural equality, so `wrote: false` means
|
|
435
|
+
// the committed rows already carried these values — nothing to commit, and
|
|
436
|
+
// nothing worth a log line above debug.
|
|
437
|
+
if (!wrote) return skip(logger, 'unchanged');
|
|
438
|
+
|
|
439
|
+
const relPath = path.relative(workTree, writePath).split(path.sep).join('/');
|
|
440
|
+
const { sha } = commitBaseline({
|
|
441
|
+
cwd: workTree,
|
|
442
|
+
git,
|
|
443
|
+
relPath,
|
|
444
|
+
subject: buildCommitSubject(storyId),
|
|
445
|
+
body: buildCommitBody(improved, baselineRows),
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
logger.warn?.(
|
|
449
|
+
`${TAG} wrote back ${improvedPaths.length} improved ${KIND} row(s) ` +
|
|
450
|
+
`on story #${storyId}: ${improvedPaths.join(', ')}; committed as ${sha}.`,
|
|
451
|
+
);
|
|
452
|
+
return { ran: true, committed: true, sha, improvedPaths };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Report a no-op by name. Every skip is `info`-level: none of them is a
|
|
457
|
+
* problem, and the close transcript already carries one line per phase.
|
|
458
|
+
*
|
|
459
|
+
* @param {object} logger
|
|
460
|
+
* @param {string} reason
|
|
461
|
+
*/
|
|
462
|
+
function skip(logger, reason) {
|
|
463
|
+
logger.info?.(`${TAG} no write-back (${reason}).`);
|
|
464
|
+
return { ran: !GUARD_REASONS.has(reason), committed: false, reason };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Default committed-row loader — the schema-validating reader, so a baseline
|
|
469
|
+
* this module would refuse to compare against is reported as `no-baseline`
|
|
470
|
+
* rather than half-read.
|
|
471
|
+
*/
|
|
472
|
+
function defaultLoadBaselineRows({ cwd }) {
|
|
473
|
+
try {
|
|
474
|
+
return loadBaselineEnvelope(KIND, { cwd })?.rows ?? null;
|
|
475
|
+
} catch {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Default on-disk location of the maintainability baseline. */
|
|
481
|
+
function defaultResolveWritePath({ cwd }) {
|
|
482
|
+
return baselineReaderInternals.resolveBaselinePath(KIND, { cwd });
|
|
483
|
+
}
|
|
@@ -333,10 +333,15 @@ export function runFormatAutofix({
|
|
|
333
333
|
* `(args: string[], opts: object) => string`. A bridge adapter wraps it into
|
|
334
334
|
* the `gitSpawn(cwd, ...args)` shape that `diffNameOnly` expects.
|
|
335
335
|
*
|
|
336
|
+
* Exported since Story #5224: the sibling `baseline-upward-writeback.js` step
|
|
337
|
+
* scopes to the same branch changed-file set, and a second copy of this
|
|
338
|
+
* `(args, opts)` → `gitSpawn` bridge is exactly the kind of near-duplicate the
|
|
339
|
+
* duplication gate exists to refuse.
|
|
340
|
+
*
|
|
336
341
|
* @param {{ cwd: string, baseBranch: string, storyBranch: string, git: Function }} opts
|
|
337
342
|
* @returns {string[]}
|
|
338
343
|
*/
|
|
339
|
-
function listChangedFiles({ cwd, baseBranch, storyBranch, git }) {
|
|
344
|
+
export function listChangedFiles({ cwd, baseBranch, storyBranch, git }) {
|
|
340
345
|
// Bridge the (args, opts) → string interface into gitSpawn(cwd, ...args).
|
|
341
346
|
const gitSpawn = (_cwd, ...args) => {
|
|
342
347
|
try {
|
|
@@ -145,10 +145,18 @@ Audit-sourced Stories carry a closed label set, so they can be filtered out of
|
|
|
145
145
|
| Label | Meaning |
|
|
146
146
|
| --- | --- |
|
|
147
147
|
| `type::story` | Every emitted Story — the sweep never opens Epics or tasks. |
|
|
148
|
-
| `agent::ready` | Filed and available for pickup. |
|
|
149
148
|
| `audit::<lens>` | One per lens represented in the group; a cross-audit merge carries several. |
|
|
150
149
|
| `risk::high` | Added when any finding in the group is Critical. |
|
|
151
150
|
|
|
151
|
+
**There is no `agent::` state label in that set, and that is the point.** The
|
|
152
|
+
sweep files a Story it has deliberately not made deliverable — Step 5 above is
|
|
153
|
+
the reason. `agent::ready` is what `/mandrel-deliver` reads as "available for
|
|
154
|
+
pickup", so a sweep that applied it would route unenriched audit prose straight
|
|
155
|
+
into delivery, which is the outcome Step 5 tells you to avoid. The label is
|
|
156
|
+
applied by `/mandrel-plan`, at the end of planning, once the finding is a
|
|
157
|
+
scoped slice. If you file these Issues by hand, apply the emitted `labels[]`
|
|
158
|
+
verbatim and do not top it up with a state.
|
|
159
|
+
|
|
152
160
|
The lens labels are a **closed taxonomy**: only the canonical `audit::<lens>`
|
|
153
161
|
names are valid, and the filer refuses to emit a label the repository has never
|
|
154
162
|
created. Create them once with `audit-labels-bootstrap.js` before the first live
|
|
@@ -211,11 +211,17 @@ shared helper's `fingerprintFooter(sha)`
|
|
|
211
211
|
Labels applied:
|
|
212
212
|
|
|
213
213
|
- `type::story`
|
|
214
|
-
- `agent::ready`
|
|
215
214
|
- `audit::<dimension>` — one per dimension represented in the merge
|
|
216
215
|
(cross-audit groups carry multiple).
|
|
217
216
|
- `risk::high` — added when any finding in the group is Critical.
|
|
218
217
|
|
|
218
|
+
**No `agent::` state label, deliberately.** The filer used to emit
|
|
219
|
+
`agent::ready`, which is exactly what `/mandrel-deliver` reads as "available
|
|
220
|
+
for pickup" — and these bodies are audit prose, not a delivery-ready Spec. A
|
|
221
|
+
Story with no `agent::` label is a legal initial state; `/mandrel-plan` stamps
|
|
222
|
+
`agent::ready` as its terminal flip once the finding has become a scoped
|
|
223
|
+
capability slice. Do not re-add it here or apply it by hand at create time.
|
|
224
|
+
|
|
219
225
|
### Phase 5c — Wire the cohort's declared ordering (**required**)
|
|
220
226
|
|
|
221
227
|
Creating the Issues is only the first pass. `groupFindings` detects `edges[]`
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: >-
|
|
3
|
-
The deliver path's one bundled framework read
|
|
4
|
-
|
|
5
|
-
the
|
|
6
|
-
|
|
7
|
-
file instead of re-reading the helper/schema set each session.
|
|
3
|
+
The deliver path's one bundled framework read: dispatch decision, engine
|
|
4
|
+
invariants, the change-set/ceremony incantation, the acceptance-eval gate,
|
|
5
|
+
the credited full-suite run, and the terminal envelope contract — the
|
|
6
|
+
engine reads one file, not the helper/schema set, each session.
|
|
8
7
|
---
|
|
9
8
|
|
|
10
9
|
# Deliver digest (read once per session)
|
|
@@ -23,9 +22,9 @@ Read `stories[].dispatchMode` from the `resolve-stories.js` envelope.
|
|
|
23
22
|
`inline` names one indivisible resource — **the router's own session** — so one
|
|
24
23
|
rule produces it:
|
|
25
24
|
|
|
26
|
-
1. **Run topology.** A run resolving **one** Story is `inline`
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
1. **Run topology.** A run resolving **one** Story is `inline` whatever its
|
|
26
|
+
shape — sub-agent isolation only matters against a *concurrent* sibling
|
|
27
|
+
racing the same checkout, and a one-Story run has none.
|
|
29
28
|
2. **Every other run is `subagent`.** A multi-Story run dispatches every Story
|
|
30
29
|
as a sub-agent however trivial its shape. Shape still sets ceremony; the
|
|
31
30
|
`route::lite` label is a human-visible hint, never the control signal.
|
|
@@ -67,8 +66,8 @@ node --input-type=module -e '
|
|
|
67
66
|
const { level, classes } = deriveChangeLevel({ changedFiles: files });
|
|
68
67
|
// resolveCeremonyForRisk({ derivedLevel, clusterIndex?, freshCriticSampleRate?,
|
|
69
68
|
// ceremonyProfile? }) -> { mode, reason, sampled, profile, verdictOwner }.
|
|
70
|
-
// derivedLevel is that level STRING
|
|
71
|
-
//
|
|
69
|
+
// derivedLevel is that level STRING — the object above matches no tier and
|
|
70
|
+
// routes to the null fail-safe: a fresh critic, silently.
|
|
72
71
|
const ceremony = resolveCeremonyForRisk({ derivedLevel: level, clusterIndex: 0 });
|
|
73
72
|
console.log(JSON.stringify({ files, level, classes, ...ceremony }));
|
|
74
73
|
'
|
|
@@ -96,11 +95,8 @@ every cluster's records into a single `criteria[]` in `acceptance[]` order, one
|
|
|
96
95
|
per acceptance item, and score that once. A gate call per cluster spends a
|
|
97
96
|
round *per cluster* and races the round ledger:
|
|
98
97
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
--story <storyId> --verdict <merged-verdict-path> \
|
|
102
|
-
--expected-criteria <acceptance[] count>
|
|
103
|
-
```
|
|
98
|
+
`node <main-repo>/.agents/scripts/acceptance-eval.js --story <storyId>
|
|
99
|
+
--verdict <merged-verdict-path> --expected-criteria <acceptance[] count>`
|
|
104
100
|
|
|
105
101
|
Pass `--expected-criteria` — **without it the coverage assertion is inert**, so
|
|
106
102
|
an unmerged cluster verdict scores a fraction of the criteria and still reports
|
|
@@ -115,20 +111,27 @@ Per-round mechanics: [`acceptance-self-eval.md`](acceptance-self-eval.md).
|
|
|
115
111
|
**After the self-eval loop's last fix commit, immediately before the push** —
|
|
116
112
|
the credit is keyed on the tree, so any later commit invalidates it. Redraft
|
|
117
113
|
rounds run scoped tests; only this final run needs credit, and a bare
|
|
118
|
-
`npm test` / `pnpm run test` deposits **none**, so close re-runs
|
|
119
|
-
|
|
120
|
-
gate:
|
|
114
|
+
`npm test` / `pnpm run test` deposits **none**, so close re-runs it. Shape it
|
|
115
|
+
by what `close-validation/gates.js` runs:
|
|
121
116
|
|
|
122
117
|
```bash
|
|
123
|
-
# CRAP gate on (default) + a `test:coverage` script — writes
|
|
124
|
-
# close `coverage-capture` gate reads:
|
|
118
|
+
# CRAP gate on (default) + a `test:coverage` script — writes close's stamp:
|
|
125
119
|
node <main-repo>/.agents/scripts/coverage-capture.js --cwd <workCwd>
|
|
126
|
-
# otherwise — the record
|
|
127
|
-
# runner exactly `npm test
|
|
120
|
+
# otherwise — the record close's `test` gate reads; <workCwd> ABSOLUTE,
|
|
121
|
+
# runner exactly `npm test` (both sides hash {cmd, args, cwd}):
|
|
128
122
|
node <main-repo>/.agents/scripts/evidence-gate.js --standalone \
|
|
129
123
|
--scope-id <storyId> --gate test --worktree <workCwd> -- npm test
|
|
130
124
|
```
|
|
131
125
|
|
|
126
|
+
Dispatch it in the **background**: it outruns the host's synchronous Bash
|
|
127
|
+
ceiling, and its completion re-invokes you. Never spawn a task to poll or
|
|
128
|
+
`sleep`-loop against it ([`parallel-tooling.md`](parallel-tooling.md)
|
|
129
|
+
Rule 2).
|
|
130
|
+
|
|
131
|
+
Read the **output**, not the exit code: capture skips — no test run, no
|
|
132
|
+
credit — when nothing changed under the CRAP `targetDirs`, so run the suite
|
|
133
|
+
yourself before handing off.
|
|
134
|
+
|
|
132
135
|
`verify[]` is scoped entries **plus** this one run: an entry that is itself a
|
|
133
136
|
full-suite command is reported credited against the same stamp, never
|
|
134
137
|
respawned.
|
|
@@ -162,7 +165,8 @@ restores live streaming.
|
|
|
162
165
|
|
|
163
166
|
## 7. When to leave this file
|
|
164
167
|
|
|
165
|
-
|
|
166
|
-
- Lease, sweep, worktree
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
Unclear state / a re-run refusal → `deliver-recover.js --story <id>`
|
|
169
|
+
(read-only). Lease, sweep, worktree scope; sequencing, epilogue, checklist
|
|
170
|
+
threading → [`deliver-story-reference.md`](deliver-story-reference.md) and
|
|
171
|
+
[`deliver-reference.md`](deliver-reference.md). CI red after the PR opens →
|
|
172
|
+
[`rules/ci-remediation.md`](../../rules/ci-remediation.md).
|
|
@@ -47,6 +47,23 @@ the full duration and blocks every other parallel opportunity.
|
|
|
47
47
|
- **Don't poll with `sleep`:** `Monitor` returns on each stdout line. Loop
|
|
48
48
|
on `until <condition>; do sleep 2; done` only when no event stream is
|
|
49
49
|
available — never as a substitute for the event channel.
|
|
50
|
+
- **A hand-rolled waiter outlives the agent that spawned it.** Prefer the
|
|
51
|
+
completion notification: it is the signal, and needs no waiter at all. Two
|
|
52
|
+
measured failure shapes, both from one delivery run whose waiters were
|
|
53
|
+
still listed running nearly eight hours after their agent had finished and
|
|
54
|
+
its worktree had been deleted:
|
|
55
|
+
- An `until` guard that inverts to permanently-false the moment the run
|
|
56
|
+
**succeeds** — `until [ -n "$(grep -l 'Test Files' $LOG)" ] && ! grep -q
|
|
57
|
+
'Test Files' $LOG` exits only while the log both has and lacks the same
|
|
58
|
+
marker.
|
|
59
|
+
- `pgrep -f <literal>` matching the waiter's **own** command line, so it
|
|
60
|
+
finds itself and concludes the work is still running. Forever.
|
|
61
|
+
|
|
62
|
+
The tell is a task file of **0 bytes** with no backing process. If you must
|
|
63
|
+
wait, hold the PID and test `kill -0 "$PID"`, or break the self-match with
|
|
64
|
+
a bracketed pattern (`pgrep -f "[m]y-script.js"`) — and always bound the
|
|
65
|
+
loop with a maximum iteration count so a wrong condition ends the wait
|
|
66
|
+
instead of the agent.
|
|
50
67
|
|
|
51
68
|
## Rule 3 — N parallel `Agent` calls in one turn for N independent units
|
|
52
69
|
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,26 @@ All notable changes to this project will be documented in this file.
|
|
|
15
15
|
-->
|
|
16
16
|
<!-- markdownlint-disable-file MD004 MD012 MD037 -->
|
|
17
17
|
|
|
18
|
+
## [2.50.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.49.0...mandrel-v2.50.0) (2026-09-08)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
* stop /audit-to-stories emitting agent::ready on generated audit Stories… ([#5229](https://github.com/dsj1984/mandrel/issues/5229)) ([#5230](https://github.com/dsj1984/mandrel/issues/5230)) ([d095608](https://github.com/dsj1984/mandrel/commit/d09560866c42084aed3fb92fb91d8c9a49fe66e3))
|
|
24
|
+
|
|
25
|
+
## [2.49.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.48.0...mandrel-v2.49.0) (2026-09-08)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
* give story-worker a reachable long-command dispatch contract so the credited suite run stops growing hand-rolled waiters ([#5219](https://github.com/dsj1984/mandrel/issues/5219)) ([#5220](https://github.com/dsj1984/mandrel/issues/5220)) ([e2bb9eb](https://github.com/dsj1984/mandrel/commit/e2bb9eba8aaaf49ecf0d8069f36fc4438edf5e40))
|
|
31
|
+
* write improved maintainability rows back at land time so upward baseline drift stops accumulating ([#5224](https://github.com/dsj1984/mandrel/issues/5224)) ([#5227](https://github.com/dsj1984/mandrel/issues/5227)) ([95d2e38](https://github.com/dsj1984/mandrel/commit/95d2e38db7fe87c410dbb40d43818e31ee80a497))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
* tell the worker what to do when the credited full-suite command legitimately skips ([#5225](https://github.com/dsj1984/mandrel/issues/5225)) ([#5226](https://github.com/dsj1984/mandrel/issues/5226)) ([330ecaf](https://github.com/dsj1984/mandrel/commit/330ecaf6001755a515c45f69a495310d8b65643a))
|
|
37
|
+
|
|
18
38
|
## [2.48.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.47.0...mandrel-v2.48.0) (2026-09-08)
|
|
19
39
|
|
|
20
40
|
|
package/package.json
CHANGED