@dev-loops/core 0.6.0 → 0.7.2

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.
@@ -24,6 +24,143 @@
24
24
  const VALID_SEVERITIES = new Set(["must-fix", "worth-fixing-now", "defer"]);
25
25
  const VALID_VERDICTS = new Set(["clean", "findings_present"]);
26
26
 
27
+ /**
28
+ * Canonical fail-closed signal for when a child/agent cannot perform real
29
+ * parallel fan-out (e.g. the harness does not honor the subagent tool at child
30
+ * depth). The flow MUST fail closed with this message and route the gate review
31
+ * to the conductor rather than silently degrading to a single-agent inline
32
+ * review (which requireFanoutProvenance is designed to reject). Documented as a
33
+ * contract in docs/gate-review-sub-loop-contract.md.
34
+ */
35
+ export const FANOUT_UNAVAILABLE_MESSAGE = "fan-out unavailable — route to conductor";
36
+
37
+ /**
38
+ * Build a fail-closed Error carrying the route-to-conductor contract signal.
39
+ * Callers throw this (or check `.routeToConductor === true`) when real fan-out
40
+ * cannot be performed. `detail` is appended for diagnostics but the stable,
41
+ * matchable prefix is always {@link FANOUT_UNAVAILABLE_MESSAGE}.
42
+ *
43
+ * @param {string} [detail] — optional diagnostic suffix (e.g. why fan-out failed)
44
+ * @returns {Error & { routeToConductor: true, code: "FANOUT_UNAVAILABLE" }}
45
+ */
46
+ export function fanoutUnavailableError(detail) {
47
+ const suffix = typeof detail === "string" && detail.trim().length > 0 ? ` (${detail.trim()})` : "";
48
+ const error = new Error(`${FANOUT_UNAVAILABLE_MESSAGE}${suffix}`);
49
+ return Object.assign(error, { routeToConductor: /** @type {const} */ (true), code: /** @type {const} */ ("FANOUT_UNAVAILABLE") });
50
+ }
51
+
52
+ /**
53
+ * Count DISTINCT reviewer identities actually recorded in a `perAngle` array.
54
+ * An entry contributes an identity via `reviewer` (preferred) or `dispatchId`;
55
+ * entries carrying neither are not countable reviewers (a bare `{angle}` proves
56
+ * nothing about who reviewed it). Pure.
57
+ *
58
+ * @param {unknown} perAngle
59
+ * @returns {number}
60
+ */
61
+ export function countDistinctReviewers(perAngle) {
62
+ if (!Array.isArray(perAngle)) return 0;
63
+ const ids = new Set();
64
+ for (const e of perAngle) {
65
+ if (!e || typeof e !== "object" || Array.isArray(e)) continue;
66
+ const id = typeof e.reviewer === "string" && e.reviewer.trim().length > 0
67
+ ? e.reviewer.trim()
68
+ : typeof e.dispatchId === "string" && e.dispatchId.trim().length > 0
69
+ ? e.dispatchId.trim()
70
+ : null;
71
+ if (id) ids.add(id);
72
+ }
73
+ return ids.size;
74
+ }
75
+
76
+ /**
77
+ * Validate INTERNAL CONSISTENCY of a fan-out provenance object. Returns an error
78
+ * string when the provenance is malformed or self-inconsistent, or null when it
79
+ * is well-formed and consistent. Shared by the write path (write-gate-findings-log)
80
+ * and the enforcement read path (buildPreMergeGateCheck) so both agree.
81
+ *
82
+ * Consistency rule (documented in docs/gate-review-sub-loop-contract.md):
83
+ * - `distinctReviewers` must be a non-negative integer.
84
+ * - `perAngle` must be an array, and non-empty when `distinctReviewers > 0`.
85
+ * - `distinctReviewers` must be <= the count of DISTINCT reviewer identities
86
+ * actually recorded in `perAngle` — you cannot claim more reviewers than you
87
+ * recorded dispatch entries for.
88
+ *
89
+ * HONEST CAVEAT: this makes recorded provenance internally consistent and raises
90
+ * the bar, but the provenance is self-reported (written by the same agent whose
91
+ * independence it claims), so it remains forgeable by a determined single agent.
92
+ * Un-forgeable recording is the Pi-harness bridge (subagent tool at child depth).
93
+ *
94
+ * @param {unknown} prov
95
+ * @returns {string|null}
96
+ */
97
+ export function provenanceConsistencyError(prov) {
98
+ if (!prov || typeof prov !== "object" || Array.isArray(prov)) {
99
+ return "provenance must be an object";
100
+ }
101
+ const p = /** @type {Record<string, unknown>} */ (prov);
102
+ if (!Number.isInteger(p.distinctReviewers) || /** @type {number} */ (p.distinctReviewers) < 0) {
103
+ return "provenance.distinctReviewers must be a non-negative integer";
104
+ }
105
+ if (!Array.isArray(p.perAngle)) {
106
+ return "provenance.perAngle must be an array";
107
+ }
108
+ const claimed = /** @type {number} */ (p.distinctReviewers);
109
+ if (claimed > 0 && p.perAngle.length === 0) {
110
+ return "provenance.perAngle must be non-empty when distinctReviewers > 0";
111
+ }
112
+ const recorded = countDistinctReviewers(p.perAngle);
113
+ if (claimed > recorded) {
114
+ return `provenance.distinctReviewers (${claimed}) exceeds distinct recorded reviewer identities (${recorded})`;
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Base angle name for a delta-suffixed re-review entry (`<angle>-delta-at-...`,
121
+ * e.g. `pr-checklist-matrix-delta-at-current-head`): a re-review scoped to only
122
+ * the current head's delta still counts toward its base angle for both
123
+ * mandatory-angle coverage and pool-membership checks.
124
+ *
125
+ * @param {string} angle
126
+ * @returns {string}
127
+ */
128
+ function baseAngleName(angle) {
129
+ return angle.replace(/-delta-at-.+$/, "");
130
+ }
131
+
132
+ /**
133
+ * Validate a recorded fan-out angle list against a gate's configured angle
134
+ * contract: every mandatory angle must be represented, and — when a pool is
135
+ * supplied — every recorded angle must be a member of it (delta-suffixed
136
+ * angles count toward their {@link baseAngleName}). Pure; shared by the write
137
+ * path (write-gate-findings-log's `provenance.perAngle`, upsert-checkpoint-verdict's
138
+ * `--findings-json` per-angle results) and the merge-evidence read path
139
+ * (detect-checkpoint-evidence re-validating the ledger's `provenance.perAngle`)
140
+ * so all three enforce identically.
141
+ *
142
+ * @param {unknown} recordedAngles — array of `{ angle: string, ... }` entries (provenance.perAngle or normalized per-angle findings)
143
+ * @param {object} [gateAngleContract]
144
+ * @param {string[]} [gateAngleContract.mandatoryAngles] — angles that must always be represented
145
+ * @param {string[]|null} [gateAngleContract.pool] — configured angle pool; null/omitted skips the foreign-angle check
146
+ * @returns {{ missingMandatory: string[], foreignAngles: string[] }}
147
+ */
148
+ export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [], pool = null } = {}) {
149
+ const recorded = Array.isArray(recordedAngles)
150
+ ? recordedAngles
151
+ .map((e) => (e && typeof e === "object" && typeof e.angle === "string" ? e.angle.trim() : ""))
152
+ .filter((a) => a.length > 0)
153
+ : [];
154
+ const recordedBases = new Set(recorded.map(baseAngleName));
155
+ const missingMandatory = mandatoryAngles.filter((a) => !recordedBases.has(a));
156
+ let foreignAngles = [];
157
+ if (Array.isArray(pool) && pool.length > 0) {
158
+ const poolSet = new Set(pool);
159
+ foreignAngles = [...new Set(recorded.filter((a) => !poolSet.has(baseAngleName(a))))];
160
+ }
161
+ return { missingMandatory, foreignAngles };
162
+ }
163
+
27
164
  /**
28
165
  * Default cap on parallel fan-out reviewers when a caller does not supply one.
29
166
  * Mirrors the config default (gates.maxFanoutReviewers).
@@ -269,6 +269,55 @@ function deriveRequiredReads(bundle, resolverOutput) {
269
269
  return Array.isArray(reads) ? [...reads] : [];
270
270
  }
271
271
 
272
+ // ---------------------------------------------------------------------------
273
+ // specSource derivation (issue #1025 — lightweight PR-body-as-spec)
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /**
277
+ * The LOCAL-FIRST spec-source subset the envelope distinguishes: phase_doc vs
278
+ * pr_body. This is NOT the full `canonicalSpecSource` value space — the same
279
+ * field name also carries "tracker_issue" in the tracker-backed mode
280
+ * (scripts/github/resolve-tracker-local-spec.mjs), which the envelope does not
281
+ * model (deriveSpecSource coerces it to null).
282
+ */
283
+ export const CANONICAL_SPEC_SOURCE = Object.freeze({
284
+ PHASE_DOC: "phase_doc",
285
+ PR_BODY: "pr_body",
286
+ });
287
+
288
+ /**
289
+ * Derive the canonical spec source. Prefer the resolver-output-level field,
290
+ * fall back to bundle-level (mirrors deriveRequiredReads). Returns null when
291
+ * absent so the default (phase-doc) path carries no specSource field and stays
292
+ * byte-identical. Any value outside the local-first subset {phase_doc, pr_body}
293
+ * — e.g. the tracker-backed "tracker_issue" carried by the same field name — is
294
+ * coerced to null so the envelope can never set a specSource that
295
+ * validateHandoffEnvelope would then reject.
296
+ */
297
+ function deriveSpecSource(bundle, resolverOutput) {
298
+ const raw = normalizeStringOrNull(resolverOutput?.canonicalSpecSource)
299
+ ?? normalizeStringOrNull(bundle?.canonicalSpecSource);
300
+ return raw === CANONICAL_SPEC_SOURCE.PHASE_DOC || raw === CANONICAL_SPEC_SOURCE.PR_BODY ? raw : null;
301
+ }
302
+
303
+ /**
304
+ * Apply the spec-source variant to acceptance criteria. Under the lightweight
305
+ * PR-body-as-spec path the phase-doc criterion text is retargeted to the PR
306
+ * description; the default (null/phase_doc) path returns the criteria verbatim
307
+ * so the phase-doc template text stays identical.
308
+ */
309
+ function applySpecSourceVariant(criteria, specSource) {
310
+ // ponytail: free-text substring retarget is a no-op for any strategy whose
311
+ // criteria lack the phase-doc phrase — fine while lightweight only composes
312
+ // with local_implementation; make it a structured criterion-id lookup if
313
+ // lightweight is ever extended to another strategy.
314
+ if (specSource !== CANONICAL_SPEC_SOURCE.PR_BODY) return [...criteria];
315
+ return criteria.map((c) => ({
316
+ ...c,
317
+ must: c.must.replace("from the active phase doc", "from the PR description"),
318
+ }));
319
+ }
320
+
272
321
  // ---------------------------------------------------------------------------
