mandrel 2.21.0 → 2.22.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 (38) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/agents/story-worker.md +5 -0
  3. package/.agents/instructions.md +14 -17
  4. package/.agents/rules/git-conventions.md +1 -1
  5. package/.agents/rules/known-tooling-behavior.md +114 -0
  6. package/.agents/scripts/check-context-budget.js +134 -2
  7. package/.agents/scripts/lib/audit-suite/selector.js +275 -162
  8. package/.agents/scripts/lib/config/temp-paths.js +51 -7
  9. package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
  10. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
  11. package/.agents/scripts/lib/label-constants.js +12 -1
  12. package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
  13. package/.agents/scripts/lib/observability/signals-writer.js +133 -14
  14. package/.agents/scripts/lib/observability/source-classifier.js +131 -1
  15. package/.agents/scripts/lib/orchestration/code-review.js +12 -0
  16. package/.agents/scripts/lib/orchestration/complexity-gate.js +51 -46
  17. package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
  18. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  19. package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
  20. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
  21. package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
  22. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
  23. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
  24. package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
  25. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
  26. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
  27. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
  28. package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
  29. package/.agents/scripts/lib/story-body/story-body.js +248 -174
  30. package/.agents/scripts/resolve-stories.js +52 -33
  31. package/.agents/scripts/single-story-confirm-merge.js +5 -7
  32. package/.agents/workflows/helpers/deliver-digest.md +8 -6
  33. package/.agents/workflows/helpers/deliver-reference.md +15 -12
  34. package/.agents/workflows/helpers/deliver-story-reference.md +23 -21
  35. package/.agents/workflows/helpers/deliver-story.md +2 -2
  36. package/.agents/workflows/helpers/plan-reference.md +5 -4
  37. package/docs/CHANGELOG.md +20 -0
  38. package/package.json +1 -1
