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.
- package/.agents/README.md +1 -1
- package/.agents/agents/story-worker.md +5 -0
- package/.agents/instructions.md +14 -17
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/rules/known-tooling-behavior.md +114 -0
- package/.agents/scripts/check-context-budget.js +134 -2
- package/.agents/scripts/lib/audit-suite/selector.js +275 -162
- package/.agents/scripts/lib/config/temp-paths.js +51 -7
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
- package/.agents/scripts/lib/label-constants.js +12 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
- package/.agents/scripts/lib/observability/signals-writer.js +133 -14
- package/.agents/scripts/lib/observability/source-classifier.js +131 -1
- package/.agents/scripts/lib/orchestration/code-review.js +12 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +51 -46
- package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
- package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
- package/.agents/scripts/lib/story-body/story-body.js +248 -174
- package/.agents/scripts/resolve-stories.js +52 -33
- package/.agents/scripts/single-story-confirm-merge.js +5 -7
- package/.agents/workflows/helpers/deliver-digest.md +8 -6
- package/.agents/workflows/helpers/deliver-reference.md +15 -12
- package/.agents/workflows/helpers/deliver-story-reference.md +23 -21
- package/.agents/workflows/helpers/deliver-story.md +2 -2
- package/.agents/workflows/helpers/plan-reference.md +5 -4
- package/docs/CHANGELOG.md +20 -0
- 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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
//
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
//
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
834
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 =
|
|
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
|
-
|
|
228
|
-
|
|
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 {{
|
|
163
|
+
* @param {{ storyBranch: string, gh: object }} args
|
|
164
164
|
* @returns {Promise<number|null>}
|
|
165
165
|
*/
|
|
166
|
-
async function resolvePrNumber({
|
|
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,
|
|
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({
|
|
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.
|
|
24
|
-
|
|
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. **
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|