273
322
  // Gate config derivation
274
323
  // ---------------------------------------------------------------------------
@@ -412,6 +461,37 @@ function normalizeGateState(gateState) {
412
461
  };
413
462
  }
414
463
 
464
+
465
+ /**
466
+ * Normalize the structured retrospective findings (issue #1077, Reading B).
467
+ *
468
+ * The retrospective is advisory: it never blocks merge or any lifecycle
469
+ * transition. Its findings travel in the handoff envelope (the conductor's
470
+ * decision input) and in an advisory PR comment — never on disk as a gate.
471
+ *
472
+ * The source is the `check-retro-tooling.mjs` JSON output shape:
473
+ * { ok, internalToolingOnly, rawCallViolations, allowedWriteOps }
474
+ *
475
+ * Returns a normalized object carrying the substantive fields, or null when no
476
+ * findings were supplied (the field is optional — present only when the loop
477
+ * subagent ran the retrospective tooling).
478
+ */
479
+ function normalizeRetrospectiveFindings(findings) {
480
+ if (findings === null || findings === undefined) return null;
481
+ if (typeof findings !== "object" || Array.isArray(findings)) return null;
482
+
483
+ const toStrArray = (v) => Array.isArray(v)
484
+ ? v.map((x) => (typeof x === "string" ? x : String(x)).trim()).filter((x) => x.length > 0)
485
+ : [];
486
+
487
+ const internalToolingOnly = findings.internalToolingOnly === true;
488
+ return {
489
+ internalToolingOnly,
490
+ rawCallViolations: toStrArray(findings.rawCallViolations),
491
+ allowedWriteOps: toStrArray(findings.allowedWriteOps),
492
+ };
493
+ }
494
+
415
495
  // ---------------------------------------------------------------------------
