mandrel 1.90.0 → 1.92.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/README.md +5 -5
- package/.agents/docs/SDLC.md +75 -107
- package/.agents/docs/configuration.md +1 -1
- package/.agents/docs/quality-gates.md +8 -7
- package/.agents/docs/workflows.md +4 -10
- package/.agents/instructions.md +2 -2
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/schemas/lifecycle/README.md +2 -1
- package/.agents/schemas/lifecycle/ledger-record.schema.json +1 -1
- package/.agents/schemas/risk-verdict.schema.json +14 -1
- package/.agents/scripts/README.md +0 -46
- package/.agents/scripts/analyze-execution.js +76 -1
- package/.agents/scripts/check-doc-links.js +3 -0
- package/.agents/scripts/check-workflow-cli-lint.js +208 -0
- package/.agents/scripts/epic-audit-recheck.js +1 -1
- package/.agents/scripts/epic-deliver-preflight.js +37 -1
- package/.agents/scripts/epic-plan-clarity.js +16 -6
- package/.agents/scripts/epic-plan-decompose.js +37 -59
- package/.agents/scripts/epic-plan-healthcheck.js +39 -6
- package/.agents/scripts/epic-plan-spec.js +41 -168
- package/.agents/scripts/epic-reconcile.js +7 -1
- package/.agents/scripts/lib/Logger.js +4 -4
- package/.agents/scripts/lib/audit-suite/index.js +4 -5
- package/.agents/scripts/lib/audit-suite/runner.js +6 -4
- package/.agents/scripts/lib/audit-suite/selector.js +2 -3
- package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
- package/.agents/scripts/lib/command-header.js +20 -0
- package/.agents/scripts/lib/config/github.js +1 -3
- package/.agents/scripts/lib/config-settings-schema.js +4 -3
- package/.agents/scripts/lib/degraded-mode.js +1 -1
- package/.agents/scripts/lib/label-constants.js +18 -3
- package/.agents/scripts/lib/label-taxonomy.js +14 -3
- package/.agents/scripts/lib/mutation/baseline-snapshot.js +7 -6
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +18 -8
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +3 -4
- package/.agents/scripts/lib/orchestration/docs-digest.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +4 -0
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +22 -12
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +6 -1
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +18 -2
- package/.agents/scripts/{lifecycle-diff.js → lib/orchestration/lifecycle/ledger-diff.js} +10 -76
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +1 -1
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +32 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +510 -0
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +177 -0
- package/.agents/scripts/lib/orchestration/plan-metrics.js +445 -0
- package/.agents/scripts/lib/orchestration/plan-persist/amend.js +359 -0
- package/.agents/scripts/lib/orchestration/plan-persist/delivery-mode.js +127 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +978 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +191 -0
- package/.agents/scripts/lib/orchestration/plan-reachability.js +160 -0
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +3 -3
- package/.agents/scripts/lib/orchestration/planning-risk.js +1 -1
- package/.agents/scripts/lib/orchestration/remote-verifier.js +165 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +10 -0
- package/.agents/scripts/lib/orchestration/spec-freshness.js +1 -1
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +1 -1
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +11 -2
- package/.agents/scripts/lib/plan-phase-cleanup.js +16 -0
- package/.agents/scripts/lib/presentation/manifest-persistence.js +2 -3
- package/.agents/scripts/lib/templates/spec-author-prompts.js +74 -0
- package/.agents/scripts/plan-context.js +186 -0
- package/.agents/scripts/plan-critics.js +227 -0
- package/.agents/scripts/plan-persist.js +383 -0
- package/.agents/scripts/pr-watch-with-update.js +8 -7
- package/.agents/scripts/run-lint.js +10 -11
- package/.agents/scripts/single-story-init.js +22 -0
- package/.agents/scripts/story-plan.js +19 -7
- package/.agents/scripts/sync-claude-commands.js +10 -5
- package/.agents/skills/core/knowledge-transfer/SKILL.md +11 -7
- package/.agents/workflows/audit-documentation.md +5 -7
- package/.agents/workflows/audit-lighthouse.md +1 -0
- package/.agents/workflows/audit-security.md +1 -0
- package/.agents/workflows/audit-to-stories.md +16 -5
- package/.agents/workflows/deliver.md +8 -0
- package/.agents/workflows/git-deliver.md +76 -243
- package/.agents/workflows/helpers/_merge-conflict-template.md +1 -1
- package/.agents/workflows/helpers/code-review.md +7 -6
- package/.agents/workflows/helpers/deliver-epic.md +16 -4
- package/.agents/workflows/helpers/plan-epic-reference.md +109 -96
- package/.agents/workflows/helpers/plan-epic.md +246 -999
- package/.agents/workflows/helpers/plan-story.md +14 -14
- package/.agents/workflows/helpers/scope-triage-gate.md +6 -4
- package/.agents/workflows/helpers/single-story-deliver.md +13 -4
- package/.agents/workflows/helpers/worktree-lifecycle.md +9 -7
- package/.agents/workflows/mandrel-update.md +144 -466
- package/.agents/workflows/plan.md +73 -143
- package/docs/CHANGELOG.md +20 -0
- package/lib/cli/registry.js +21 -3
- package/package.json +1 -1
- package/.agents/schemas/loop-unit.schema.json +0 -70
- package/.agents/scripts/assert-branch.js +0 -81
- package/.agents/scripts/check-loop-units.js +0 -204
- package/.agents/scripts/detect-merges.js +0 -111
- package/.agents/scripts/git-pr-quality-gate.js +0 -205
- package/.agents/scripts/git-rebase-and-resolve.js +0 -234
- package/.agents/scripts/hierarchy-gate.js +0 -192
- package/.agents/scripts/hydrate-context.js +0 -179
- package/.agents/scripts/lib/loop-units/validate-loop-unit.js +0 -197
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +0 -167
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/cli-args.js +0 -69
- package/.agents/scripts/lib/orchestration/plan-runner/plan-router.js +0 -86
- package/.agents/scripts/loc-delta.js +0 -205
- package/.agents/scripts/render-manifest.js +0 -143
- package/.agents/scripts/run-audit-suite.js +0 -97
- package/.agents/scripts/select-audits.js +0 -155
- package/.agents/scripts/update-mutation-baseline.js +0 -189
- package/.agents/workflows/explain.md +0 -118
- package/.agents/workflows/git-merge-pr.md +0 -377
- package/.agents/workflows/helpers/epic-plan-decompose.md +0 -22
- package/.agents/workflows/helpers/epic-plan-spec.md +0 -22
- package/.agents/workflows/loops/README.md +0 -65
- package/.agents/workflows/loops/fix-failing-tests.md +0 -74
- package/.agents/workflows/loops/nightly-audit.md +0 -81
- package/.agents/workflows/loops/watch-ci.md +0 -68
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* summary.js — plan-persist terminal summary (Epic #4474, PR3).
|
|
3
|
+
*
|
|
4
|
+
* Owns the single `plan-summary` structured comment the collapsed persist
|
|
5
|
+
* surface upserts at terminal success, closing with the dry-run wave table.
|
|
6
|
+
* This replaces two retired round-trips of the 12-phase pipeline:
|
|
7
|
+
*
|
|
8
|
+
* - the Phase 9 plan-time `dispatch-manifest` comment, whose claimed
|
|
9
|
+
* consumer ("Wave Completeness Gate, /deliver Step 0.5") does not exist
|
|
10
|
+
* — the live manifest is written at deliver time by `wave-record-io.js`
|
|
11
|
+
* (#4474 design §3: DROP, keep the wave table as summary text only);
|
|
12
|
+
* - the Phase 12 notify round-trip ("informational — no webhook for
|
|
13
|
+
* planning").
|
|
14
|
+
*
|
|
15
|
+
* The wave table is computed from the validated ticket set's `depends_on`
|
|
16
|
+
* slug edges via the same `computeStoryWaves` layering the deliver-time
|
|
17
|
+
* dispatch pipeline uses, so the preview matches what `/deliver` will
|
|
18
|
+
* actually fan out (barring later manual ticket edits).
|
|
19
|
+
*
|
|
20
|
+
* @module lib/orchestration/plan-persist/summary
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { computeStoryWaves } from '../dependency-analyzer.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Structured-comment type for the persist summary. Registered in
|
|
27
|
+
* `ticketing/reads.js` `STRUCTURED_COMMENT_TYPES`; upsert-idempotent so a
|
|
28
|
+
* `--force`/`--resume` re-persist replaces the prior summary in place.
|
|
29
|
+
*/
|
|
30
|
+
export const PLAN_SUMMARY_COMMENT_TYPE = 'plan-summary';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compute the dry-run wave assignment for a validated ticket set.
|
|
34
|
+
*
|
|
35
|
+
* Adapts the slug-keyed ticket shape onto `computeStoryWaves`' storyGroups
|
|
36
|
+
* contract (slug → { storyId, tasks: [] }; explicit deps from `depends_on`).
|
|
37
|
+
* Under the 2-tier hierarchy Stories carry no tasks, so all edges are
|
|
38
|
+
* explicit.
|
|
39
|
+
*
|
|
40
|
+
* @param {Array<{ slug: string, title?: string, depends_on?: string[] }>} tickets
|
|
41
|
+
* @returns {Array<{ wave: number, stories: Array<{ slug: string, title: string }> }>}
|
|
42
|
+
* Waves in execution order.
|
|
43
|
+
*/
|
|
44
|
+
export function buildWaveTable(tickets) {
|
|
45
|
+
const list = Array.isArray(tickets) ? tickets : [];
|
|
46
|
+
if (list.length === 0) return [];
|
|
47
|
+
const storyGroups = new Map();
|
|
48
|
+
const explicitDeps = new Map();
|
|
49
|
+
for (const t of list) {
|
|
50
|
+
storyGroups.set(t.slug, { storyId: t.slug, tasks: [] });
|
|
51
|
+
explicitDeps.set(
|
|
52
|
+
t.slug,
|
|
53
|
+
(t.depends_on ?? []).filter((dep) => typeof dep === 'string'),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const assignment = computeStoryWaves(storyGroups, explicitDeps);
|
|
57
|
+
const byWave = new Map();
|
|
58
|
+
for (const t of list) {
|
|
59
|
+
const wave = assignment.get(t.slug) ?? 0;
|
|
60
|
+
if (!byWave.has(wave)) byWave.set(wave, []);
|
|
61
|
+
byWave.get(wave).push({ slug: t.slug, title: t.title ?? t.slug });
|
|
62
|
+
}
|
|
63
|
+
return [...byWave.keys()]
|
|
64
|
+
.sort((a, b) => a - b)
|
|
65
|
+
.map((wave) => ({ wave, stories: byWave.get(wave) }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render the dry-run wave table as GitHub-flavoured markdown.
|
|
70
|
+
*
|
|
71
|
+
* @param {ReturnType<typeof buildWaveTable>} waveTable
|
|
72
|
+
* @returns {string[]} markdown lines
|
|
73
|
+
*/
|
|
74
|
+
function renderWaveTableLines(waveTable) {
|
|
75
|
+
if (!Array.isArray(waveTable) || waveTable.length === 0) {
|
|
76
|
+
return ['_No stories to wave (empty plan)._'];
|
|
77
|
+
}
|
|
78
|
+
const rows = waveTable.map(
|
|
79
|
+
({ wave, stories }) =>
|
|
80
|
+
`| ${wave + 1} | ${stories.map((s) => `\`${s.slug}\``).join(', ')} |`,
|
|
81
|
+
);
|
|
82
|
+
return ['| Wave | Stories |', '| --- | --- |', ...rows];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build the `plan-summary` structured-comment body: risk + routing +
|
|
87
|
+
* freshness + healthcheck receipts, closing with the mode-specific tail —
|
|
88
|
+
* the dry-run wave table for fan-out/amend, or the single-delivery routing
|
|
89
|
+
* record `{ deliveryShape, sliceCount, routingReasons }` (Epic #4474 PR4)
|
|
90
|
+
* for the spec-only mode.
|
|
91
|
+
*
|
|
92
|
+
* @param {{
|
|
93
|
+
* epicId: number,
|
|
94
|
+
* ticketCount: number,
|
|
95
|
+
* planningRisk: { overallLevel?: string, gateDecision?: string },
|
|
96
|
+
* reviewRouting: { decision?: string },
|
|
97
|
+
* freshness?: { stale?: number, ambiguous?: number },
|
|
98
|
+
* healthcheck?: { ok?: boolean, waived?: boolean, skipped?: boolean },
|
|
99
|
+
* waveTable: ReturnType<typeof buildWaveTable>,
|
|
100
|
+
* mode?: 'fan-out'|'single'|'amend',
|
|
101
|
+
* planMetricsLine?: string|null,
|
|
102
|
+
* single?: { deliveryShape: 'single', sliceCount: number|null, routingReasons: string[] }|null,
|
|
103
|
+
* amend?: {
|
|
104
|
+
* closed: Array<{ slug: string, issueNumber: number }>,
|
|
105
|
+
* recreated: Array<{ slug: string, oldIssueNumber: number, issueNumber: number }>,
|
|
106
|
+
* created: Array<{ slug: string, issueNumber: number }>,
|
|
107
|
+
* keptCount: number,
|
|
108
|
+
* }|null,
|
|
109
|
+
* }} input
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
export function buildPlanSummaryCommentBody({
|
|
113
|
+
epicId,
|
|
114
|
+
ticketCount,
|
|
115
|
+
planningRisk,
|
|
116
|
+
reviewRouting,
|
|
117
|
+
freshness,
|
|
118
|
+
healthcheck,
|
|
119
|
+
waveTable,
|
|
120
|
+
mode = 'fan-out',
|
|
121
|
+
planMetricsLine = null,
|
|
122
|
+
single = null,
|
|
123
|
+
amend = null,
|
|
124
|
+
}) {
|
|
125
|
+
const freshnessLine =
|
|
126
|
+
(freshness?.stale ?? 0) > 0 || (freshness?.ambiguous ?? 0) > 0
|
|
127
|
+
? `- ⚠️ Spec freshness: ${freshness.stale} stale / ${freshness.ambiguous} ambiguous reference(s) — see the spec-freshness comment.`
|
|
128
|
+
: '- Spec freshness: clean.';
|
|
129
|
+
const healthcheckLine = healthcheck?.skipped
|
|
130
|
+
? '- Healthcheck: skipped (test seam).'
|
|
131
|
+
: healthcheck?.ok
|
|
132
|
+
? '- Healthcheck: passed.'
|
|
133
|
+
: `- Healthcheck: failed, waived by operator label.`;
|
|
134
|
+
|
|
135
|
+
const headLine =
|
|
136
|
+
mode === 'single'
|
|
137
|
+
? `- Single-delivery plan (\`delivery::single\`): no Story tree — the Delivery Slicing table is the audit trail.`
|
|
138
|
+
: `- ${ticketCount} Story ticket(s) persisted across ${waveTable.length} wave(s).`;
|
|
139
|
+
|
|
140
|
+
const amendLines = amend
|
|
141
|
+
? [
|
|
142
|
+
`- Amend delta: ${amend.created.length} added, ${amend.recreated.length} modified (closed + recreated), ${amend.closed.length} closed, ${amend.keptCount} kept untouched.`,
|
|
143
|
+
]
|
|
144
|
+
: [];
|
|
145
|
+
|
|
146
|
+
const tail =
|
|
147
|
+
mode === 'single'
|
|
148
|
+
? [
|
|
149
|
+
'#### Delivery routing record',
|
|
150
|
+
'',
|
|
151
|
+
'```json',
|
|
152
|
+
JSON.stringify(
|
|
153
|
+
{
|
|
154
|
+
deliveryShape: 'single',
|
|
155
|
+
sliceCount: single?.sliceCount ?? null,
|
|
156
|
+
routingReasons: single?.routingReasons ?? [],
|
|
157
|
+
},
|
|
158
|
+
null,
|
|
159
|
+
2,
|
|
160
|
+
),
|
|
161
|
+
'```',
|
|
162
|
+
'',
|
|
163
|
+
'_Marker is inert until #4475 lands the deliver-side reader — `/deliver` still treats this Epic as fan-out until then._',
|
|
164
|
+
]
|
|
165
|
+
: [
|
|
166
|
+
'#### Dry-run wave table',
|
|
167
|
+
'',
|
|
168
|
+
...renderWaveTableLines(waveTable),
|
|
169
|
+
'',
|
|
170
|
+
'_Preview only — the authoritative dispatch manifest is written at deliver time (`wave-record-io.js`)._',
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
return [
|
|
174
|
+
`### 📋 Plan Summary — Epic #${epicId} is \`agent::ready\``,
|
|
175
|
+
'',
|
|
176
|
+
headLine,
|
|
177
|
+
...amendLines,
|
|
178
|
+
`- Risk: ${planningRisk?.overallLevel ?? 'unknown'} · ${planningRisk?.gateDecision ?? 'unknown'} (review routing: ${reviewRouting?.decision ?? 'unknown'}).`,
|
|
179
|
+
freshnessLine,
|
|
180
|
+
healthcheckLine,
|
|
181
|
+
// G2 measurement receipt (Epic #4474 PR1/PR7): the plan-CLI invocation
|
|
182
|
+
// ledger roll-up (turns-per-plan proxy, per-mode counts, critic skips)
|
|
183
|
+
// rides the summary comment so the cohort reader never has to pull the
|
|
184
|
+
// temp ledger off the runner's disk. Omitted when the ledger is empty.
|
|
185
|
+
...(typeof planMetricsLine === 'string' && planMetricsLine.length > 0
|
|
186
|
+
? [`- ${planMetricsLine}`]
|
|
187
|
+
: []),
|
|
188
|
+
'',
|
|
189
|
+
...tail,
|
|
190
|
+
].join('\n');
|
|
191
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan-reachability.js — deterministic draft-ticket reachability check for
|
|
3
|
+
* the persist surface (Epic #4474 PR6, design §4: the 8.4 reachability
|
|
4
|
+
* critic demoted from a fresh-context sub-agent to a persist-side scan).
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the mechanics of the existing `--paranoid` F7 healthcheck
|
|
7
|
+
* (`epic-plan-healthcheck.js#checkReachability`) — route-glob scan of the
|
|
8
|
+
* paths a Story declares vs the `planning.navigation.navRegistry` token
|
|
9
|
+
* list — but runs it over the **draft** ticket set inside `plan-persist.js`
|
|
10
|
+
* step 4.5, before any provider call, so an orphaned surface is caught
|
|
11
|
+
* while a one-line targeted amend is still free (nothing has been written
|
|
12
|
+
* to GitHub yet).
|
|
13
|
+
*
|
|
14
|
+
* Plan-level coverage semantics (the convergence contract): a route-adding
|
|
15
|
+
* story that never references the nav registry produces orphan surfaces —
|
|
16
|
+
* UNLESS every one of its route paths is also mentioned by some story in
|
|
17
|
+
* the plan that DOES reference the registry (the "navigation owner"). That
|
|
18
|
+
* is exactly what the documented recovery produces: the author appends the
|
|
19
|
+
* single reachability Story (which cites the orphaned routes and the nav
|
|
20
|
+
* registry) in one targeted amend, and the re-run persist passes.
|
|
21
|
+
*
|
|
22
|
+
* Silent no-op when `planning.navigation` is unconfigured (`routeGlobs`
|
|
23
|
+
* empty) — same opt-in contract as F7 / AC-13 — reported as
|
|
24
|
+
* `status: 'skipped'` so the caller can append the audit record to the
|
|
25
|
+
* plan-metrics ledger.
|
|
26
|
+
*
|
|
27
|
+
* Pure over its inputs (tickets + resolved config), no I/O.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
extractStoryPaths,
|
|
32
|
+
globToRegExp,
|
|
33
|
+
resolveNavConfig,
|
|
34
|
+
} from '../../epic-plan-healthcheck.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Fallback tokens when `navRegistry` is unconfigured but `routeGlobs` is —
|
|
38
|
+
* identical to the F7 healthcheck's fallback, so the two checks agree on
|
|
39
|
+
* what counts as a registry reference.
|
|
40
|
+
*/
|
|
41
|
+
const FALLBACK_REGISTRY_TOKENS = ['nav registry', 'navigation'];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {Object} ReachabilityOrphan
|
|
45
|
+
* @property {string} story The offending draft story's slug (or title).
|
|
46
|
+
* @property {string[]} paths The route-matching paths with no navigation
|
|
47
|
+
* owner anywhere in the plan.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {Object} DraftReachabilityResult
|
|
52
|
+
* @property {'skipped'|'ok'|'orphans'} status
|
|
53
|
+
* @property {string[]} reasons
|
|
54
|
+
* @property {ReachabilityOrphan[]} orphans Empty unless `status` is
|
|
55
|
+
* `'orphans'`.
|
|
56
|
+
* @property {number} scanned Draft stories scanned (0 when skipped).
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Evaluate draft-ticket reachability against the configured navigation
|
|
61
|
+
* surface.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} input
|
|
64
|
+
* @param {Array<{ slug?: string, title?: string, body?: string }>} input.tickets
|
|
65
|
+
* The draft ticket set the persist is about to create (fan-out: the
|
|
66
|
+
* authored `tickets.json`; amend: the merged set).
|
|
67
|
+
* @param {object} [input.config] Resolved `.agentrc.json` (threads
|
|
68
|
+
* `planning.navigation`).
|
|
69
|
+
* @returns {DraftReachabilityResult}
|
|
70
|
+
*/
|
|
71
|
+
export function evaluateDraftReachability({ tickets, config }) {
|
|
72
|
+
const { routeGlobs, navRegistry } = resolveNavConfig(config);
|
|
73
|
+
|
|
74
|
+
if (routeGlobs.length === 0) {
|
|
75
|
+
return {
|
|
76
|
+
status: 'skipped',
|
|
77
|
+
reasons: ['No planning.navigation.routeGlobs configured — skipped.'],
|
|
78
|
+
orphans: [],
|
|
79
|
+
scanned: 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const stories = Array.isArray(tickets) ? tickets : [];
|
|
84
|
+
const matchers = routeGlobs.map(globToRegExp);
|
|
85
|
+
const registryTokens =
|
|
86
|
+
navRegistry.length > 0
|
|
87
|
+
? navRegistry.map((t) => t.toLowerCase())
|
|
88
|
+
: FALLBACK_REGISTRY_TOKENS;
|
|
89
|
+
|
|
90
|
+
// Pass 1: per-story scan — declared paths, route matches, registry refs.
|
|
91
|
+
const scannedStories = stories.map((story) => {
|
|
92
|
+
const body = typeof story?.body === 'string' ? story.body : '';
|
|
93
|
+
const paths = extractStoryPaths(body);
|
|
94
|
+
const routePaths = paths.filter((p) => matchers.some((rx) => rx.test(p)));
|
|
95
|
+
const text = [body, story?.title ?? ''].join('\n').toLowerCase();
|
|
96
|
+
const referencesRegistry = registryTokens.some((tok) => text.includes(tok));
|
|
97
|
+
return { story, paths, routePaths, referencesRegistry };
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Pass 2: navigation owners — every path mentioned by a
|
|
101
|
+
// registry-referencing story is covered plan-wide.
|
|
102
|
+
const coveredPaths = new Set();
|
|
103
|
+
for (const s of scannedStories) {
|
|
104
|
+
if (!s.referencesRegistry) continue;
|
|
105
|
+
for (const p of s.paths) coveredPaths.add(p);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const orphans = [];
|
|
109
|
+
for (const s of scannedStories) {
|
|
110
|
+
if (s.referencesRegistry || s.routePaths.length === 0) continue;
|
|
111
|
+
const uncovered = s.routePaths.filter((p) => !coveredPaths.has(p));
|
|
112
|
+
if (uncovered.length === 0) continue;
|
|
113
|
+
orphans.push({
|
|
114
|
+
story: s.story?.slug ?? s.story?.title ?? '<unnamed story>',
|
|
115
|
+
paths: uncovered,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (orphans.length > 0) {
|
|
120
|
+
const registryHint =
|
|
121
|
+
navRegistry.length > 0 ? navRegistry.join(', ') : 'the nav registry';
|
|
122
|
+
return {
|
|
123
|
+
status: 'orphans',
|
|
124
|
+
reasons: [
|
|
125
|
+
`${orphans.length} route-adding draft story(ies) leave orphan surfaces with no navigation owner (registry: ${registryHint}).`,
|
|
126
|
+
],
|
|
127
|
+
orphans,
|
|
128
|
+
scanned: stories.length,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
status: 'ok',
|
|
134
|
+
reasons: [
|
|
135
|
+
`${stories.length} draft story(ies) scanned — every route-adding story has a navigation owner.`,
|
|
136
|
+
],
|
|
137
|
+
orphans: [],
|
|
138
|
+
scanned: stories.length,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Render the named soft-failure message the persist CLI prints — the
|
|
144
|
+
* orphan-surface list plus the one-targeted-amend recovery contract.
|
|
145
|
+
*
|
|
146
|
+
* @param {DraftReachabilityResult} result A `status: 'orphans'` result.
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
export function renderReachabilityOrphans(result) {
|
|
150
|
+
const lines = [
|
|
151
|
+
'[plan-persist] SOFT FAILURE — reachability orphans (route-glob vs navRegistry):',
|
|
152
|
+
...result.orphans.map((o) => ` - ${o.story}: ${o.paths.join(', ')}`),
|
|
153
|
+
'',
|
|
154
|
+
'Nothing was written to GitHub. Apply ONE targeted amend to tickets.json',
|
|
155
|
+
'adding a navigation owner (at most one reachability Story per plan) that',
|
|
156
|
+
'cites the orphaned routes and the nav registry, then re-run the persist',
|
|
157
|
+
'once.',
|
|
158
|
+
];
|
|
159
|
+
return lines.join('\n');
|
|
160
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* plan-runner/worktree-sweep.js
|
|
3
3
|
*
|
|
4
|
-
* Reap-sweep
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
4
|
+
* Reap-sweep run at plan boot (via `drainPendingCleanupAtBoot` in
|
|
5
|
+
* `lib/orchestration/epic-plan-spec/phases/drain.js`, wired into
|
|
6
|
+
* `plan-persist.js`). Iterates the `.worktrees/story-<id>/`
|
|
7
7
|
* entries registered with git, looks up each parent Story, and force-removes
|
|
8
8
|
* any whose Story is already closed or labeled `agent::done`.
|
|
9
9
|
*
|
|
@@ -138,7 +138,7 @@ function resolveRequiresReview(overallLevel, axes) {
|
|
|
138
138
|
/**
|
|
139
139
|
* Derive the stable planningRisk envelope from a schema-validated planner
|
|
140
140
|
* verdict. Pure derivation — schema validation happens at the read boundary
|
|
141
|
-
* (`
|
|
141
|
+
* (`plan-persist.js` via `loadRiskVerdict`), never here, so a malformed verdict fails closed
|
|
142
142
|
* before this function runs.
|
|
143
143
|
*
|
|
144
144
|
* **No-BDD-runner waiver (Story #4145).** The acceptance disposition the risk
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// .agents/scripts/lib/orchestration/remote-verifier.js
|
|
2
|
+
/**
|
|
3
|
+
* remote-verifier.js — deterministic "is there a live, pushable remote?"
|
|
4
|
+
* evidence for the delivery entry seams. Issue #4483.
|
|
5
|
+
*
|
|
6
|
+
* `/deliver` could silently shortcut the entire orchestration — building
|
|
7
|
+
* the delivery inline and committing to local `main` without pushing —
|
|
8
|
+
* when the driving agent *perceived* the environment had no live GitHub
|
|
9
|
+
* remote. The judgment was vibes, not fact. This module gives the entry
|
|
10
|
+
* seams (`epic-deliver-preflight.js`, `single-story-init.js`) a verified
|
|
11
|
+
* probe result to record in their envelopes so the workflow can branch on
|
|
12
|
+
* `remoteVerified: true|false` deterministically: use the remote, or
|
|
13
|
+
* transition to `agent::blocked` quoting the probe output — never a
|
|
14
|
+
* silent local build.
|
|
15
|
+
*
|
|
16
|
+
* Two probes, both bounded (a hung git spawn must not park the entry
|
|
17
|
+
* seam — mirrors the `ghPrListHead` timeout contract in `finalizer.js`):
|
|
18
|
+
*
|
|
19
|
+
* 1. `git remote get-url origin` — is an `origin` remote configured?
|
|
20
|
+
* 2. `git ls-remote origin HEAD` — is it reachable with current auth?
|
|
21
|
+
*
|
|
22
|
+
* `remoteVerified` is true only when BOTH succeed. The CLI callers do
|
|
23
|
+
* NOT flip labels on a false result — the workflow owns the
|
|
24
|
+
* `agent::blocked` transition (same division of labour as the preflight
|
|
25
|
+
* breach handling).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { spawnSync } from 'node:child_process';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Bounded timeout for each git probe. `ls-remote` is a network call;
|
|
32
|
+
* SIGKILL at the bound so an unreachable or hanging remote degrades to a
|
|
33
|
+
* deterministic `remoteVerified: false` instead of a stuck entry seam.
|
|
34
|
+
*/
|
|
35
|
+
export const REMOTE_PROBE_TIMEOUT_MS = 30_000;
|
|
36
|
+
|
|
37
|
+
function runProbe({ args, cwd, spawnFn, timeoutMs }) {
|
|
38
|
+
const result = spawnFn('git', args, {
|
|
39
|
+
cwd,
|
|
40
|
+
encoding: 'utf-8',
|
|
41
|
+
shell: false,
|
|
42
|
+
timeout: timeoutMs,
|
|
43
|
+
killSignal: 'SIGKILL',
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
args: ['git', ...args].join(' '),
|
|
47
|
+
status: result.status ?? 1,
|
|
48
|
+
stdout: (result.stdout ?? '').trim(),
|
|
49
|
+
stderr: (result.stderr ?? '').trim(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Probe the `origin` remote for existence + reachability.
|
|
55
|
+
*
|
|
56
|
+
* @param {{
|
|
57
|
+
* cwd?: string,
|
|
58
|
+
* spawnFn?: typeof spawnSync,
|
|
59
|
+
* timeoutMs?: number,
|
|
60
|
+
* }} [opts]
|
|
61
|
+
* @returns {{
|
|
62
|
+
* remoteVerified: boolean,
|
|
63
|
+
* remoteUrl: string|null,
|
|
64
|
+
* detail: string,
|
|
65
|
+
* probes: {
|
|
66
|
+
* getUrl: { args: string, status: number, stdout: string, stderr: string },
|
|
67
|
+
* lsRemote: { args: string, status: number, stdout: string, stderr: string }|null,
|
|
68
|
+
* },
|
|
69
|
+
* }}
|
|
70
|
+
*/
|
|
71
|
+
export function verifyRemote({
|
|
72
|
+
cwd = process.cwd(),
|
|
73
|
+
spawnFn = spawnSync,
|
|
74
|
+
timeoutMs = REMOTE_PROBE_TIMEOUT_MS,
|
|
75
|
+
} = {}) {
|
|
76
|
+
const getUrl = runProbe({
|
|
77
|
+
args: ['remote', 'get-url', 'origin'],
|
|
78
|
+
cwd,
|
|
79
|
+
spawnFn,
|
|
80
|
+
timeoutMs,
|
|
81
|
+
});
|
|
82
|
+
if (getUrl.status !== 0) {
|
|
83
|
+
return {
|
|
84
|
+
remoteVerified: false,
|
|
85
|
+
remoteUrl: null,
|
|
86
|
+
detail: `no 'origin' remote configured — \`${getUrl.args}\` exited ${getUrl.status}: ${getUrl.stderr || '(no stderr)'}`,
|
|
87
|
+
probes: { getUrl, lsRemote: null },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const remoteUrl = getUrl.stdout;
|
|
91
|
+
|
|
92
|
+
const lsRemote = runProbe({
|
|
93
|
+
args: ['ls-remote', 'origin', 'HEAD'],
|
|
94
|
+
cwd,
|
|
95
|
+
spawnFn,
|
|
96
|
+
timeoutMs,
|
|
97
|
+
});
|
|
98
|
+
if (lsRemote.status !== 0 || lsRemote.stdout.length === 0) {
|
|
99
|
+
return {
|
|
100
|
+
remoteVerified: false,
|
|
101
|
+
remoteUrl,
|
|
102
|
+
detail: `'origin' (${remoteUrl}) is unreachable — \`${lsRemote.args}\` exited ${lsRemote.status}: ${lsRemote.stderr || '(empty ls-remote output)'}`,
|
|
103
|
+
probes: { getUrl, lsRemote },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
remoteVerified: true,
|
|
109
|
+
remoteUrl,
|
|
110
|
+
detail: `origin verified (${remoteUrl}); ls-remote HEAD → ${lsRemote.stdout.split(/\s+/)[0]}`,
|
|
111
|
+
probes: { getUrl, lsRemote },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Probe whether a specific branch exists on `origin` — the deterministic
|
|
117
|
+
* finalize backstop (issue #4483 fix direction 3): a delivery branch that
|
|
118
|
+
* was never pushed MUST fail finalize with an explicit blocker rather
|
|
119
|
+
* than let the run declare success.
|
|
120
|
+
*
|
|
121
|
+
* Distinct from `git-branch-lifecycle.js#branchExistsRemotely` in two
|
|
122
|
+
* load-bearing ways: the spawn is bounded (timeout + SIGKILL, so a hung
|
|
123
|
+
* remote cannot park the finalize seam) and the result carries the probe
|
|
124
|
+
* detail for the blocker envelope instead of a bare boolean.
|
|
125
|
+
*
|
|
126
|
+
* @param {{
|
|
127
|
+
* branch: string,
|
|
128
|
+
* cwd?: string,
|
|
129
|
+
* spawnFn?: typeof spawnSync,
|
|
130
|
+
* timeoutMs?: number,
|
|
131
|
+
* }} opts
|
|
132
|
+
* @returns {{ exists: boolean, detail: string }}
|
|
133
|
+
*/
|
|
134
|
+
export function probeRemoteBranch({
|
|
135
|
+
branch,
|
|
136
|
+
cwd = process.cwd(),
|
|
137
|
+
spawnFn = spawnSync,
|
|
138
|
+
timeoutMs = REMOTE_PROBE_TIMEOUT_MS,
|
|
139
|
+
}) {
|
|
140
|
+
if (typeof branch !== 'string' || branch.length === 0) {
|
|
141
|
+
throw new TypeError('probeRemoteBranch: branch must be a non-empty string');
|
|
142
|
+
}
|
|
143
|
+
const probe = runProbe({
|
|
144
|
+
args: ['ls-remote', '--heads', 'origin', branch],
|
|
145
|
+
cwd,
|
|
146
|
+
spawnFn,
|
|
147
|
+
timeoutMs,
|
|
148
|
+
});
|
|
149
|
+
if (probe.status !== 0) {
|
|
150
|
+
return {
|
|
151
|
+
exists: false,
|
|
152
|
+
detail: `\`${probe.args}\` exited ${probe.status}: ${probe.stderr || '(no stderr)'}`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (probe.stdout.length === 0) {
|
|
156
|
+
return {
|
|
157
|
+
exists: false,
|
|
158
|
+
detail: `\`${probe.args}\` found no ref — ${branch} was never pushed to origin`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
exists: true,
|
|
163
|
+
detail: `${branch} on origin at ${probe.stdout.split(/\s+/)[0]}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
* subsequent fetches are cheap. A push failure raises so the caller
|
|
6
6
|
* fails non-zero — the operator must resolve before retrying.
|
|
7
7
|
*
|
|
8
|
+
* Issue #4483 — this phase IS the standalone path's deterministic
|
|
9
|
+
* land-or-block backstop (the counterpart to the Epic finalize seam's
|
|
10
|
+
* `delivery-branch-missing-on-origin` blocker). `git push` exits 0 only
|
|
11
|
+
* after origin has accepted the ref, so the throw below is the origin
|
|
12
|
+
* assertion: every later close phase (PR open, auto-merge, the
|
|
13
|
+
* `closeResult` success envelope) is unreachable unless the Story branch
|
|
14
|
+
* verifiably landed on origin. No post-push `ls-remote` re-probe is
|
|
15
|
+
* added because it would re-ask a question the push exit code already
|
|
16
|
+
* answered authoritatively.
|
|
17
|
+
*
|
|
8
18
|
* `gitSync` is accepted as an injected dependency rather than statically
|
|
9
19
|
* imported so the caller's (cache-busted) binding wins. The
|
|
10
20
|
* `single-story-close.js` orchestrator owns the static import; test
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* `validateSpecFreshness` scans a Tech Spec body for path-shaped references,
|
|
12
12
|
* probes each against `baseBranchRef` (via `git cat-file -e`), and returns a
|
|
13
|
-
* `{ stale, fresh, ambiguous }` envelope. The caller (
|
|
13
|
+
* `{ stale, fresh, ambiguous }` envelope. The caller (plan-persist.js)
|
|
14
14
|
* uses the result to write a JSON report and post an advisory structured
|
|
15
15
|
* comment on the Epic. The check is intentionally non-blocking —
|
|
16
16
|
* planning continues even when stale references are present, because the
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
* 5xx, transport timeouts) inside the cascade transition. Three attempts with
|
|
34
34
|
* exponential backoff (250ms / 500ms / 1000ms) mirrors the budget used by
|
|
35
35
|
* `gitFetchWithRetry` (see `lib/git-utils.js`) and the HTTP-client retry path
|
|
36
|
-
* referenced by
|
|
36
|
+
* referenced by the plan persist surface. Backoff is overridable via
|
|
37
37
|
* {@link __setCascadeRetryDelays} so tests don't pay real wall-clock time.
|
|
38
38
|
*/
|
|
39
39
|
const CASCADE_RETRY_BACKOFF_MS = [250, 500, 1000];
|
|
@@ -102,7 +102,7 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
|
|
|
102
102
|
// a `clarity-gate-update` comment on the Epic when the operator approves
|
|
103
103
|
// a sharpened body rewrite, recording the persistence event for audit.
|
|
104
104
|
'clarity-gate-update',
|
|
105
|
-
// Story #2635 —
|
|
105
|
+
// Story #2635 — Tech Spec freshness check. `plan-persist.js`
|
|
106
106
|
// upserts a `spec-freshness` comment on the Epic listing any
|
|
107
107
|
// path-shaped references that don't exist at the base branch, so the
|
|
108
108
|
// operator can correct drift before Phase 8 decomposes from a stale
|
|
@@ -147,7 +147,7 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
|
|
|
147
147
|
// the documented remediation actually executable
|
|
148
148
|
// (assertValidStructuredCommentType would otherwise throw).
|
|
149
149
|
'wave-stall',
|
|
150
|
-
// Story #3873 (Epic #3865) — `
|
|
150
|
+
// Story #3873 (Epic #3865) — `plan-persist.js` upserts a `risk-verdict`
|
|
151
151
|
// comment on the Epic at persist time, recording the planner-authored,
|
|
152
152
|
// schema-validated risk verdict and the planningRisk envelope derived
|
|
153
153
|
// from it (`deriveRiskEnvelope`). One entry per Epic; re-plans upsert in
|
|
@@ -171,6 +171,15 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
|
|
|
171
171
|
// `graduator="audit-results|code-review"` attr so the two graduators
|
|
172
172
|
// upsert independent comments; re-runs upsert in place.
|
|
173
173
|
'cross-repo-deferred',
|
|
174
|
+
// Epic #4474 (PR3) — `plan-persist.js` upserts a single `plan-summary`
|
|
175
|
+
// comment on the Epic at terminal persist success, carrying the risk /
|
|
176
|
+
// routing / freshness / healthcheck receipts and the dry-run wave table
|
|
177
|
+
// as closing text. Replaces the retired plan-time `dispatch-manifest`
|
|
178
|
+
// comment (whose claimed consumer did not exist — the live manifest is
|
|
179
|
+
// written at deliver time by `wave-record-io.js`) and the Phase 12
|
|
180
|
+
// notify round-trip. One entry per Epic; a --force/--resume re-persist
|
|
181
|
+
// upserts in place.
|
|
182
|
+
'plan-summary',
|
|
174
183
|
]);
|
|
175
184
|
|
|
176
185
|
export const WAVE_TYPE_PATTERN = WAVE_MARKER_RE;
|
|
@@ -41,6 +41,22 @@ export const PHASE_TEMP_BASENAMES = Object.freeze({
|
|
|
41
41
|
'acceptance-spec.md',
|
|
42
42
|
]),
|
|
43
43
|
decompose: Object.freeze(['decomposer-context.json', 'tickets.json']),
|
|
44
|
+
// Epic #4474 (PR3) — the collapsed `plan-persist.js` surface owns every
|
|
45
|
+
// plan-phase artifact and deletes them ONLY at terminal success (after
|
|
46
|
+
// the `agent::ready` flip), fixing the mid-pipeline deletion defect where
|
|
47
|
+
// per-phase cleanup removed artifacts a `--force`/`--resume` re-persist
|
|
48
|
+
// was still entitled to reuse. `risk-verdict.json` joins the set here:
|
|
49
|
+
// the split-phase cleanup never owned it, which orphaned it in temp/.
|
|
50
|
+
// `plan-metrics.json` stays deliberately excluded (PR1) — the ledger
|
|
51
|
+
// must survive cleanup so the whole plan run is visible in one stream.
|
|
52
|
+
persist: Object.freeze([
|
|
53
|
+
'planner-context.json',
|
|
54
|
+
'techspec.md',
|
|
55
|
+
'acceptance-spec.md',
|
|
56
|
+
'risk-verdict.json',
|
|
57
|
+
'decomposer-context.json',
|
|
58
|
+
'tickets.json',
|
|
59
|
+
]),
|
|
44
60
|
});
|
|
45
61
|
|
|
46
62
|
/**
|
|
@@ -120,10 +120,9 @@ export function deleteLegacyFlatManifest(epicId, opts = {}) {
|
|
|
120
120
|
* Atomic write-then-rename. On any failure, best-effort remove the `.tmp`
|
|
121
121
|
* file and rethrow so the caller can surface a structured result.
|
|
122
122
|
*
|
|
123
|
-
*
|
|
124
|
-
* `.md` / `.json` artefact writes through the same crash-safe primitive.
|
|
123
|
+
* Internal crash-safe write primitive for the manifest artefact pair.
|
|
125
124
|
*/
|
|
126
|
-
|
|
125
|
+
function atomicWrite(finalPath, content) {
|
|
127
126
|
const tmpPath = `${finalPath}.tmp`;
|
|
128
127
|
try {
|
|
129
128
|
fs.writeFileSync(tmpPath, content, 'utf8');
|