@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.
- package/package.json +7 -7
- package/src/analysis/change-classifier.mjs +50 -6
- package/src/analysis/diff-analyzer.mjs +68 -12
- package/src/claude/hook-decisions.mjs +138 -15
- package/src/config/config.mjs +247 -98
- package/src/config/extension-defaults.yaml +6 -11
- package/src/github/copilot-helpers.mjs +143 -0
- package/src/harness/extension-adapter.mjs +1 -0
- package/src/harness/index.mjs +0 -1
- package/src/loop/bash-command-classify.mjs +333 -29
- package/src/loop/conductor-routing.mjs +0 -27
- package/src/loop/copilot-loop-state.mjs +25 -2
- package/src/loop/gate-fanin.mjs +137 -0
- package/src/loop/handoff-envelope.mjs +142 -70
- package/src/loop/issue-refinement-artifact.mjs +259 -8
- package/src/loop/lifecycle-state.mjs +1 -1
- package/src/loop/pr-gate-coordination.mjs +158 -238
- package/src/loop/pr-lifecycle.mjs +79 -0
- package/src/loop/public-dev-loop-routing.mjs +2 -2
- package/src/loop/queue-board-ordering.mjs +52 -8
- package/src/loop/queue-board-sync.mjs +62 -3
- package/src/loop/queue-driver.mjs +80 -8
- package/src/loop/queue-state.mjs +13 -2
- package/src/loop/reviewer-loop-state.mjs +20 -2
- package/src/projects/list-queue-items.mjs +380 -0
- package/src/projects/move-queue-item.mjs +394 -0
- package/src/projects/resolve-project.mjs +183 -0
- package/bin/capture-deep-persona-signals.mjs +0 -143
- package/src/debt/deep-persona-signals.mjs +0 -266
- package/src/harness/claude-extension-adapter.mjs +0 -102
- package/src/refinement/ac-dod-matrix.mjs +0 -95
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
// -----
|
|
771
|
-
if (envelope.
|
|
772
|
-
|
|
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: "
|
|
775
|
-
reason: "if present, must be a non-array object
|
|
776
|
-
got:
|
|
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 (
|
|
780
|
-
|
|
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 (
|
|
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 (
|
|
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,
|