416
496
  // Sub-gate resolution
417
497
  // ---------------------------------------------------------------------------
@@ -461,6 +541,11 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
461
541
 
462
542
  const gs = normalizeGateState(gateState);
463
543
  const subGate = resolveSubGate(strategy, gs);
544
+ // Normalize each source independently, then fall back on the normalized result
545
+ // (not the raw value): a present-but-invalid gateState value must NOT shadow a
546
+ // valid options.retrospectiveFindings fallback (issue #1077 review finding).
547
+ const retrospectiveFindings = normalizeRetrospectiveFindings(gateState?.retrospectiveFindings)
548
+ ?? normalizeRetrospectiveFindings(options.retrospectiveFindings);
464
549
 
465
550
  const target = deriveTarget(bundle, repo);
466
551
  const requiredReads = deriveRequiredReads(bundle, resolverOutput);
@@ -468,11 +553,25 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
468
553
  const gateConfig = deriveGateConfig(settings, subGate);
469
554
  const derivedCwd = deriveCwd(bundle, { repoRoot: options.repoRoot, worktreeCwd: options.worktreeCwd });
470
555
  const template = lookupAcceptanceTemplate(strategy, subGate);
556
+ // Lightweight PR-body-as-spec (issue #1025): retarget the phase-doc criterion
557
+ // text to the PR description. Null/phase_doc leaves the criteria untouched, so
558
+ // the non-lightweight path stays byte-identical.
559
+ const specSource = deriveSpecSource(bundle, resolverOutput);
560
+ const acceptanceCriteria = applySpecSourceVariant(template.criteria, specSource);
471
561
 
