mandrel 2.9.0 → 2.11.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.
Files changed (75) hide show
  1. package/.agents/agents/.markdownlint.json +4 -0
  2. package/.agents/agents/acceptance-critic.md +30 -5
  3. package/.agents/agents/auditor.md +36 -19
  4. package/.agents/agents/plan-critic.md +31 -5
  5. package/.agents/agents/story-worker.md +91 -100
  6. package/.agents/docs/configuration.md +39 -25
  7. package/.agents/docs/execution-reference.md +13 -0
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +131 -265
  10. package/.agents/rules/git-conventions.md +47 -83
  11. package/.agents/rules/orchestration-error-handling.md +28 -0
  12. package/.agents/schemas/agentrc.schema.json +36 -9
  13. package/.agents/schemas/validation-evidence.schema.json +3 -1
  14. package/.agents/scripts/acceptance-eval.js +10 -6
  15. package/.agents/scripts/apply-quality-bootstrap.js +1 -1
  16. package/.agents/scripts/check-test-temp-hygiene.js +438 -0
  17. package/.agents/scripts/deliver-recover.js +23 -6
  18. package/.agents/scripts/lib/audit-suite/index.js +5 -0
  19. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
  20. package/.agents/scripts/lib/audit-suite/selector.js +1 -1
  21. package/.agents/scripts/lib/baselines/env-overrides.js +33 -0
  22. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  23. package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
  24. package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
  25. package/.agents/scripts/lib/config/quality.js +13 -0
  26. package/.agents/scripts/lib/config/temp-paths.js +121 -1
  27. package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
  28. package/.agents/scripts/lib/config-settings-schema.js +12 -16
  29. package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
  30. package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
  31. package/.agents/scripts/lib/orchestration/ceremony-routing.js +45 -0
  32. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
  33. package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
  34. package/.agents/scripts/lib/orchestration/complexity-gate.js +533 -93
  35. package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
  36. package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
  37. package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
  38. package/.agents/scripts/lib/orchestration/plan-context.js +119 -3
  39. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
  40. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
  41. package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
  42. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +153 -2
  43. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +221 -14
  44. package/.agents/scripts/lib/orchestration/resolve-stories.js +12 -1
  45. package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
  46. package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
  48. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
  49. package/.agents/scripts/lib/orchestration/spec-budget.js +78 -0
  50. package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
  51. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
  52. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
  53. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +6 -0
  54. package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
  55. package/.agents/scripts/lib/templates/decomposer-prompts.js +13 -6
  56. package/.agents/scripts/lib/test-env.js +65 -0
  57. package/.agents/scripts/plan-context.js +84 -9
  58. package/.agents/scripts/plan-critics.js +115 -3
  59. package/.agents/scripts/plan-persist.js +11 -1
  60. package/.agents/scripts/plan-run-epilogue.js +1 -1
  61. package/.agents/scripts/resolve-stories.js +2 -0
  62. package/.agents/scripts/single-story-confirm-merge.js +65 -5
  63. package/.agents/scripts/stories-wave-tick.js +1 -1
  64. package/.agents/workflows/deliver.md +88 -230
  65. package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
  66. package/.agents/workflows/helpers/deliver-reference.md +171 -0
  67. package/.agents/workflows/helpers/deliver-story-reference.md +223 -0
  68. package/.agents/workflows/helpers/deliver-story.md +115 -432
  69. package/.agents/workflows/helpers/plan-reference.md +246 -0
  70. package/.agents/workflows/plan.md +108 -304
  71. package/docs/CHANGELOG.md +46 -0
  72. package/lib/cli/registry.js +31 -14
  73. package/lib/migrations/index.js +2 -0
  74. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -0
  75. package/package.json +1 -1
@@ -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({ techSpecContent, tickets, config });
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, null, 2)}\n`);
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, null, 2)}\n`);
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, null, 2));
70
+ Logger.info(JSON.stringify(result));
71
71
  if (result.errors?.length) {
72
72
  process.exitCode = 1;
73
73
  }
@@ -209,6 +209,7 @@ async function main() {
209
209
  stories,
210
210
  nativeEdges,
211
211
  warn: (m) => Logger.warn(m),
212
+ config,
212
213
  });
