mandrel 2.8.0 → 2.10.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/.markdownlint.json +4 -0
- package/.agents/agents/acceptance-critic.md +30 -5
- package/.agents/agents/auditor.md +36 -19
- package/.agents/agents/plan-critic.md +31 -5
- package/.agents/agents/story-worker.md +91 -100
- package/.agents/docs/configuration.md +39 -1
- package/.agents/docs/execution-reference.md +13 -0
- package/.agents/docs/workflows.md +1 -1
- package/.agents/instructions.md +131 -265
- package/.agents/rules/git-conventions.md +47 -83
- package/.agents/rules/orchestration-error-handling.md +28 -0
- package/.agents/schemas/agentrc.schema.json +44 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +1 -1
- package/.agents/scripts/apply-quality-bootstrap.js +1 -1
- package/.agents/scripts/audit-to-stories.js +51 -0
- package/.agents/scripts/check-test-temp-hygiene.js +438 -0
- package/.agents/scripts/deliver-recover.js +23 -6
- package/.agents/scripts/lib/audit-suite/index.js +5 -0
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
- package/.agents/scripts/lib/audit-suite/selector.js +1 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +120 -55
- package/.agents/scripts/lib/config/temp-paths.js +121 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
- package/.agents/scripts/lib/config-settings-schema.js +32 -0
- package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
- package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
- package/.agents/scripts/lib/observability/terse-result.js +114 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +318 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
- package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
- package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +60 -0
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
- package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +102 -2
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +215 -14
- package/.agents/scripts/lib/orchestration/resolve-stories.js +7 -0
- package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +55 -14
- package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -1
- package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
- package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
- package/.agents/scripts/lib/story-body/story-body.js +18 -2
- package/.agents/scripts/lib/templates/decomposer-prompts.js +29 -6
- package/.agents/scripts/lib/test-env.js +65 -0
- package/.agents/scripts/plan-context.js +66 -9
- package/.agents/scripts/plan-critics.js +115 -3
- package/.agents/scripts/plan-persist.js +11 -1
- package/.agents/scripts/plan-run-epilogue.js +1 -1
- package/.agents/scripts/providers/github/issues.js +54 -7
- package/.agents/scripts/providers/github/search-budget.js +124 -0
- package/.agents/scripts/providers/github/search-query.js +71 -0
- package/.agents/scripts/single-story-confirm-merge.js +79 -10
- package/.agents/scripts/single-story-init.js +19 -3
- package/.agents/scripts/stories-wave-tick.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +9 -3
- package/.agents/workflows/deliver.md +86 -230
- package/.agents/workflows/helpers/deliver-reference.md +167 -0
- package/.agents/workflows/helpers/deliver-story-reference.md +203 -0
- package/.agents/workflows/helpers/deliver-story.md +114 -422
- package/.agents/workflows/helpers/plan-reference.md +211 -0
- package/.agents/workflows/plan.md +107 -279
- package/docs/CHANGELOG.md +47 -0
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
DELIVERABLE_GRANULARITY_GUIDANCE,
|
|
6
6
|
resolveCapacityCeilings,
|
|
7
7
|
} from '../orchestration/ticket-validator-sizing.js';
|
|
8
|
+
import { BODY_FORMAT_LINTS } from '../story-body/body-format-lints.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Sole source of truth for the prompt's `maxTickets` cap is the resolved
|
|
@@ -63,6 +64,15 @@ function render2TierPrompt({ maxTickets }) {
|
|
|
63
64
|
advisoryCaveat,
|
|
64
65
|
newFileContract,
|
|
65
66
|
} = AUTHORING_ALTITUDE_GUIDANCE;
|
|
67
|
+
// The deterministic body-format lints (structured `## Changes` bullet shape,
|
|
68
|
+
// verify-tier suffixes, …) rendered example-first from their single source
|
|
69
|
+
// (`lib/story-body/body-format-lints.js`) so an authored draft is lint-clean
|
|
70
|
+
// by construction rather than discovered as a persist dry-run failure and
|
|
71
|
+
// re-authored at resident-context prices (Story #4684).
|
|
72
|
+
const bodyFormatLintChecklist = BODY_FORMAT_LINTS.map(
|
|
73
|
+
(lint) =>
|
|
74
|
+
`- **${lint.id}** — ${lint.summary} Example: \`${lint.goodExample}\``,
|
|
75
|
+
).join('\n');
|
|
66
76
|
return `You are an expert Senior Project Manager and Orchestrator.
|
|
67
77
|
Your job is to turn a plan seed / Tech Spec into a Story ticket array for an AI Agent to execute.
|
|
68
78
|
|
|
@@ -99,7 +109,7 @@ You MUST respond ONLY with a valid JSON array of objects. No prose, no markdown
|
|
|
99
109
|
**Slug format**: \`^[a-z0-9][a-z0-9-]*$\` — hyphen-case only. Underscores are rejected by the validator.
|
|
100
110
|
|
|
101
111
|
### STORY BODY SCHEMA (REQUIRED FOR EVERY STORY):
|
|
102
|
-
\`body\`
|
|
112
|
+
\`body\` is either the serialized markdown **string** (the section format below) or a **structured object** carrying the same fields (\`goal\`, optional \`slicing\` / \`spec\`, \`changes\`, optional \`non_goals\` / \`wide\` / \`reason_to_exist\` / \`estimated_test_files\`) — persist parses either shape and serializes the canonical markdown itself, so you never need to read \`story-body.js\` or hand-assemble the markdown (the \`stories.template.json\` file emitted next to the plan-context envelope is a ready-to-fill structured-object skeleton). Stories are consumed by non-interactive sub-agents that must self-verify from the Story ticket alone — so the ticket must carry everything an agent needs to execute and self-verify.
|
|
103
113
|
|
|
104
114
|
The \`acceptance[]\` and \`verify[]\` arrays live at the **top level** of the Story ticket object — that is the machine contract the validator reads. Author each list **once, at top level**, and **omit** the \`## Acceptance\` / \`## Verify\` sections from the authored \`body\` string: persist syncs the top-level arrays into those sections so the GitHub issue stays a complete executable document. The validator resolves both fields from the top level, so an omitted section is the expected shape, not a violation.
|
|
105
115
|
|
|
@@ -128,10 +138,6 @@ The serialized \`body\` string renders these markdown sections (in order):
|
|
|
128
138
|
- <exact command or test path> (<tier>)
|
|
129
139
|
- ...
|
|
130
140
|
|
|
131
|
-
## References
|
|
132
|
-
- {"path": "<read-only dependency path>", "assumption": "exists"}
|
|
133
|
-
- ...
|
|
134
|
-
|
|
135
141
|
## Non-Goals
|
|
136
142
|
- <a capability or change this Story explicitly does NOT deliver>
|
|
137
143
|
- ...
|
|
@@ -139,7 +145,7 @@ The serialized \`body\` string renders these markdown sections (in order):
|
|
|
139
145
|
#### STORY BODY RULES:
|
|
140
146
|
|
|
141
147
|
- **goal** (in body string): One sentence stating WHY this Story exists.
|
|
142
|
-
- **spec** (optional, in body string as \`## Spec\`): Lean technical approach only. If the Spec is large enough to feel like its own document, the Story is probably too big — split it. Persist keeps Specs inline and rejects over-budget Specs (never writes them under \`docs/\`).
|
|
148
|
+
- **spec** (optional, in body string as \`## Spec\`): Lean technical approach only, at the altitude the SPEC PROSE CONTRACT below fixes — contract and invariants, never implementation narration. If the Spec is large enough to feel like its own document, the Story is probably too big — split it. Persist keeps Specs inline and rejects over-budget Specs (never writes them under \`docs/\`).
|
|
143
149
|
- **slicing** (optional): Ordered intra-session checkpoints for one Story. Not a fan-out table and not a duplicate of Acceptance.
|
|
144
150
|
- **changes** (in body string): Each entry is an object \`{ path, assumption }\` where \`assumption\` is one of \`creates | refactors-existing | deletes\`. Acceptable path shapes include explicit files (\`src/components/Foo.tsx\`), glob patterns (\`tests/e2e/*.spec.ts\`, \`**/*.astro\`), and module identifiers that resolve to files. Use \`refactors-existing\` for in-place edits to a file already on \`main\`; \`creates\` for net-new files; \`deletes\` for removals.
|
|
145
151
|
- **acceptance** (top-level array on the ticket object): Items MUST be observable from outside the agent. Acceptable shapes: a specific command exits 0, a file exists at a given path, a snapshot test matches, a \`data-testid\` resolves under a given selector, a row count in a fixture matches. UNACCEPTABLE: "verify by reading the diff", "looks good", "matches the spec" — push these down into a \`verify\` command instead.
|
|
@@ -152,6 +158,23 @@ The serialized \`body\` string renders these markdown sections (in order):
|
|
|
152
158
|
- **Bodies record decisions, never questions to the operator.** Never persist an open question ("Flag if…", "TBD", "confirm with the operator") into a Story body — the executing sub-agent is non-interactive and cannot answer it. Resolve the unknown before authoring, or restate it as a declarative Key Assumption the agent can act on.
|
|
153
159
|
- **non_goals** (OPTIONAL, in body string as the \`## Non-Goals\` section): A short list of capabilities or changes this Story explicitly does NOT deliver — an advisory negative-scope bound that fences the executing agent away from adjacent work. It is **advisory and NON-GATING**: the validator does not require, count, or reject on it, and an absent or empty section renders nothing. Use the EXACT single-word hyphenated heading spelling \`## Non-Goals\` (a space-separated heading like \`## Out of Scope\` is NOT recognized by the parser and will be dropped). Reach for it when a Story's negative boundary is non-obvious from its \`acceptance[]\` alone; omit it otherwise.
|
|
154
160
|
|
|
161
|
+
#### SPEC PROSE CONTRACT — state the contract, not the implementation:
|
|
162
|
+
|
|
163
|
+
The Story is executed by a frontier-model deliverer that reads the codebase itself. Author \`## Spec\` (and Goal prose) at contract level:
|
|
164
|
+
|
|
165
|
+
- **Spec states the contract and invariants**: interfaces, status codes, security invariants, and load-bearing constraints — each with its why. That is the whole job of the Spec.
|
|
166
|
+
- **Implementation choices belong to the deliverer** unless a choice is load-bearing; a load-bearing choice is stated as a constraint (with why it binds), never as a walkthrough of how to code it.
|
|
167
|
+
- **No per-file behavior paragraphs.** The \`## Changes\` list already names the footprint; do not narrate what each file will do.
|
|
168
|
+
- **No current-state narration.** Do not describe how the codebase works today as scene-setting; the deliverer reads the code. The only current-state prose allowed is a claim a decision depends on, opening with \`Current state (verified <date>)\` per the observed-behavior rule above.
|
|
169
|
+
- **Do not author a \`## References\` section.** Read-only context the deliverer needs is discoverable from the contract and the footprint.
|
|
170
|
+
- **Acceptance criteria remain the binding contract** — the Spec constrains and explains; \`acceptance[]\` binds.
|
|
171
|
+
|
|
172
|
+
#### DETERMINISTIC BODY-FORMAT LINTS — author lint-clean by construction:
|
|
173
|
+
|
|
174
|
+
Persist enforces the deterministic body-format rules below and **rejects** an authored body that violates any of them. Author every Story to satisfy all of them on the FIRST draft — each rule is stated example-first so there is nothing to discover by trial-and-error. The two auto-fixable rules (\`changes-path-entry-shape\`, \`verify-tier-suffix\`) also emit the corrected form in the dry-run failure output, but authoring them right up front avoids the round-trip entirely.
|
|
175
|
+
|
|
176
|
+
${bodyFormatLintChecklist}
|
|
177
|
+
|
|
155
178
|
#### AUTHORING ALTITUDE — BINDING ACCEPTANCE vs ADVISORY CHANGES:
|
|
156
179
|
|
|
157
180
|
${authoringAltitude}
|
|
@@ -1,3 +1,62 @@
|
|
|
1
|
+
import { mkdtempSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { TEST_TEMP_ROOT_ENV } from './config/temp-paths.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-process memo for the created scratch dir, so repeated calls in one
|
|
9
|
+
* runner process (e.g. building env bags for several chunks) share a single
|
|
10
|
+
* scratch tree instead of minting one dir per call.
|
|
11
|
+
*/
|
|
12
|
+
let _createdScratchDir = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Test-only: clear the per-process scratch memo so a suite can exercise the
|
|
16
|
+
* creation branch repeatedly in one process.
|
|
17
|
+
*/
|
|
18
|
+
export function _clearTestScratchTempRootCache() {
|
|
19
|
+
_createdScratchDir = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ensure an absolute per-process scratch tempRoot is available for the test
|
|
24
|
+
* run and return it (Story #4696).
|
|
25
|
+
*
|
|
26
|
+
* If `baseEnv` already carries an absolute `MANDREL_TEST_TEMP_ROOT` (the
|
|
27
|
+
* common case for a child process that inherits the parent runner's env),
|
|
28
|
+
* that value is reused verbatim so every chunk / worker of a single suite
|
|
29
|
+
* run shares one scratch dir. Otherwise a fresh `os.tmpdir()` directory is
|
|
30
|
+
* created once per process (memoized). Every stream writer that resolves a
|
|
31
|
+
* relative tempRoot then lands under this dir instead of the repo's real
|
|
32
|
+
* `temp/` telemetry tree — the regression that let 99% of friction records
|
|
33
|
+
* be test-fixture pollution.
|
|
34
|
+
*
|
|
35
|
+
* Directly unit-tested via the injectable `mkdtemp` seam in
|
|
36
|
+
* `tests/lib/test-env.test.js` (Story #4711).
|
|
37
|
+
*
|
|
38
|
+
* @param {NodeJS.ProcessEnv} [baseEnv=process.env]
|
|
39
|
+
* @param {{ mkdtemp?: typeof mkdtempSync }} [deps] Injectable for tests.
|
|
40
|
+
* @returns {string} absolute scratch tempRoot
|
|
41
|
+
*/
|
|
42
|
+
export function ensureTestScratchTempRoot(
|
|
43
|
+
baseEnv = process.env,
|
|
44
|
+
{ mkdtemp = mkdtempSync } = {},
|
|
45
|
+
) {
|
|
46
|
+
const existing = baseEnv?.[TEST_TEMP_ROOT_ENV];
|
|
47
|
+
if (
|
|
48
|
+
typeof existing === 'string' &&
|
|
49
|
+
existing.length > 0 &&
|
|
50
|
+
path.isAbsolute(existing)
|
|
51
|
+
) {
|
|
52
|
+
return existing;
|
|
53
|
+
}
|
|
54
|
+
if (_createdScratchDir === null) {
|
|
55
|
+
_createdScratchDir = mkdtemp(path.join(os.tmpdir(), 'mandrel-test-temp-'));
|
|
56
|
+
}
|
|
57
|
+
return _createdScratchDir;
|
|
58
|
+
}
|
|
59
|
+
|
|
1
60
|
/**
|
|
2
61
|
* Build a webhook-safe child-process environment for test runners.
|
|
3
62
|
*
|
|
@@ -27,6 +86,11 @@
|
|
|
27
86
|
* covered by `cleanGitEnv` in `git-utils.js`; this is the same scrub
|
|
28
87
|
* for test child processes, which may spawn git directly. Tests that
|
|
29
88
|
* need a `GIT_*` variable set it explicitly on their own spawn.
|
|
89
|
+
* - `MANDREL_TEST_TEMP_ROOT` is set to an absolute per-process scratch
|
|
90
|
+
* dir (Story #4696). Any test that reaches a stream writer without
|
|
91
|
+
* injecting its own absolute tempRoot lands under scratch instead of
|
|
92
|
+
* the repo's real `temp/` telemetry tree, so the suite can no longer
|
|
93
|
+
* append fixture records to friction / lifecycle / trace streams.
|
|
30
94
|
*
|
|
31
95
|
* @param {NodeJS.ProcessEnv} baseEnv
|
|
32
96
|
* @returns {NodeJS.ProcessEnv}
|
|
@@ -39,5 +103,6 @@ export function buildWebhookSafeTestEnv(baseEnv = process.env) {
|
|
|
39
103
|
if (env.MANDREL_ALLOW_TEST_WEBHOOKS !== '1') {
|
|
40
104
|
delete env.NOTIFICATION_WEBHOOK_URL;
|
|
41
105
|
}
|
|
106
|
+
env[TEST_TEMP_ROOT_ENV] = ensureTestScratchTempRoot(baseEnv);
|
|
42
107
|
return env;
|
|
43
108
|
}
|
|
@@ -18,18 +18,21 @@
|
|
|
18
18
|
* Stories. Envelope carries `sourceTickets[]`.
|
|
19
19
|
*
|
|
20
20
|
* Flags:
|
|
21
|
-
* --out <path>
|
|
21
|
+
* --out <path> Write the envelope to <path> (parent dirs created).
|
|
22
22
|
* `/plan` points this at `<plan-dir>/plan-context.json`,
|
|
23
23
|
* which is where `plan-persist.js` auto-discovers the
|
|
24
24
|
* `--tickets` source ids from (Story #4554). Without a
|
|
25
25
|
* captured envelope persist cannot know a `--tickets` run
|
|
26
26
|
* happened, and superseding degrades to the
|
|
27
|
-
* `--source-tickets` flag.
|
|
28
|
-
*
|
|
27
|
+
* `--source-tickets` flag. With --out, stdout carries a
|
|
28
|
+
* compact digest naming the artifact instead of the full
|
|
29
|
+
* envelope (Story #4708 script-output contract).
|
|
30
|
+
* --pretty Pretty-print the JSON envelope (no-op with --out).
|
|
29
31
|
*
|
|
30
|
-
* stdout is reserved for
|
|
31
|
-
*
|
|
32
|
-
*
|
|
32
|
+
* stdout is reserved for a single JSON payload (Story #2278 discipline) —
|
|
33
|
+
* the envelope, or the digest when --out captures it:
|
|
34
|
+
* `routeAllOutputToStderr()` runs before any pipeline code so the stream
|
|
35
|
+
* is unconditionally parseable by `JSON.parse`.
|
|
33
36
|
*
|
|
34
37
|
* Exit codes:
|
|
35
38
|
* 0 — envelope emitted.
|
|
@@ -49,7 +52,11 @@ import {
|
|
|
49
52
|
validateOrchestrationConfig,
|
|
50
53
|
} from './lib/config-resolver.js';
|
|
51
54
|
import { Logger, routeAllOutputToStderr } from './lib/Logger.js';
|
|
52
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
buildPlanContext,
|
|
57
|
+
renderStoriesTemplate,
|
|
58
|
+
STORIES_TEMPLATE_FILENAME,
|
|
59
|
+
} from './lib/orchestration/plan-context.js';
|
|
53
60
|
import { recordPlanInvocation } from './lib/orchestration/plan-metrics.js';
|
|
54
61
|
import { createProvider } from './lib/provider-factory.js';
|
|
55
62
|
|
|
@@ -111,8 +118,31 @@ export async function emitPlanContext({
|
|
|
111
118
|
const json = pretty
|
|
112
119
|
? JSON.stringify(envelope, null, 2)
|
|
113
120
|
: JSON.stringify(envelope);
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
if (outPath) {
|
|
122
|
+
// Script-output contract (Story #4708, AC-5): the full envelope is a
|
|
123
|
+
// ~40KB artifact that would ride resident in the transcript for every
|
|
124
|
+
// later turn. When it is captured to disk anyway, stdout carries a
|
|
125
|
+
// compact digest naming the artifact instead of the payload itself.
|
|
126
|
+
await writeEnvelopeFile(outPath, json);
|
|
127
|
+
await writeStoriesTemplateFile(outPath);
|
|
128
|
+
const resolved = path.resolve(outPath);
|
|
129
|
+
const digest = {
|
|
130
|
+
digest: 'plan-context',
|
|
131
|
+
mode: envelope.mode,
|
|
132
|
+
out: resolved,
|
|
133
|
+
storiesTemplate: path.join(
|
|
134
|
+
path.dirname(resolved),
|
|
135
|
+
'stories.template.json',
|
|
136
|
+
),
|
|
137
|
+
bytes: Buffer.byteLength(json, 'utf8'),
|
|
138
|
+
sourceTickets: (envelope.sourceTickets ?? []).map((t) => t.id),
|
|
139
|
+
duplicates: (envelope.duplicates ?? []).length,
|
|
140
|
+
complexityRoute: envelope.complexityRoute?.route ?? null,
|
|
141
|
+
};
|
|
142
|
+
stdout.write(`${JSON.stringify(digest)}\n`);
|
|
143
|
+
} else {
|
|
144
|
+
stdout.write(`${json}\n`);
|
|
145
|
+
}
|
|
116
146
|
return envelope;
|
|
117
147
|
}
|
|
118
148
|
|
|
@@ -140,6 +170,33 @@ async function writeEnvelopeFile(outPath, json) {
|
|
|
140
170
|
Logger.info(`[plan-context] wrote envelope to ${resolved}`);
|
|
141
171
|
}
|
|
142
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Emit the ready-to-fill Story authoring template next to the captured
|
|
175
|
+
* envelope (Story #4707 — one-shot authoring). The planner copies it to
|
|
176
|
+
* `stories.json` and fills the placeholders; no step of the authoring path
|
|
177
|
+
* requires reading `story-body.js` source. Written whenever `--out` is
|
|
178
|
+
* passed, and throwing on failure for the same reason the envelope write
|
|
179
|
+
* does: a silently missing template re-opens the format-discovery loop it
|
|
180
|
+
* exists to close.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} outPath The envelope `--out` path; the template lands in
|
|
183
|
+
* the same directory as {@link STORIES_TEMPLATE_FILENAME}.
|
|
184
|
+
*/
|
|
185
|
+
async function writeStoriesTemplateFile(outPath) {
|
|
186
|
+
const resolved = path.resolve(
|
|
187
|
+
path.dirname(path.resolve(outPath)),
|
|
188
|
+
STORIES_TEMPLATE_FILENAME,
|
|
189
|
+
);
|
|
190
|
+
try {
|
|
191
|
+
await writeFile(resolved, renderStoriesTemplate(), 'utf8');
|
|
192
|
+
} catch (err) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`[plan-context] cannot write stories template to ${resolved}: ${err.message}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
Logger.info(`[plan-context] wrote ready-to-fill template to ${resolved}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
143
200
|
async function main() {
|
|
144
201
|
const { values } = parseArgs({
|
|
145
202
|
options: {
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* act on it — dispatching a fresh-context critic sub-agent and folding its
|
|
10
10
|
* findings into a re-author round **before** the plan is persisted.
|
|
11
11
|
*
|
|
12
|
+
* The pre-mortem's external-dependency arm (Story #4700) needs the repo's own
|
|
13
|
+
* manifests to tell an external scoped package from a local one, so this CLI
|
|
14
|
+
* reads them (`collectRepoPackages`) and passes the specifier set down — the
|
|
15
|
+
* pure evaluation modules never touch the filesystem.
|
|
16
|
+
*
|
|
12
17
|
* Why here and nowhere else. The evaluation used to run inside
|
|
13
18
|
* `run-plan-persist.js`, after authoring was finished and immediately before
|
|
14
19
|
* `createStoryIssues` — the one point in the flow where nothing can act on a
|
|
@@ -43,7 +48,7 @@
|
|
|
43
48
|
* Exit codes: 0 success (any verdict); 1 usage/IO error.
|
|
44
49
|
*/
|
|
45
50
|
|
|
46
|
-
import { readFile } from 'node:fs/promises';
|
|
51
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
47
52
|
import path from 'node:path';
|
|
48
53
|
import { parseArgs } from 'node:util';
|
|
49
54
|
|
|
@@ -63,6 +68,101 @@ const USAGE = 'Usage: plan-critics.js --stories <file> [--tech-spec <file>]';
|
|
|
63
68
|
/** The `cli` discriminator every ledger record from this surface carries. */
|
|
64
69
|
export const PLAN_CRITICS_CLI = 'plan-critics';
|
|
65
70
|
|
|
71
|
+
/** Dependency maps a manifest can declare a package under. */
|
|
72
|
+
const DEP_MAP_KEYS = [
|
|
73
|
+
'dependencies',
|
|
74
|
+
'devDependencies',
|
|
75
|
+
'optionalDependencies',
|
|
76
|
+
'peerDependencies',
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/** Add a manifest's own name and every declared dependency to `names`. */
|
|
80
|
+
function collectPackageIdentity(names, pkg) {
|
|
81
|
+
if (typeof pkg?.name === 'string') names.add(pkg.name);
|
|
82
|
+
for (const key of DEP_MAP_KEYS) {
|
|
83
|
+
const map = pkg?.[key];
|
|
84
|
+
if (map && typeof map === 'object') {
|
|
85
|
+
for (const dep of Object.keys(map)) names.add(dep);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Best-effort JSON read: a missing or malformed file yields `null`. */
|
|
91
|
+
async function readJsonIfPresent(filePath) {
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(await readFile(filePath, 'utf8'));
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Normalize the `workspaces` field (array or `{ packages: [] }`) to a list. */
|
|
100
|
+
function workspacePatterns(pkg) {
|
|
101
|
+
const ws = pkg?.workspaces;
|
|
102
|
+
if (Array.isArray(ws)) return ws;
|
|
103
|
+
if (Array.isArray(ws?.packages)) return ws.packages;
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve `workspaces` patterns to child `package.json` paths. Handles the two
|
|
109
|
+
* common shapes — a `dir/*` glob (expanded one level) and a literal path — and
|
|
110
|
+
* never throws: an unreadable base directory is skipped.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} rootDir
|
|
113
|
+
* @param {string[]} patterns
|
|
114
|
+
* @returns {Promise<string[]>}
|
|
115
|
+
*/
|
|
116
|
+
async function resolveWorkspaceManifestPaths(rootDir, patterns) {
|
|
117
|
+
const paths = [];
|
|
118
|
+
for (const pattern of patterns) {
|
|
119
|
+
if (typeof pattern !== 'string') continue;
|
|
120
|
+
if (!pattern.endsWith('/*')) {
|
|
121
|
+
paths.push(path.resolve(rootDir, pattern, 'package.json'));
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const base = path.resolve(rootDir, pattern.slice(0, -2));
|
|
125
|
+
let entries;
|
|
126
|
+
try {
|
|
127
|
+
entries = await readdir(base, { withFileTypes: true });
|
|
128
|
+
} catch {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
if (entry.isDirectory()) {
|
|
133
|
+
paths.push(path.join(base, entry.name, 'package.json'));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return paths;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Gather the package specifiers the repo's own manifests declare — the set the
|
|
142
|
+
* pre-mortem external-dependency probe (Story #4700) measures a scoped-package
|
|
143
|
+
* reference against. Includes the root manifest's own name and dependency maps
|
|
144
|
+
* plus every workspace manifest's. Best-effort: a repo with no `package.json`
|
|
145
|
+
* yields `[]`, which only widens what the probe treats as external.
|
|
146
|
+
*
|
|
147
|
+
* @param {{ rootDir?: string }} [opts]
|
|
148
|
+
* @returns {Promise<string[]>}
|
|
149
|
+
*/
|
|
150
|
+
export async function collectRepoPackages({ rootDir = process.cwd() } = {}) {
|
|
151
|
+
const root = await readJsonIfPresent(path.join(rootDir, 'package.json'));
|
|
152
|
+
if (!root) return [];
|
|
153
|
+
const names = new Set();
|
|
154
|
+
collectPackageIdentity(names, root);
|
|
155
|
+
const wsPaths = await resolveWorkspaceManifestPaths(
|
|
156
|
+
rootDir,
|
|
157
|
+
workspacePatterns(root),
|
|
158
|
+
);
|
|
159
|
+
for (const wsPath of wsPaths) {
|
|
160
|
+
const pkg = await readJsonIfPresent(wsPath);
|
|
161
|
+
if (pkg) collectPackageIdentity(names, pkg);
|
|
162
|
+
}
|
|
163
|
+
return [...names];
|
|
164
|
+
}
|
|
165
|
+
|
|
66
166
|
/**
|
|
67
167
|
* Read the draft artifacts the critics evaluate.
|
|
68
168
|
*
|
|
@@ -155,21 +255,32 @@ export async function recordCriticSkips(
|
|
|
155
255
|
* storiesPath: string,
|
|
156
256
|
* techSpecPath?: string|null,
|
|
157
257
|
* config?: object,
|
|
258
|
+
* knownPackages?: string[],
|
|
158
259
|
* append?: typeof appendCriticSkip,
|
|
159
260
|
* }} args
|
|
261
|
+
* @param {string[]} [args.knownPackages] - Package specifiers the repo's own
|
|
262
|
+
* manifests declare, forwarded to the pre-mortem external-dependency probe
|
|
263
|
+
* (Story #4700). `main()` resolves them via `collectRepoPackages`; tests may
|
|
264
|
+
* pass an explicit set or omit it (defaults to `[]`).
|
|
160
265
|
* @returns {Promise<{ consolidation: object, premortem: object, textHygiene: object }>}
|
|
161
266
|
*/
|
|
162
267
|
export async function evaluateCriticArtifacts({
|
|
163
268
|
storiesPath,
|
|
164
269
|
techSpecPath = null,
|
|
165
270
|
config = {},
|
|
271
|
+
knownPackages = [],
|
|
166
272
|
append = appendCriticSkip,
|
|
167
273
|
}) {
|
|
168
274
|
const { tickets, techSpecContent } = await loadCriticArtifacts({
|
|
169
275
|
storiesPath,
|
|
170
276
|
techSpecPath,
|
|
171
277
|
});
|
|
172
|
-
const verdict = evaluatePlanCritics({
|
|
278
|
+
const verdict = evaluatePlanCritics({
|
|
279
|
+
techSpecContent,
|
|
280
|
+
tickets,
|
|
281
|
+
config,
|
|
282
|
+
knownPackages,
|
|
283
|
+
});
|
|
173
284
|
await recordCriticSkips(verdict, config, { append });
|
|
174
285
|
return verdict;
|
|
175
286
|
}
|
|
@@ -191,9 +302,10 @@ async function main() {
|
|
|
191
302
|
? path.resolve(values['tech-spec'])
|
|
192
303
|
: null,
|
|
193
304
|
config: resolveConfig(),
|
|
305
|
+
knownPackages: await collectRepoPackages(),
|
|
194
306
|
});
|
|
195
307
|
|
|
196
|
-
process.stdout.write(`${JSON.stringify(verdict
|
|
308
|
+
process.stdout.write(`${JSON.stringify(verdict)}\n`);
|
|
197
309
|
return 0;
|
|
198
310
|
}
|
|
199
311
|
|
|
@@ -34,6 +34,13 @@
|
|
|
34
34
|
* ids, for hand-driven runs. Each id must be
|
|
35
35
|
* claimed by exactly one Story's `supersedes[]`;
|
|
36
36
|
* they are commented on and closed as superseded
|
|
37
|
+
* --route-downgrade-reason <text>
|
|
38
|
+
* Audited planner downgrade (Story #4707): treat
|
|
39
|
+
* the envelope's `full` complexity verdict as
|
|
40
|
+
* `lite`, recording <text> on every Story's
|
|
41
|
+
* story-plan-state checkpoint. Absent this flag
|
|
42
|
+
* the deterministic verdict stands; the gate
|
|
43
|
+
* itself still fails toward `full`
|
|
37
44
|
* --no-close-superseded Keep the source tickets open (no comment, no
|
|
38
45
|
* close) — for a genuinely partial supersede
|
|
39
46
|
* --dry-run Assemble + validate without GitHub writes
|
|
@@ -100,6 +107,7 @@ const CLI_OPTIONS = {
|
|
|
100
107
|
'plan-context': { type: 'string' },
|
|
101
108
|
'plan-acceptance': { type: 'string' },
|
|
102
109
|
'source-tickets': { type: 'string' },
|
|
110
|
+
'route-downgrade-reason': { type: 'string' },
|
|
103
111
|
'close-superseded': { type: 'boolean', default: true },
|
|
104
112
|
'no-close-superseded': { type: 'boolean', default: false },
|
|
105
113
|
'dry-run': { type: 'boolean', default: false },
|
|
@@ -113,6 +121,7 @@ const USAGE =
|
|
|
113
121
|
'[--tech-spec <file>] [--plan-dir <dir>] [--plan-context <file>] ' +
|
|
114
122
|
'[--plan-acceptance <file>] ' +
|
|
115
123
|
'[--source-tickets <ids>] [--no-close-superseded] ' +
|
|
124
|
+
'[--route-downgrade-reason <text>] ' +
|
|
116
125
|
'[--dry-run] [--force-review] ' +
|
|
117
126
|
'[--allow-over-budget] [--allow-large-fan-out]';
|
|
118
127
|
|
|
@@ -204,6 +213,7 @@ export function buildPersistOptions(values, paths, planContextEnvelope) {
|
|
|
204
213
|
skipCleanup: values['dry-run'],
|
|
205
214
|
sourceTicketIds: source.ids,
|
|
206
215
|
sourceTicketOrigin: source.origin,
|
|
216
|
+
routeDowngradeReason: values['route-downgrade-reason'] ?? null,
|
|
207
217
|
// Default-on: `--no-close-superseded` is the explicit escape and always
|
|
208
218
|
// wins over the (default `true`) `--close-superseded`.
|
|
209
219
|
closeSuperseded:
|
|
@@ -328,7 +338,7 @@ async function main() {
|
|
|
328
338
|
|
|
329
339
|
await attachPlanMetrics(result, config, metricsSince);
|
|
330
340
|
|
|
331
|
-
process.stdout.write(`${JSON.stringify(result
|
|
341
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
332
342
|
}
|
|
333
343
|
|
|
334
344
|
runAsCli(import.meta.url, main, { source: 'plan-persist' });
|
|
@@ -67,7 +67,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
67
67
|
});
|
|
68
68
|
warnOnUnresolvedBase(result);
|
|
69
69
|
warnOnEmptyRollup(result);
|
|
70
|
-
Logger.info(JSON.stringify(result
|
|
70
|
+
Logger.info(JSON.stringify(result));
|
|
71
71
|
if (result.errors?.length) {
|
|
72
72
|
process.exitCode = 1;
|
|
73
73
|
}
|
|
@@ -15,16 +15,38 @@
|
|
|
15
15
|
* @see Story #2462 — Split GitHubProvider god class into seven composed gateways.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { GhRateLimitError } from '../../lib/gh-exec.js';
|
|
18
19
|
import { Logger } from '../../lib/Logger.js';
|
|
19
20
|
import { concurrentMap } from '../../lib/util/concurrent-map.js';
|
|
20
21
|
import { isNotFoundError } from './branch-protection.js';
|
|
21
|
-
import { withTransientRetry } from './errors.js';
|
|
22
|
+
import { classifyGithubError, withTransientRetry } from './errors.js';
|
|
22
23
|
import { issueToEpic } from './mappers.js';
|
|
23
24
|
import {
|
|
24
25
|
defaultRetryWarn,
|
|
25
26
|
paginateRest,
|
|
26
27
|
parseApiJson,
|
|
27
28
|
} from './request-helpers.js';
|
|
29
|
+
import {
|
|
30
|
+
searchBudget as defaultSearchBudget,
|
|
31
|
+
parseRateLimitResetMs,
|
|
32
|
+
} from './search-budget.js';
|
|
33
|
+
import { composeBoundedQuery } from './search-query.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Retry classifier for the `searchIssues` call site. A rate-limit error is
|
|
37
|
+
* non-transient **here** — the shared search budget (not `withTransientRetry`)
|
|
38
|
+
* owns the wait, so the call must fail fast rather than burn its retry budget
|
|
39
|
+
* re-issuing into an empty window. Every other error keeps the global
|
|
40
|
+
* classification, so a 5xx/network blip still retries. `classifyGithubError`
|
|
41
|
+
* itself is unchanged: other endpoints legitimately retry on rate-limit.
|
|
42
|
+
*
|
|
43
|
+
* @param {unknown} err
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function classifySearchRetry(err) {
|
|
47
|
+
if (err instanceof GhRateLimitError) return 'rate-limited';
|
|
48
|
+
return classifyGithubError(err);
|
|
49
|
+
}
|
|
28
50
|
|
|
29
51
|
/**
|
|
30
52
|
* Concurrency budget for the `getSubTickets` fan-out — preserved from
|
|
@@ -51,11 +73,15 @@ export class IssuesGateway {
|
|
|
51
73
|
* },
|
|
52
74
|
* }} deps
|
|
53
75
|
*/
|
|
54
|
-
constructor({ gh, owner, repo, hooks = {} } = {}) {
|
|
76
|
+
constructor({ gh, owner, repo, hooks = {}, searchBudget } = {}) {
|
|
55
77
|
this._gh = gh;
|
|
56
78
|
this.owner = owner;
|
|
57
79
|
this.repo = repo;
|
|
58
80
|
this._hooks = hooks;
|
|
81
|
+
// The `/search/issues` fan-out budget. Defaults to the process-wide
|
|
82
|
+
// singleton so every gateway instance shares one 30/min window; injectable
|
|
83
|
+
// so unit tests drive it deterministically (Story #4678).
|
|
84
|
+
this._searchBudget = searchBudget ?? defaultSearchBudget;
|
|
59
85
|
}
|
|
60
86
|
|
|
61
87
|
/**
|
|
@@ -132,14 +158,35 @@ export class IssuesGateway {
|
|
|
132
158
|
// Constrain the search to this repo and to issues (not PRs). The
|
|
133
159
|
// fingerprint sha is the free-text term; GitHub matches it against the
|
|
134
160
|
// issue body where the `<!-- audit-fingerprints: ... -->` footer lives.
|
|
161
|
+
// The composed `q` is bounded to GitHub Search's 256-char limit here — the
|
|
162
|
+
// only place that knows both the free text and the qualifiers it appends —
|
|
163
|
+
// truncating the free-text portion on a whole-token boundary (Story #4678).
|
|
135
164
|
const qualifiers = [`repo:${scopeOwner}/${scopeRepo}`, 'type:issue'];
|
|
136
|
-
const q =
|
|
165
|
+
const q = composeBoundedQuery(query, qualifiers);
|
|
137
166
|
const params = new URLSearchParams({ q, per_page: '100' });
|
|
138
167
|
const endpoint = `/search/issues?${params}`;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
168
|
+
// Await the shared 30/min budget before every call so the whole scan's
|
|
169
|
+
// fan-out is throttled at the endpoint, not per caller (Story #4678).
|
|
170
|
+
await this._searchBudget.take();
|
|
171
|
+
let result;
|
|
172
|
+
try {
|
|
173
|
+
result = await withTransientRetry(
|
|
174
|
+
() => this._gh.api({ method: 'GET', endpoint }),
|
|
175
|
+
{
|
|
176
|
+
label: `searchIssues ${query}`,
|
|
177
|
+
onRetry: defaultRetryWarn,
|
|
178
|
+
classify: classifySearchRetry,
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
// A rate limit means the window is empty: drain the budget until the
|
|
183
|
+
// reported reset so the *next* call pauses once, rather than every call
|
|
184
|
+
// retrying independently into the exhausted window.
|
|
185
|
+
if (err instanceof GhRateLimitError) {
|
|
186
|
+
this._searchBudget.noteRateLimited(parseRateLimitResetMs(err));
|
|
187
|
+
}
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
143
190
|
const json = parseApiJson(result);
|
|
144
191
|
const items = Array.isArray(json?.items) ? json.items : [];
|
|
145
192
|
return items.map((item) => ({
|