472
562
  const overrides = options.overrides && typeof options.overrides === "object" && Object.keys(options.overrides).length > 0
473
563
  ? { ...options.overrides }
474
564
  : undefined;
475
565
 
566
+ // Sanctioned operation → wrapper command map (issue #1081). Core is
567
+ // consumer-agnostic: it carries whatever map the consumer supplies (the
568
+ // `loop build-envelope` CLI injects this repo's scripts/... paths) so every
569
+ // spawned subagent receives it by DEFAULT. Core defines the SHAPE only —
570
+ // it never hardcodes repo-specific paths. A non-object is ignored.
571
+ const sanctionedCommands = options.sanctionedCommands && typeof options.sanctionedCommands === "object" && !Array.isArray(options.sanctionedCommands)
572
+ ? options.sanctionedCommands
573
+ : undefined;
574
+
476
575
  // Surface the *effective* async-start posture alongside the *configured* one (#834). The
477
576
  // configured `asyncStartMode` is echoed verbatim from settings (back-compat), but the contract
478
577
  // is relaxed at validation time under the Claude harness (resolveEffectiveAsyncStartMode →
@@ -509,7 +608,7 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
509
608
  worktreeRequired: true,
510
609
 
511
610
  acceptance: {
512
- criteria: [...template.criteria],
611
+ criteria: acceptanceCriteria,
513
612
  evidence: [...template.evidence],
514
613
  maxFinalizationTurns: template.maxFinalizationTurns,
515
614
  },