213
214
  const foreignDone = await resolveForeignDone({
214
215
  provider,
@@ -220,6 +221,7 @@ async function main() {
220
221
  nativeEdges,
221
222
  foreignDone,
222
223
  warn: () => {},
224
+ config,
223
225
  });
224
226
 
225
227
  process.stdout.write(
@@ -27,7 +27,8 @@
27
27
  * short-circuits to a `noop` envelope.
28
28
  *
29
29
  * Usage:
30
- * node single-story-confirm-merge.js --story <STORY_ID> [--pr <n>]
30
+ * node single-story-confirm-merge.js --story <STORY_ID> [--pr <n>] [--wait]
31
+ * [--max-wait-seconds <n>]
31
32
  * [--cwd <main-repo>]
32
33
  *
33
34
  * Exit codes: 0 ok (merged, pending, or noop), 1 error.
@@ -40,12 +41,13 @@ import { parseSprintArgs } from './lib/cli-args.js';
40
41
  import { runAsCli } from './lib/cli-utils.js';
41
42
  import { resolveConfig } from './lib/config-resolver.js';
42
43
  import { formatCliError } from './lib/error-redactor.js';
43
- import { gh as defaultGh } from './lib/gh-exec.js';
44
+ import { createGh } from './lib/gh-exec.js';
44
45
  import { getStoryBranch } from './lib/git-utils.js';
45
46
  import { Logger } from './lib/Logger.js';
46
47
  import { emitTerminalFriction } from './lib/observability/runtime-friction.js';
47
48
  import { emitTerseResult } from './lib/observability/terse-result.js';
48
49
  import { MERGED_FLIP_FAILED_BLOCK_CLASS } from './lib/orchestration/lifecycle/emit-merge-flip-failed.js';
50
+ import { MERGE_WAIT_GH_TIMEOUT_MS } from './lib/orchestration/merge-poll.js';
49
51
  import { parsePrNumber } from './lib/orchestration/single-story-close/phases/code-review.js';
50
52
  import { runConfirmMergePhase as defaultRunConfirmMergePhase } from './lib/orchestration/single-story-close/phases/confirm-merge.js';
51
53
  import { parseCloseOptions } from './lib/orchestration/single-story-close/phases/options.js';
@@ -64,6 +66,25 @@ const progress = Logger.createProgress('single-story-confirm-merge', {
64
66
  stderr: true,
65
67
  });
66
68
 
69
+ /**
70
+ * Default `gh` facade for this CLI, bound to the merge wait's spawn-level
71
+ * timeout (Story #4710). This CLI is the resume surface async mode hands the
72
+ * merge wait to — a background invocation with no host tool ceiling — so an
73
+ * un-timeboxed `gh pr list` / `gh pr view` here could strand the resume the
74
+ * same way an un-timeboxed probe stranded the in-close wait.
75
+ */
76
+ const defaultGh = createGh(undefined, { timeoutMs: MERGE_WAIT_GH_TIMEOUT_MS });
77
+
78
+ /** One usage string for the throw path and `--help` (Story #4710). */
79
+ const USAGE =
80
+ 'Usage: node single-story-confirm-merge.js --story <STORY_ID> [--pr <n>] [--wait] ' +
81
+ '[--max-wait-seconds <n>] [--cwd <main-repo>]\n\n' +
82
+ ' --wait resume the bounded merge wait instead of probing once\n' +
83
+ ' --max-wait-seconds per-invocation wait bound override, threaded to\n' +
84
+ ' resolveMergeWaitConfig exactly as the close does (wins\n' +
85
+ ' over delivery.mergeWatch.maxWaitSeconds and the async\n' +
86
+ ' probe-window cap; only meaningful with --wait)';
87
+
67
88
  /**
68
89
  * Read the `--pr <n>` flag from `process.argv` for the direct-CLI path.
69
90
  * Injection callers pass `pr` directly and never reach this. Returns the
@@ -106,6 +127,33 @@ function readWaitFlag() {
106
127
  }
107
128
  }
108
129
 
130
+ /**
131
+ * `--max-wait-seconds <n>`: per-invocation wait-bound override for the
132
+ * `--wait` resume path (Story #4710). The close CLI already accepted this
133
+ * flag, but the resume CLI — the exact command async mode's `pending`
134
+ * terminal hands off to — did not, so the documented per-run override was
135
+ * unreachable where it mattered most and a slow-CI landing depended on an
136
+ * unbounded chain of short invocations. Threaded to `runConfirmMergePhase`
137
+ * (and thence `resolveMergeWaitConfig`) exactly as close threads its own
138
+ * flag. Returns `undefined` when absent or not a positive integer — the
139
+ * phase's config/default resolution owns that case.
140
+ *
141
+ * @returns {number|undefined}
142
+ */
143
+ function readMaxWaitSecondsFlag() {
144
+ try {
145
+ const { values } = parseArgs({
146
+ args: process.argv.slice(2),
147
+ options: { 'max-wait-seconds': { type: 'string' } },
148
+ strict: false,
149
+ });
150
+ const parsed = Number.parseInt(String(values['max-wait-seconds']), 10);
151
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
152
+ } catch {
153
+ return undefined;
154
+ }
155
+ }
156
+
109
157
  /**
110
158
  * Resolve the PR number for the Story branch when one was not passed on
111
159
  * the CLI. Probes `gh pr list --head <branch> --state all` (the merged PR
@@ -262,6 +310,7 @@ export async function runConfirmMerge({
262
310
  cwd: cwdParam,
263
311
  pr: prParam,
264
312
  wait: waitParam,
313
+ maxWaitSeconds: maxWaitSecondsParam,
265
314
  injectedProvider,
266
315
  injectedConfig,
267
316
  injectedGh,
@@ -275,11 +324,10 @@ export async function runConfirmMerge({
275
324
  });
276
325
 
277
326
  if (!storyId) {
278
- throw new Error(
279
- 'Usage: node single-story-confirm-merge.js --story <STORY_ID> [--pr <n>] [--wait] [--cwd <main-repo>]',
280
- );
327
+ throw new Error(USAGE);
281
328
  }
282
329
  const wait = waitParam ?? readWaitFlag();
330
+ const maxWaitSeconds = maxWaitSecondsParam ?? readMaxWaitSecondsFlag();
283
331
 
284
332
  const startedAtMs = Date.now();
285
333
  const config = injectedConfig || resolveConfig({ cwd });
@@ -346,6 +394,10 @@ export async function runConfirmMerge({
346
394
  // The close already armed it; this CLI is resuming that wait, not
347
395
  // deciding whether to arm.
348
396
  autoMergeEnabled: true,
397
+ // The per-run override (`--max-wait-seconds`), resolved by
398
+ // `resolveMergeWaitConfig` exactly as the close resolves its own flag —
399
+ // it wins over the config value and the async probe-window cap.
400
+ maxWaitSeconds,
349
401
  provider,
350
402
  config,
351
403
  progress,
@@ -450,6 +502,14 @@ export async function runConfirmMerge({
450
502
  * or none at all; "none at all" is what Story #4543 removes.
451
503
  */
452
504
  async function main() {
505
+ if (process.argv.includes('--help')) {
506
+ // Print usage (including --max-wait-seconds) and exit cleanly — the
507
+ // resume-path override is only discoverable if the CLI can say it exists.
508
+ // `process.stdout.write` (not console.log) keeps the CLI within the
509
+ // no-console repo invariant while still writing help to stdout.
510
+ process.stdout.write(`${USAGE}\n`);
511
+ return 0;
512
+ }
453
513
  try {
454
514
  const outcome = await runConfirmMerge();
455
515
  return exitCodeForTerminal(outcome?.terminal ?? { status: 'failed' });
@@ -862,7 +862,7 @@ async function main(argv) {
862
862
  inFlight: values['in-flight'],
863
863
  });
864
864
 
865
- process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
865
+ process.stdout.write(`${JSON.stringify(envelope)}\n`);
866
866
 
867
867
  if (exitCode !== 0) {
868
868
  Logger.error(