@@ -203,66 +203,95 @@ const WIDE_MARKER_LINE_RE = /^>\s*\*\*Wide:\*\*/;
203
203
  function parsePathEntry(raw, warnings) {
204
204
  // Already a structured object (from a parsed JSON body, not markdown).
205
205
  if (raw !== null && typeof raw === 'object') {
206
- if (
207
- typeof raw.path === 'string' &&
208
- raw.path.trim().length > 0 &&
209
- FILE_ASSUMPTION_VALUES.includes(raw.assumption)
210
- ) {
211
- return { path: raw.path.trim(), assumption: raw.assumption };
212
- }
213
- // Malformed object: fail closed.
214
- throw new StoryBodyParseError(
215
- `changes/references entry is an object but not a valid PathEntry: ${JSON.stringify(raw)}`,
216
- { field: 'changes', raw: JSON.stringify(raw) },
217
- );
206
+ return pathEntryFromObject(raw);
218
207
  }
219
208
 
220
209
  const str = typeof raw === 'string' ? raw.trim() : String(raw).trim();
221
210
  if (str.length === 0) return null;
222
211
 
223
- // Humanized bullet shape (the canonical serialize() output since
224
- // Story #4600): `path` — assumption.
225
- const humanized = str.match(HUMANIZED_PATH_ENTRY_RE);
226
- if (humanized) {
227
- const path = humanized[1].trim();
228
- if (path.length > 0 && FILE_ASSUMPTION_VALUES.includes(humanized[2])) {
229
- return { path, assumption: humanized[2] };
230
- }
231
- // Recognized the humanized shape but the fields are invalid: fail closed.
232
- throw new StoryBodyParseError(
233
- `changes/references entry is a humanized bullet but not a valid PathEntry: ${str.slice(0, 120)}${pathEntryFixIt(str)}`,
234
- { field: 'changes', raw: str },
235
- );
212
+ const entry = pathEntryFromHumanized(str) ?? pathEntryFromInlineJson(str);
213
+ if (entry) return entry;
214
+
215
+ throw new StoryBodyParseError(
216
+ `changes/references entry must be a { path, assumption } object; plain string bullets are no longer accepted: ${str.slice(0, 120)}${pathEntryFixIt(str)}`,
217
+ { field: 'changes', raw: str },
218
+ );
219
+ }
220
+
221
+ /**
222
+ * Validate an already-structured `{ path, assumption }` object. Fails closed
223
+ * on a malformed object.
224
+ *
225
+ * @param {object} raw
226
+ * @returns {PathEntry}
227
+ */
228
+ function pathEntryFromObject(raw) {
229
+ if (
230
+ typeof raw.path === 'string' &&
231
+ raw.path.trim().length > 0 &&
232
+ FILE_ASSUMPTION_VALUES.includes(raw.assumption)
233
+ ) {
234
+ return { path: raw.path.trim(), assumption: raw.assumption };
236
235
  }
236
+ // Malformed object: fail closed.
237
+ throw new StoryBodyParseError(
238
+ `changes/references entry is an object but not a valid PathEntry: ${JSON.stringify(raw)}`,
239
+ { field: 'changes', raw: JSON.stringify(raw) },
240
+ );
241
+ }
237
242
 
238
- // Try to detect inline JSON object shape: `{ "path": "...", "assumption": "..." }`
239
- if (str.startsWith('{')) {
240
- try {
241
- const parsed = JSON.parse(str);
242
- // It's a JSON object — treat it as a path entry.
243
- // If the path is missing or assumption is invalid, fail closed.
244
- if (typeof parsed === 'object' && parsed !== null) {
245
- if (
246
- typeof parsed.path === 'string' &&
247
- FILE_ASSUMPTION_VALUES.includes(parsed.assumption)
248
- ) {
249
- return { path: parsed.path.trim(), assumption: parsed.assumption };
250
- }
251
- // Parsed successfully as JSON object but has invalid fields — fail closed.
252
- throw new StoryBodyParseError(
253
- `changes/references entry is a JSON object but not a valid PathEntry: ${str}`,
254
- { field: 'changes', raw: str },
255
- );
256
- }
257
- } catch (err) {
258
- // Re-throw StoryBodyParseError so it propagates.
259
- if (err instanceof StoryBodyParseError) throw err;
260
- // JSON parse failed — fall through to reject plain-string form.
261
- }
243
+ /**
244
+ * Parse the humanized bullet shape (the canonical serialize() output since
245
+ * Story #4600): `` `path` — assumption ``. Returns `null` when the line is
246
+ * not that shape at all; fails closed when the shape is recognized but the
247
+ * fields are invalid.
248
+ *
249
+ * @param {string} str
250
+ * @returns {PathEntry|null}
251
+ */
252
+ function pathEntryFromHumanized(str) {
253
+ const humanized = str.match(HUMANIZED_PATH_ENTRY_RE);
254
+ if (!humanized) return null;
255
+ const path = humanized[1].trim();
256
+ if (path.length > 0 && FILE_ASSUMPTION_VALUES.includes(humanized[2])) {
257
+ return { path, assumption: humanized[2] };
262
258
  }
259
+ // Recognized the humanized shape but the fields are invalid: fail closed.
260
+ throw new StoryBodyParseError(
261
+ `changes/references entry is a humanized bullet but not a valid PathEntry: ${str.slice(0, 120)}${pathEntryFixIt(str)}`,
262
+ { field: 'changes', raw: str },
263
+ );
264
+ }
263
265
 
266
+ /**
267
+ * Parse the legacy inline-JSON object bullet:
268
+ * `{ "path": "...", "assumption": "..." }`. Returns `null` when the line is
269
+ * not a JSON object at all (including a JSON parse failure — the caller then
270
+ * rejects the plain-string form); fails closed when it parses to an object
271
+ * without valid PathEntry fields.
272
+ *
273
+ * @param {string} str
274
+ * @returns {PathEntry|null}
275
+ */
276
+ function pathEntryFromInlineJson(str) {
277
+ if (!str.startsWith('{')) return null;
278
+ let parsed;
279
+ try {
280
+ parsed = JSON.parse(str);
281
+ } catch {
282
+ // JSON parse failed — the caller rejects the plain-string form.
283
+ return null;
284
+ }
285
+ if (typeof parsed !== 'object' || parsed === null) return null;
286
+ if (
287
+ typeof parsed.path === 'string' &&
288
+ FILE_ASSUMPTION_VALUES.includes(parsed.assumption)
289
+ ) {
290
+ return { path: parsed.path.trim(), assumption: parsed.assumption };
291
+ }
292
+ // Parsed successfully as JSON object but has invalid fields — fail closed.
264
293
  throw new StoryBodyParseError(
265
- `changes/references entry must be a { path, assumption } object; plain string bullets are no longer accepted: ${str.slice(0, 120)}${pathEntryFixIt(str)}`,
294
+ `changes/references entry is a JSON object but not a valid PathEntry: ${str}`,
266
295
  { field: 'changes', raw: str },
267
296
  );
268
297
  }
@@ -420,13 +449,9 @@ function splitSections(markdown) {
420
449
  for (let i = 0; i < lines.length; i++) {
421
450
  const line = lines[i];
422
451
 
423
- // Detect footer separator: `---` on its own line
424
- if (/^---\s*$/.test(line)) {
425
- const remaining = lines.slice(i + 1).join('\n');
426
- if (/^(parent:|Epic:|blocked by)/im.test(remaining)) {
427
- footerStart = i;
428
- break;
429
- }
452
+ if (isFooterSeparator(line, lines, i)) {
453
+ footerStart = i;
454
+ break;
430
455
  }
431
456
 
432
457
  // Detect `## Heading` (canonical) or `### Heading` lines. GitHub Issue
@@ -440,7 +465,7 @@ function splitSections(markdown) {
440
465
  // `-` folded to `_`) before the HEADING_TO_FIELD lookup, so `Non-Goals`
441
466
  // resolves to the `non_goals` field. Multi-word headings that contain a
442
467
  // space (`## Out of Scope`, `## Agent Prompts`) still do NOT match this
443
- // single-token shape — they fall through to the catch-all heading branch
468
+ // single-token shape — they fall through to the section-terminator branch
444
469
  // below, which closes the open section. The chosen canonical spelling is
445
470
  // therefore the hyphenated single token `## Non-Goals`.
446
471
  const fieldHeadingMatch = line.match(/^#{2,3}\s+([\w-]+)\s*$/i);
@@ -452,47 +477,12 @@ function splitSections(markdown) {
452
477
  continue;
453
478
  }
454
479
 
455
- // Any other markdown heading (`## …` / `### …`, single- or multi-word)
456
- // that is NOT a canonical field heading TERMINATES the current structured
457
- // section. Trailing extended content a producer appends after the
458
- // canonical block — `audit-to-stories`'s `## Agent Prompts` / `## Context`
459
- // / `## Sequencing` blocks, for instance — must not bleed into the last
460
- // structured section's bullet list (Story #4270). Without this, those
461
- // lines were silently absorbed into `verify[]` / `acceptance[]`. The
462
- // heading and everything under it is dropped from structured parsing
463
- // (it is extended, non-canonical markdown).
464
- if (
465
- !inPreamble &&
466
- /^#{1,6}\s+\S/.test(line) &&
467
- !TEXT_BLOCK_FIELDS.has(currentSection)
468
- ) {
480
+ if (isSectionTerminatorHeading(line, inPreamble, currentSection)) {
469
481
  currentSection = null;
470
482
  continue;
471
483
  }
472
484
 
473
- // The trailing `<!-- meta: {...} -->` block is machine metadata, not
474
- // section content. Skip it so a `## References` section immediately
475
- // followed by the meta block does not swallow the comment as a
476
- // references entry. `extractMeta` reads it separately from the raw body.
477
- if (META_BLOCK_RE.test(line)) {
478
- continue;
479
- }
480
-
481
- // The visible `> 🏷️ Authored with Mandrel …` provenance marker is
482
- // machine-managed metadata too (emitted alongside the meta block by the
483
- // authoring path). Skip it so it never bleeds into the trailing structured
484
- // section (e.g. `## Verify`); the value round-trips via the meta block.
485
- if (AUTHORED_MARKER_LINE_RE.test(line)) {
486
- continue;
487
- }
488
-
489
- // The visible `> **Wide:** <reason>` rationale line is presentation only
490
- // (Story #4600): the meta block remains the canonical carrier for
491
- // `wide.reason`, so this line must not bleed into the goal (or any other)
492
- // section. Skip it wherever it appears.
493
- if (WIDE_MARKER_LINE_RE.test(line)) {
494
- continue;
495
- }
485
+ if (isMachineMarkerLine(line)) continue;
496
486
 
497
487
  if (inPreamble) {
498
488
  preambleLines.push(line);
@@ -507,6 +497,70 @@ function splitSections(markdown) {
507
497
  return { sections, footer, preamble };
508
498
  }
509
499
 
500
+ /**
501
+ * True when line `index` opens the footer block: a `---` on its own line
502
+ * whose remaining lines start with a recognised footer key (`parent:`,
503
+ * `Epic:`, `blocked by`).
504
+ *
505
+ * @param {string} line
506
+ * @param {string[]} lines
507
+ * @param {number} index
508
+ * @returns {boolean}
509
+ */
510
+ function isFooterSeparator(line, lines, index) {
511
+ if (!/^---\s*$/.test(line)) return false;
512
+ const remaining = lines.slice(index + 1).join('\n');
513
+ return /^(parent:|Epic:|blocked by)/im.test(remaining);
514
+ }
515
+
516
+ /**
517
+ * True for a non-canonical markdown heading that TERMINATES the current
518
+ * structured section. Trailing extended content a producer appends after the
519
+ * canonical block — `audit-to-stories`'s `## Agent Prompts` / `## Context`
520
+ * / `## Sequencing` blocks, for instance — must not bleed into the last
521
+ * structured section's bullet list (Story #4270). Without this, those
522
+ * lines were silently absorbed into `verify[]` / `acceptance[]`. The
523
+ * heading and everything under it is dropped from structured parsing
524
+ * (it is extended, non-canonical markdown).
525
+ *
526
+ * @param {string} line
527
+ * @param {boolean} inPreamble
528
+ * @param {string|null} currentSection
529
+ * @returns {boolean}
530
+ */
531
+ function isSectionTerminatorHeading(line, inPreamble, currentSection) {
532
+ return (
533
+ !inPreamble &&
534
+ /^#{1,6}\s+\S/.test(line) &&
535
+ !TEXT_BLOCK_FIELDS.has(currentSection)
536
+ );
537
+ }
538
+
539
+ /**
540
+ * True for machine-managed marker lines section parsing skips wherever they
541
+ * appear:
542
+ * - the trailing `<!-- meta: {...} -->` block — machine metadata, not
543
+ * section content, read separately by `extractMeta`, and skipped so a
544
+ * `## References` section immediately followed by it does not swallow
545
+ * the comment as a references entry;
546
+ * - the visible `> 🏷️ Authored with Mandrel …` provenance marker (emitted
547
+ * alongside the meta block by the authoring path; the value round-trips
548
+ * via the meta block);
549
+ * - the visible `> **Wide:** <reason>` rationale line (Story #4600):
550
+ * presentation only — the meta block remains the canonical carrier for
551
+ * `wide.reason`.
552
+ *
553
+ * @param {string} line
554
+ * @returns {boolean}
555
+ */
556
+ function isMachineMarkerLine(line) {
557
+ return (
558
+ META_BLOCK_RE.test(line) ||
559
+ AUTHORED_MARKER_LINE_RE.test(line) ||
560
+ WIDE_MARKER_LINE_RE.test(line)
561
+ );
562
+ }
563
+
510
564
  // ---------------------------------------------------------------------------
511
565
  // Parser — per-section sub-parsers
512
566
  // ---------------------------------------------------------------------------
@@ -771,90 +825,25 @@ export function parse(input) {
771
825
  function parseStructuredObject(obj) {
772
826
  const warnings = [];
773
827
 
774
- const goal = typeof obj.goal === 'string' ? obj.goal.trim() : '';
775
-
776
- // slicing optional v2 intra-Story delivery slice plan (verbatim text).
777
- const slicing = typeof obj.slicing === 'string' ? obj.slicing.trim() : '';
778
-
779
- // spec optional folded Tech Spec (verbatim text).
780
- const spec = typeof obj.spec === 'string' ? obj.spec.trim() : '';
781
-
782
- // changes
783
- const rawChanges = Array.isArray(obj.changes) ? obj.changes : [];
784
- const changes = [];
785
- for (const raw of rawChanges) {
786
- const entry = parsePathEntry(raw, warnings);
787
- if (entry !== null) changes.push(entry);
788
- }
789
-
790
- // acceptance
791
- const acceptance = Array.isArray(obj.acceptance)
792
- ? obj.acceptance.filter((a) => typeof a === 'string' && a.trim().length > 0)
793
- : [];
794
-
795
- // verify
796
- const verify = Array.isArray(obj.verify)
797
- ? obj.verify.filter((v) => typeof v === 'string' && v.trim().length > 0)
798
- : [];
799
-
800
- // references
801
- const rawRefs = Array.isArray(obj.references) ? obj.references : [];
802
- const references = [];
803
- for (const raw of rawRefs) {
804
- const entry = parsePathEntry(raw, warnings);
805
- if (entry !== null) references.push(entry);
828
+ // The declarative half of the normalization: every field whose value is a
829
+ // pure function of its raw input (plus the shared warnings sink) is one
830
+ // table row, walked in canonical body-key order. Adding a field of an
831
+ // existing kind is a one-row change.
832
+ const body = {};
833
+ for (const { name, kind } of STRUCTURED_FIELD_SPECS) {
834
+ body[name] = STRUCTURED_FIELD_NORMALIZERS[kind](obj[name], warnings);
806
835
  }
807
836
 
808
- // non_goals (advisory negative-scope bullets)
809
- const non_goals = Array.isArray(obj.non_goals)
810
- ? obj.non_goals.filter((n) => typeof n === 'string' && n.trim().length > 0)
811
- : [];
812
-
813
- const wide = normalizeWide(obj.wide);
814
- const reason_to_exist = normalizeReasonToExist(obj.reason_to_exist);
815
-
816
- // depends_on: may be at top level or in body
817
- const rawDeps = Array.isArray(obj.depends_on) ? obj.depends_on : [];
818
- const depends_on = rawDeps.filter(
819
- (d) => typeof d === 'string' && d.trim().length > 0,
837
+ // estimated_test_files is the one warning-coupled scalar: absent (== null)
838
+ // warns; a non-number, non-null value stays null silently.
839
+ body.estimated_test_files = normalizeEstimatedTestFiles(
840
+ obj.estimated_test_files,
841
+ warnings,
820
842
  );
821
843
 
822
- // estimated_test_files
823
- let estimated_test_files = null;
824
- if (typeof obj.estimated_test_files === 'number') {
825
- estimated_test_files = obj.estimated_test_files;
826
- } else if (obj.estimated_test_files == null) {
827
- warnings.push(
828
- 'test-surface-unestimated: estimated_test_files not present.',
829
- );
830
- }
831
-
832
844
  // Provenance stamp (preserved verbatim; never re-derived here).
833
- const mandrel_version =
834
- typeof obj.mandrel_version === 'string' && obj.mandrel_version.trim()
835
- ? obj.mandrel_version.trim()
836
- : null;
837
- const authored_at =
838
- typeof obj.authored_at === 'string' && obj.authored_at.trim()
839
- ? obj.authored_at.trim()
840
- : null;
841
-
842
- const body = {
843
- goal,
844
- slicing,
845
- spec,
846
- changes,
847
- acceptance,
848
- verify,
849
- references,
850
- non_goals,
851
- wide,
852
- reason_to_exist,
853
- depends_on,
854
- estimated_test_files,
855
- mandrel_version,
856
- authored_at,
857
- };
845
+ body.mandrel_version = normalizeProvenanceString(obj.mandrel_version);
846
+ body.authored_at = normalizeProvenanceString(obj.authored_at);
858
847
 
859
848
  return {
860
849
  body,
@@ -873,6 +862,91 @@ function parseStructuredObject(obj) {
873
862
  };
874
863
  }
875
864
 
865
+ /**
866
+ * The field-spec table driving {@link parseStructuredObject}, in canonical
867
+ * body-key order. `kind` selects the normalizer from
868
+ * {@link STRUCTURED_FIELD_NORMALIZERS}:
869
+ * - `text` — trimmed string, or `''` when absent/non-string.
870
+ * - `stringList` — array filtered to non-empty strings, else `[]`.
871
+ * - `pathEntryList` — array normalized entry-wise via `parsePathEntry`
872
+ * (fails closed on a malformed entry), else `[]`.
873
+ * - `wide` / `reasonToExist` — the dedicated normalizers shared with the
874
+ * markdown parse path's meta-block recovery.
875
+ *
876
+ * @type {Array<{ name: string, kind: keyof typeof STRUCTURED_FIELD_NORMALIZERS }>}
877
+ */
878
+ const STRUCTURED_FIELD_SPECS = [
879
+ { name: 'goal', kind: 'text' },
880
+ // slicing — optional v2 intra-Story delivery slice plan (verbatim text).
881
+ { name: 'slicing', kind: 'text' },
882
+ // spec — optional folded Tech Spec (verbatim text).
883
+ { name: 'spec', kind: 'text' },
884
+ { name: 'changes', kind: 'pathEntryList' },
885
+ { name: 'acceptance', kind: 'stringList' },
886
+ { name: 'verify', kind: 'stringList' },
887
+ { name: 'references', kind: 'pathEntryList' },
888
+ // non_goals — advisory negative-scope bullets.
889
+ { name: 'non_goals', kind: 'stringList' },
890
+ { name: 'wide', kind: 'wide' },
891
+ { name: 'reason_to_exist', kind: 'reasonToExist' },
892
+ // depends_on — may be at top level or in body.
893
+ { name: 'depends_on', kind: 'stringList' },
894
+ ];
895
+
896
+ /**
897
+ * One normalizer per {@link STRUCTURED_FIELD_SPECS} kind. Each takes the raw
898
+ * field value plus the shared warnings sink and returns the canonical value.
899
+ *
900
+ * @type {Record<string, (raw: unknown, warnings: string[]) => unknown>}
901
+ */
902
+ const STRUCTURED_FIELD_NORMALIZERS = {
903
+ text: (raw) => (typeof raw === 'string' ? raw.trim() : ''),
904
+ stringList: (raw) =>
905
+ Array.isArray(raw)
906
+ ? raw.filter((s) => typeof s === 'string' && s.trim().length > 0)
907
+ : [],
908
+ pathEntryList: (raw, warnings) => {
909
+ const entries = [];
910
+ for (const item of Array.isArray(raw) ? raw : []) {
911
+ const entry = parsePathEntry(item, warnings);
912
+ if (entry !== null) entries.push(entry);
913
+ }
914
+ return entries;
915
+ },
916
+ wide: (raw) => normalizeWide(raw),
917
+ reasonToExist: (raw) => normalizeReasonToExist(raw),
918
+ };
919
+
920
+ /**
921
+ * Normalize `estimated_test_files`: a number passes through; an absent
922
+ * (`null`/`undefined`) value warns `test-surface-unestimated`; any other
923
+ * value stays `null` silently.
924
+ *
925
+ * @param {unknown} raw
926
+ * @param {string[]} warnings
927
+ * @returns {number|null}
928
+ */
929
+ function normalizeEstimatedTestFiles(raw, warnings) {
930
+ if (typeof raw === 'number') return raw;
931
+ if (raw == null) {
932
+ warnings.push(
933
+ 'test-surface-unestimated: estimated_test_files not present.',
934
+ );
935
+ }
936
+ return null;
937
+ }
938
+
939
+ /**
940
+ * Normalize a provenance stamp field (`mandrel_version` / `authored_at`) to
941
+ * a non-empty trimmed string or `null`.
942
+ *
943
+ * @param {unknown} raw
944
+ * @returns {string|null}
945
+ */
946
+ function normalizeProvenanceString(raw) {
947
+ return typeof raw === 'string' && raw.trim() ? raw.trim() : null;
948
+ }
949
+
876
950
  // ---------------------------------------------------------------------------
877
951
  // Serializer
878
952
  // ---------------------------------------------------------------------------
@@ -166,41 +166,26 @@ export async function resolveForeignDone({ provider, dag, inSetIds }) {
166
166
  return resolved.filter((id) => id !== null);
167
167
  }
168
168
 
169
- async function main() {
170
- const { values } = parseArgs({
171
- options: {
172
- ids: { type: 'string' },
173
- pretty: { type: 'boolean', default: false },
174
- native: { type: 'boolean', default: true },
175
- help: { type: 'boolean', default: false },
176
- },
177
- // The documented opt-out is `--no-native`; without allowNegative,
178
- // parseArgs rejects it as an unknown option and the CLI has no working
179
- // way to skip the dependencies API.
180
- allowNegative: true,
181
- allowPositionals: false,
182
- });
183
-
184
- if (values.help) {
185
- process.stdout.write(HELP);
186
- return 0;
187
- }
188
- if (!values.ids) {
189
- process.stderr.write(HELP);
190
- throw new Error('[resolve-stories] --ids <n,n,...> is required');
191
- }
192
-
193
- // stdout is a JSON stream — keep human-readable output on stderr so a
194
- // headless caller can pipe this straight into stories-wave-tick.js.
195
- routeAllOutputToStderr();
196
-
197
- const ids = parseIds(values.ids);
198
- const { provider, config } = resolveStoriesProvider();
169
+ /**
170
+ * Resolve the requested ids into the `{ stories, dag, done }` envelope and
171
+ * write it to `stdout`. The flow core behind `main` — provider, config, and
172
+ * stdout are injected so the whole path is unit-testable without a live
173
+ * GitHub round-trip. Exported for testing.
174
+ *
175
+ * @param {{ ids: string, native?: boolean, pretty?: boolean }} args
176
+ * @param {{ provider: object, config: object, stdout?: { write(s: string): void } }} deps
177
+ * @returns {Promise<number>}
178
+ */
179
+ export async function runResolveStories(
180
+ { ids: rawIds, native = true, pretty = false },
181
+ { provider, config, stdout = process.stdout },
182
+ ) {
183
+ const ids = parseIds(rawIds);
199
184
  const owner = config.github?.owner;
200
185
  const repo = config.github?.repo;
201
186
 
202
187
  const stories = await fetchStories(provider, ids);
203
- const nativeEdges = values.native
188
+ const nativeEdges = native
204
189
  ? await readNativeEdges({ provider, stories, owner, repo })
205
190
  : new Map();
206
191
 
@@ -224,14 +209,48 @@ async function main() {
224
209
  config,
225
210
  });
226
211
 
227
- process.stdout.write(
228
- values.pretty
212
+ stdout.write(
213
+ pretty
229
214
  ? `${JSON.stringify(envelope, null, 2)}\n`
230
215
  : `${JSON.stringify(envelope)}\n`,
231
216
  );
232
217
  return 0;
233
218
  }
234
219
 
220
+ async function main() {
221
+ const { values } = parseArgs({
222
+ options: {
223
+ ids: { type: 'string' },
224
+ pretty: { type: 'boolean', default: false },
225
+ native: { type: 'boolean', default: true },
226
+ help: { type: 'boolean', default: false },
227
+ },
228
+ // The documented opt-out is `--no-native`; without allowNegative,
229
+ // parseArgs rejects it as an unknown option and the CLI has no working
230
+ // way to skip the dependencies API.
231
+ allowNegative: true,
232
+ allowPositionals: false,
233
+ });
234
+
235
+ if (values.help) {
236
+ process.stdout.write(HELP);
237
+ return 0;
238
+ }
239
+ if (!values.ids) {
240
+ process.stderr.write(HELP);
241
+ throw new Error('[resolve-stories] --ids <n,n,...> is required');
242
+ }
243
+
244
+ // stdout is a JSON stream — keep human-readable output on stderr so a
245
+ // headless caller can pipe this straight into stories-wave-tick.js.
246
+ routeAllOutputToStderr();
247
+
248
+ return runResolveStories(
249
+ { ids: values.ids, native: values.native, pretty: values.pretty },
250
+ resolveStoriesProvider(),
251
+ );
252
+ }
253
+
235
254
  runAsCli(import.meta.url, main, {
236
255
  source: 'resolve-stories',
237
256
  propagateExitCode: true,
@@ -158,14 +158,13 @@ function readMaxWaitSecondsFlag() {
158
158
  * Resolve the PR number for the Story branch when one was not passed on
159
159
  * the CLI. Probes `gh pr list --head <branch> --state all` (the merged PR
160
160
  * is no longer `open`, so `--state all` is required). Returns `null` when
161
- * no PR is found.
161
+ * no PR is found. Exported for testing.
162
162
  *
163
- * @param {{ cwd: string, storyBranch: string, gh: object }} args
163
+ * @param {{ storyBranch: string, gh: object }} args
164
164
  * @returns {Promise<number|null>}
165
165
  */
166
- async function resolvePrNumber({ cwd, storyBranch, gh }) {
166
+ export async function resolvePrNumber({ storyBranch, gh }) {
167
167
  try {
168
- void cwd;
169
168
  const rows = await gh.pr.list(
170
169
  ['--head', storyBranch, '--state', 'all'],
171
170
  ['number', 'url'],
@@ -293,11 +292,11 @@ function buildConfirmTerminal({
293
292
  });
294
293
  }
295
294
 
296
- async function resolveConfirmPrNumber({ prParam, cwd, storyBranch, gh }) {
295
+ async function resolveConfirmPrNumber({ prParam, storyBranch, gh }) {
297
296
  const rawPr = prParam ?? readPrFlag();
298
297
  let prNumber = Number.parseInt(String(rawPr ?? ''), 10);
299
298
  if (!Number.isInteger(prNumber) || prNumber <= 0) {
300
- prNumber = await resolvePrNumber({ cwd, storyBranch, gh });
299
+ prNumber = await resolvePrNumber({ storyBranch, gh });
301
300
  }
302
301
  return Number.isInteger(prNumber) && prNumber > 0 ? prNumber : null;
303
302
  }
@@ -340,7 +339,6 @@ export async function runConfirmMerge({
340
339
 
341
340
  const prNumber = await resolveConfirmPrNumber({
342
341
  prParam,
343
- cwd,
344
342
  storyBranch,
345
343
  gh,
346
344
  });
@@ -20,16 +20,18 @@ description: >-
20
20
 
21
21
  ## 1. Dispatch — where the engine runs
22
22
 
23
- Read `stories[].dispatchMode` from the `resolve-stories.js` envelope. Two
24
- rules produce it, in order:
23
+ Read `stories[].dispatchMode` from the `resolve-stories.js` envelope.
24
+ `inline` names one indivisible resource — **the router's own session** — so one
25
+ rule produces it:
25
26
 
26
27
  1. **Run topology.** A run resolving **one** Story is `inline`
27
28
  whatever its shape — sub-agent isolation is load-bearing only against a
28
29
  *concurrent* sibling racing the same checkout, and a one-Story run has none.
29
- 2. **Body shape.** In a multi-Story run, a lite-shaped body is
30
- `inline`; a full-shaped body, an unparseable one, or a footprint touching a
31
- sensitive-path class is `subagent`. The `route::lite` label is a
32
- human-visible hint, never the control signal.
30
+ 2. **Every other run is `subagent`.** A multi-Story run dispatches every Story
31
+ as a sub-agent however trivial its shape: a lite body does not conjure a
32
+ second session for a sibling to run in, and the wave tick may hand you the
33
+ whole set on one beat. Shape still sets ceremony and is reported alongside;
34
+ the `route::lite` label is a human-visible hint, never the control signal.
33
35
 
34
36
  `inline` removes model-side fan-out only — no `story-worker` boot, no fresh
35
37
  acceptance-critic spawn. **`subagent` and `inline` run the same engine**: same