@@ -528,11 +627,22 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
528
627
  envelope.overrides = overrides;
529
628
  }
530
629
 
531
- // Optional refinement contract (AC/DoD matrix) from the refiner.
532
- // Set via options.refinementContract, bundle.refinementContract, or resolverOutput.refinementContract.
533
- const refinementContract = options.refinementContract ?? bundle.refinementContract ?? resolverOutput.refinementContract ?? null;
534
- if (refinementContract != null) {
535
- envelope.refinementContract = refinementContract;
630
+ if (sanctionedCommands) {
631
+ envelope.sanctionedCommands = sanctionedCommands;
632
+ }
633
+
634
+ // Advisory retrospective findings (issue #1077, Reading B). Optional structured
635
+ // field carrying the check-retro-tooling.mjs JSON output to the conductor. Never a
636
+ // gate — the conductor surfaces these as an advisory PR comment, not a block.
637
+ if (retrospectiveFindings) {
638
+ envelope.retrospectiveFindings = retrospectiveFindings;
639
+ }
640
+
641
+ // Canonical spec source (issue #1025). Optional: only set when the resolver
642
+ // marks a lightweight PR-body-as-spec session, so the default (phase-doc) path
643
+ // carries no specSource field and its envelope stays byte-identical.
644
+ if (specSource) {
645
+ envelope.specSource = specSource;
536
646
  }
537
647
 
538
648
  return deepFreeze(envelope);
@@ -767,79 +877,40 @@ export function validateHandoffEnvelope(envelope) {
767
877
  });
768
878
  }
769
879
 
770
- // ----- refinementContract (optional) -----
771
- if (envelope.refinementContract !== undefined && envelope.refinementContract !== null) {
772
- if (typeof envelope.refinementContract !== "object" || Array.isArray(envelope.refinementContract)) {
880
+ // ----- retrospectiveFindings (optional, advisory — issue #1077) -----
881
+ if (envelope.retrospectiveFindings !== undefined && envelope.retrospectiveFindings !== null) {
882
+ const rf = envelope.retrospectiveFindings;
883
+ if (typeof rf !== "object" || Array.isArray(rf)) {
773
884
  errors.push({
774
- field: "refinementContract",
775
- reason: "if present, must be a non-array object with schema, items, generatedAt, and isComplete",
776
- got: envelope.refinementContract,
885
+ field: "retrospectiveFindings",
886
+ reason: "if present, must be a non-array object { internalToolingOnly, rawCallViolations, allowedWriteOps }",
887
+ got: rf,
777
888
  });
778
889
  } else {
779
- if (envelope.refinementContract.schema !== "ac-dod-matrix/v1") {
780
- warnings.push({
781
- field: "refinementContract.schema",
782
- reason: "expected 'ac-dod-matrix/v1'",
783
- got: envelope.refinementContract.schema,
784
- });
785
- }
786
- if (!Array.isArray(envelope.refinementContract.items) || envelope.refinementContract.items.length === 0) {
787
- errors.push({
788
- field: "refinementContract.items",
789
- reason: "must be a non-empty array of AC/DoD matrix items",
790
- got: envelope.refinementContract.items,
791
- });
792
- } else {
793
- const bad = [];
794
- for (let i = 0; i < envelope.refinementContract.items.length; i++) {
795
- const item = envelope.refinementContract.items[i];
796
- if (
797
- !item || typeof item !== "object" ||
798
- typeof item.item !== "string" || !item.item.trim() ||
799
- !["AC", "DoD", "Non-goal"].includes(item.type) ||
800
- !["Met", "Partial", "Unmet", "Unverified"].includes(item.status) ||
801
- typeof item.evidence !== "string" ||
802
- typeof item.notes !== "string"
803
- ) {
804
- bad.push(i);
805
- }
806
- }
807
- if (bad.length > 0) {
808
- errors.push({
809
- field: "refinementContract.items",
810
- reason: `entries at indices [${bad.join(",")}] must have valid item, type, status, evidence, and notes fields`,
811
- got: envelope.refinementContract.items,
812
- });
813
- }
890
+ if (typeof rf.internalToolingOnly !== "boolean") {
891
+ errors.push({ field: "retrospectiveFindings.internalToolingOnly", reason: "must be a boolean", got: rf.internalToolingOnly });
814
892
  }
815
- if (typeof envelope.refinementContract.generatedAt !== "string" || isNaN(Date.parse(envelope.refinementContract.generatedAt))) {
816
- errors.push({
817
- field: "refinementContract.generatedAt",
818
- reason: "must be a valid ISO 8601 timestamp",
819
- got: envelope.refinementContract.generatedAt,
820
- });
893
+ if (!Array.isArray(rf.rawCallViolations) || rf.rawCallViolations.some((v) => typeof v !== "string")) {
894
+ errors.push({ field: "retrospectiveFindings.rawCallViolations", reason: "must be an array of strings", got: rf.rawCallViolations });
821
895
  }
822
- if (typeof envelope.refinementContract.isComplete !== "boolean") {
823
- errors.push({
824
- field: "refinementContract.isComplete",
825
- reason: "must be a boolean",
826
- got: envelope.refinementContract.isComplete,
827
- });
828
- } else if (envelope.refinementContract.items && Array.isArray(envelope.refinementContract.items)) {
829
- const allMet = envelope.refinementContract.items.every(
830
- (item) => item && typeof item === "object" && item.status === "Met"
831
- );
832
- if (envelope.refinementContract.isComplete !== allMet) {
833
- errors.push({
834
- field: "refinementContract.isComplete",
835
- reason: "must match items status (true iff every item has status 'Met')",
836
- got: { isComplete: envelope.refinementContract.isComplete, allItemsMet: allMet },
837
- });
838
- }
896
+ if (!Array.isArray(rf.allowedWriteOps) || rf.allowedWriteOps.some((v) => typeof v !== "string")) {
897
+ errors.push({ field: "retrospectiveFindings.allowedWriteOps", reason: "must be an array of strings", got: rf.allowedWriteOps });
839
898
  }
840
899
  }
841
900
  }
842
901
 
902
+ // ----- specSource (optional — issue #1025, lightweight PR-body-as-spec) -----
903
+ if (envelope.specSource !== undefined && envelope.specSource !== null) {
904
+ const validSources = [CANONICAL_SPEC_SOURCE.PHASE_DOC, CANONICAL_SPEC_SOURCE.PR_BODY];
905
+ if (typeof envelope.specSource !== "string" || !validSources.includes(envelope.specSource)) {
906
+ errors.push({
907
+ field: "specSource",
908
+ reason: `if present, must be one of ${validSources.join(", ")}`,
909
+ got: envelope.specSource,
910
+ });
911
+ }
912
+ }
913
+
843
914
  // ----- derivedAt (informational, warn on missing) -----
844
915
  if (typeof envelope.derivedAt !== "string" || !envelope.derivedAt.trim()) {
845
916
  warnings.push({ field: "derivedAt", reason: "should be an ISO 8601 timestamp" });
@@ -863,6 +934,7 @@ export {
863
934
  deriveCwd,
864
935
  deriveRequiredReads,
865
936
  normalizeGateState,
937
+ normalizeRetrospectiveFindings,
866
938
  resolveSubGate,
867
939
  lookupAcceptanceTemplate,
868
940
  buildWorktreeSlug,