peaks-loop 4.0.53 → 4.0.54

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 (44) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/codegraph-commands.js +164 -5
  5. package/dist/cli/commands/core/memory-command.js +35 -2
  6. package/dist/cli/commands/dispatch-commands.js +4 -2
  7. package/dist/cli/commands/prd-commands.js +15 -0
  8. package/dist/cli/commands/sub-agent-shared.d.ts +23 -0
  9. package/dist/cli/commands/sub-agent-shared.js +39 -0
  10. package/dist/services/artifacts/artifact-prerequisites.js +34 -0
  11. package/dist/services/audit/enforcer-liveness.js +1 -1
  12. package/dist/services/codegraph/codegraph-config-repair-writer.d.ts +44 -0
  13. package/dist/services/codegraph/codegraph-config-repair-writer.js +112 -13
  14. package/dist/services/codegraph/codegraph-exclude-repair.d.ts +23 -1
  15. package/dist/services/codegraph/codegraph-exclude-repair.js +6 -0
  16. package/dist/services/evidence/evidence-generator.js +11 -3
  17. package/dist/services/memory/project-memory-service/index.d.ts +1 -1
  18. package/dist/services/memory/project-memory-service/index.js +1 -1
  19. package/dist/services/memory/project-memory-service/store/atomic-write.d.ts +59 -0
  20. package/dist/services/memory/project-memory-service/store/atomic-write.js +167 -7
  21. package/dist/services/prd/gate-evidence-derivation.d.ts +160 -0
  22. package/dist/services/prd/gate-evidence-derivation.js +270 -0
  23. package/dist/services/prd/handoff-auto-regen.js +13 -1
  24. package/dist/services/prd/handoff-frontmatter.d.ts +1 -1
  25. package/dist/services/prd/handoff-frontmatter.js +62 -0
  26. package/dist/services/prd/handoff-gate-evidence.d.ts +95 -1
  27. package/dist/services/prd/handoff-gate-evidence.js +125 -57
  28. package/dist/services/prd/handoff-service.d.ts +21 -1
  29. package/dist/services/prd/handoff-service.js +45 -2
  30. package/dist/services/prd/handoff-types.d.ts +40 -0
  31. package/dist/services/prd/handoff-types.js +28 -1
  32. package/dist/services/prd/project-scan-reader.d.ts +7 -0
  33. package/dist/services/prd/project-scan-reader.js +7 -1
  34. package/dist/services/rd/reviewer-dispatch-policy.d.ts +36 -8
  35. package/dist/services/rd/reviewer-dispatch-policy.js +36 -8
  36. package/package.json +6 -6
  37. package/skills/bee/peaks-rd/SKILL.md +1 -1
  38. package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +26 -10
  39. package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +1 -1
  40. package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +6 -1
  41. package/skills/peaks-code/references/periodic-checkpoint.md +7 -5
  42. package/skills/peaks-code/references/startup-sequence.md +1 -1
  43. package/skills/peaks-final-review/SKILL.md +1 -1
  44. /package/{docs → contracts}/test-style-contract.md +0 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Where `gateEvidence` comes FROM, and where its promise is checked.
3
+ *
4
+ * B2 (merged with the B1 repair), rid `rid-b2-gate-evidence-wiring`.
5
+ *
6
+ * B1 made the field real at the SERVICE layer and QA found the hole that
7
+ * mattered (F1 of `rid-b1-qa`): all three frontmatter producers passed
8
+ * nothing, so no capsule on disk ever carried the field — the dead surface
9
+ * had moved one layer out, from "no producer function" to "no production
10
+ * caller". This module closes that by DERIVING the map instead of accepting
11
+ * it from a caller.
12
+ *
13
+ * WHY DERIVED, NOT PASSED IN. Every path in the map is a function of the
14
+ * session id, the request id and the REQUEST TYPE, all of which the producer
15
+ * already has. Asking the caller to supply the map would put a second source
16
+ * next to the derivation, and a CLI flag (`--gate-evidence <json>`) would
17
+ * additionally require the user to hand-author JSON, which the project's
18
+ * Human-NL-Choice-Only rule forbids. So: one function, computed, no flag.
19
+ *
20
+ * THE TYPE-AWARE TENSION, AND HOW IT IS RESOLVED. The schema doc
21
+ * (`skills/bee/peaks-rd/references/writing-handoff-frontmatter.md:53`) calls
22
+ * the five keys a schema and says "Missing keys → Gate C failure". Taken
23
+ * literally that demands all five paths in every capsule — and then a `docs`
24
+ * slice, which Gate C requires NO evidence from, would fail the gate for
25
+ * "declaring" `audit/perf-<rid>.md` and `audit/security-<rid>.md`, two files
26
+ * its type is not supposed to produce. Declaring evidence that does not and
27
+ * should not exist is a false claim, and a gate that fails on a false claim
28
+ * the writer invented is worse than useless.
29
+ *
30
+ * Resolution, and the reason it is not a special case: the map declares
31
+ * EXACTLY what this request type's transition gate requires, read from
32
+ * `PREREQUISITES_BY_TYPE` — the same table `checkPrerequisites` enforces at
33
+ * `rd:qa-handoff`. So:
34
+ *
35
+ * - feature / refactor → projectScan, prdHandoff, codeReview,
36
+ * securityReview, perfBaseline (all five)
37
+ * - bugfix → the same five
38
+ * - config → projectScan, securityReview (TWO keys, verified
39
+ * against the table rather than assumed — `CONFIG_TABLE['rd:qa-handoff']`
40
+ * is `[SECURITY_REVIEW]` alone, so there is no `prdHandoff`: config has no
41
+ * `AUDIT_REQUIRES_HANDOFF` row. An earlier revision of this comment
42
+ * claimed three, and QA caught prose that disagreed with the code, which
43
+ * is the defect this module exists to end. The security path declared is
44
+ * the config-specific `rd/security-review.md` — genuinely ridless, per its
45
+ * own row — not the documented `audit/security-<rid>.md`, which no config
46
+ * slice writes)
47
+ * - docs / chore → NOTHING. No `rd:qa-handoff` row ⇒ nothing for Gate C
48
+ * to validate ⇒ nothing declared (see below)
49
+ *
50
+ * WHY `projectScan` IS DECLARED, AND WHY ONLY FOR GATED TYPES. It is not read
51
+ * from the table — it is Gate A's artifact, and Gate A is type-independent
52
+ * ("After workspace init + project scan", step 0.6 of every workflow). But a
53
+ * declaration is only worth making when something VERIFIES it, and the only
54
+ * verifier is `checkDeclaredGateEvidence`, which runs on `rd:qa-handoff`. For a
55
+ * type with a gate row, Gate C therefore does check this path like any other.
56
+ * For `docs`/`chore` nothing ever reads it, so it is not declared: keeping an
57
+ * inert statement in every docs capsule would recreate, in miniature, the exact
58
+ * defect this whole line of work exists to remove — a claim no one reads, which
59
+ * is how the field was dead in the first place (F1 of `rid-b2-qa`). Scope, in
60
+ * one sentence: **this map declares the evidence paths Gate C will check for
61
+ * this slice**, so an empty declaration is the honest one when there is no such
62
+ * gate.
63
+ *
64
+ * THE REJECTED ALTERNATIVE, named so it is not silently revisited: declaring
65
+ * all five unconditionally and teaching Gate C to check only the
66
+ * type-required subset. Rejected because the capsule would then carry
67
+ * permanent false statements about files that must never exist, and every
68
+ * reader of the frontmatter (peaks-qa's own checklist among them) would need
69
+ * the same type-conditioned filter to avoid mis-reading them.
70
+ *
71
+ * NO DERIVATION → NO DECLARATION. When there is no request artifact to read
72
+ * the type off, this returns `undefined` and the producers write no
73
+ * `gateEvidence` block at all. Never a partial map: a half-derived declaration
74
+ * is a false statement about the missing half's keys, and the pre-B2 bytes are
75
+ * the honest fallback. An id the artifact service REFUSES is not this case and
76
+ * is not reported as `undefined` — see `deriveGateEvidenceForRequest`.
77
+ */
78
+ import { type RequestType } from '../artifacts/artifact-prerequisites.js';
79
+ import { type GateEvidence } from './handoff-types.js';
80
+ /**
81
+ * The `gateEvidence` map for a slice of `requestType` — the evidence paths
82
+ * Gate C will check for it. Pure: no disk access, so a caller can compute it
83
+ * without side effects. Empty (`{}`) — never a partial map — when the type has
84
+ * no `rd:qa-handoff` row, because then there is no gate to declare anything to.
85
+ */
86
+ export declare function deriveGateEvidence(opts: {
87
+ readonly sessionId: string;
88
+ readonly requestId: string;
89
+ readonly requestType: RequestType;
90
+ }): GateEvidence;
91
+ /**
92
+ * Derive for a request by reading its PRD artifact — the only place the
93
+ * request TYPE is recorded (`- type: <t>`, written by `peaks request init`
94
+ * and read back by `request-artifact-service.extractMetadata`).
95
+ *
96
+ * Returns `undefined` when there is NO artifact to read — the one case the
97
+ * producers turn into "write no block" (see the header). That is now the ONLY
98
+ * `undefined`: a `showRequestArtifact` that THROWS is deliberately not caught,
99
+ * so a caller can tell "this slice has nothing to declare" apart from "the
100
+ * artifact could not be read".
101
+ *
102
+ * F4 (`rid-f4-ceiling-breach`). This used to be `catch { return undefined }`,
103
+ * which folded those two into one value — and the repository's own ratchet
104
+ * caught it (`capability-guard-runner/contracts/J03.ts`, rule
105
+ * `catch-return-null`; the ceiling was written for exactly this shape). Nothing
106
+ * is lost by letting the throw through. The refusals `showRequestArtifact` can
107
+ * raise are the rid/sid ones, and BOTH callers reach a byte-identical message
108
+ * on the next statement anyway — `assertSafeHandoffIds` (`handoff-service.ts`)
109
+ * for `prd handoff init`, `generateEvidence`'s own guard for the evidence
110
+ * generator — so the user-visible failure is unchanged; only its origin moves
111
+ * two lines earlier. What the catch DID cover in practice was an I/O failure
112
+ * while reading the artifact file, and swallowing that writes a capsule with no
113
+ * declaration at all, which Gate C reads as "nothing declared, nothing to
114
+ * check" (`field-absent` is not this check's business, below). A silent hole is
115
+ * worse than a loud, already-duplicated one.
116
+ */
117
+ export declare function deriveGateEvidenceForRequest(opts: {
118
+ readonly projectRoot: string;
119
+ readonly sessionId: string;
120
+ readonly requestId: string;
121
+ }): Promise<GateEvidence | undefined>;
122
+ /** `missing` / `warnings` in the same shape `checkPrerequisites` reports. */
123
+ export interface GateEvidenceDeclarationCheck {
124
+ readonly ok: boolean;
125
+ readonly missing: ReadonlyArray<{
126
+ path: string;
127
+ description: string;
128
+ }>;
129
+ readonly warnings: ReadonlyArray<{
130
+ path: string;
131
+ code: string;
132
+ message: string;
133
+ }>;
134
+ }
135
+ /**
136
+ * GATE C — is the capsule's OWN declaration true?
137
+ *
138
+ * The division of labour, stated so the two checks cannot drift: the
139
+ * prerequisite table says which artifacts this type MUST produce (it owns the
140
+ * requirement and its file existence); this function says the capsule must
141
+ * not CLAIM evidence it does not have (it owns the declaration). Neither
142
+ * re-implements the other, and both read their paths from wherever the path
143
+ * really comes from.
144
+ *
145
+ * A missing declared path FAILS and names the key, because a declaration the
146
+ * gate cannot verify is exactly the "prose pretending to be data" state this
147
+ * field was created to end. Outcomes that are not failures, each for its own
148
+ * reason:
149
+ * - no capsule → not this check's business (`AUDIT_REQUIRES_HANDOFF` owns it)
150
+ * - `field-absent` → the capsule declares nothing; every pre-B1 capsule,
151
+ * and every slice whose producer could not derive a type
152
+ * - an unknown key → a WARNING: the five-key vocabulary is a typo trap, and
153
+ * the type's real requirement is still enforced by the table, so this
154
+ * informs without blocking
155
+ */
156
+ export declare function checkDeclaredGateEvidence(opts: {
157
+ readonly projectRoot: string;
158
+ readonly sessionId: string;
159
+ readonly requestId: string;
160
+ }): Promise<GateEvidenceDeclarationCheck>;
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Where `gateEvidence` comes FROM, and where its promise is checked.
3
+ *
4
+ * B2 (merged with the B1 repair), rid `rid-b2-gate-evidence-wiring`.
5
+ *
6
+ * B1 made the field real at the SERVICE layer and QA found the hole that
7
+ * mattered (F1 of `rid-b1-qa`): all three frontmatter producers passed
8
+ * nothing, so no capsule on disk ever carried the field — the dead surface
9
+ * had moved one layer out, from "no producer function" to "no production
10
+ * caller". This module closes that by DERIVING the map instead of accepting
11
+ * it from a caller.
12
+ *
13
+ * WHY DERIVED, NOT PASSED IN. Every path in the map is a function of the
14
+ * session id, the request id and the REQUEST TYPE, all of which the producer
15
+ * already has. Asking the caller to supply the map would put a second source
16
+ * next to the derivation, and a CLI flag (`--gate-evidence <json>`) would
17
+ * additionally require the user to hand-author JSON, which the project's
18
+ * Human-NL-Choice-Only rule forbids. So: one function, computed, no flag.
19
+ *
20
+ * THE TYPE-AWARE TENSION, AND HOW IT IS RESOLVED. The schema doc
21
+ * (`skills/bee/peaks-rd/references/writing-handoff-frontmatter.md:53`) calls
22
+ * the five keys a schema and says "Missing keys → Gate C failure". Taken
23
+ * literally that demands all five paths in every capsule — and then a `docs`
24
+ * slice, which Gate C requires NO evidence from, would fail the gate for
25
+ * "declaring" `audit/perf-<rid>.md` and `audit/security-<rid>.md`, two files
26
+ * its type is not supposed to produce. Declaring evidence that does not and
27
+ * should not exist is a false claim, and a gate that fails on a false claim
28
+ * the writer invented is worse than useless.
29
+ *
30
+ * Resolution, and the reason it is not a special case: the map declares
31
+ * EXACTLY what this request type's transition gate requires, read from
32
+ * `PREREQUISITES_BY_TYPE` — the same table `checkPrerequisites` enforces at
33
+ * `rd:qa-handoff`. So:
34
+ *
35
+ * - feature / refactor → projectScan, prdHandoff, codeReview,
36
+ * securityReview, perfBaseline (all five)
37
+ * - bugfix → the same five
38
+ * - config → projectScan, securityReview (TWO keys, verified
39
+ * against the table rather than assumed — `CONFIG_TABLE['rd:qa-handoff']`
40
+ * is `[SECURITY_REVIEW]` alone, so there is no `prdHandoff`: config has no
41
+ * `AUDIT_REQUIRES_HANDOFF` row. An earlier revision of this comment
42
+ * claimed three, and QA caught prose that disagreed with the code, which
43
+ * is the defect this module exists to end. The security path declared is
44
+ * the config-specific `rd/security-review.md` — genuinely ridless, per its
45
+ * own row — not the documented `audit/security-<rid>.md`, which no config
46
+ * slice writes)
47
+ * - docs / chore → NOTHING. No `rd:qa-handoff` row ⇒ nothing for Gate C
48
+ * to validate ⇒ nothing declared (see below)
49
+ *
50
+ * WHY `projectScan` IS DECLARED, AND WHY ONLY FOR GATED TYPES. It is not read
51
+ * from the table — it is Gate A's artifact, and Gate A is type-independent
52
+ * ("After workspace init + project scan", step 0.6 of every workflow). But a
53
+ * declaration is only worth making when something VERIFIES it, and the only
54
+ * verifier is `checkDeclaredGateEvidence`, which runs on `rd:qa-handoff`. For a
55
+ * type with a gate row, Gate C therefore does check this path like any other.
56
+ * For `docs`/`chore` nothing ever reads it, so it is not declared: keeping an
57
+ * inert statement in every docs capsule would recreate, in miniature, the exact
58
+ * defect this whole line of work exists to remove — a claim no one reads, which
59
+ * is how the field was dead in the first place (F1 of `rid-b2-qa`). Scope, in
60
+ * one sentence: **this map declares the evidence paths Gate C will check for
61
+ * this slice**, so an empty declaration is the honest one when there is no such
62
+ * gate.
63
+ *
64
+ * THE REJECTED ALTERNATIVE, named so it is not silently revisited: declaring
65
+ * all five unconditionally and teaching Gate C to check only the
66
+ * type-required subset. Rejected because the capsule would then carry
67
+ * permanent false statements about files that must never exist, and every
68
+ * reader of the frontmatter (peaks-qa's own checklist among them) would need
69
+ * the same type-conditioned filter to avoid mis-reading them.
70
+ *
71
+ * NO DERIVATION → NO DECLARATION. When there is no request artifact to read
72
+ * the type off, this returns `undefined` and the producers write no
73
+ * `gateEvidence` block at all. Never a partial map: a half-derived declaration
74
+ * is a false statement about the missing half's keys, and the pre-B2 bytes are
75
+ * the honest fallback. An id the artifact service REFUSES is not this case and
76
+ * is not reported as `undefined` — see `deriveGateEvidenceForRequest`.
77
+ */
78
+ import { join } from 'node:path';
79
+ import { pathExists } from 'peaks-loop-shared/fs';
80
+ import { getPrerequisitesFor } from '../artifacts/artifact-prerequisites.js';
81
+ import { showRequestArtifact } from '../artifacts/request-artifact-service.js';
82
+ import { readHandoffGateEvidence } from './handoff-gate-evidence.js';
83
+ import { resolveHandoffPath } from './handoff-service.js';
84
+ import { GATE_EVIDENCE_KEYS } from './handoff-types.js';
85
+ import { PROJECT_SCAN_RELATIVE } from './project-scan-reader.js';
86
+ /**
87
+ * Map ONE prerequisite of the `rd:qa-handoff` table onto the five-key
88
+ * vocabulary. The PATHS come from the table (single source); this function
89
+ * only names which key each required artifact answers to, so a change to a
90
+ * prerequisite's path moves the declared path with it.
91
+ *
92
+ * `mut/mut-report.json`, `rd/karpathy-review-<rid>.md`,
93
+ * `rd/third-party-review.md`, `qa/test-cases/<rid>.md` and the unit-test
94
+ * marker have no key — the vocabulary is five keys, and those artifacts are
95
+ * already enforced by the table itself, so they are deliberately not
96
+ * declared here rather than being forced into a key that does not mean them.
97
+ */
98
+ function gateKeyOfPrerequisitePath(relativePath) {
99
+ if (relativePath.startsWith('prd/handoff'))
100
+ return 'prdHandoff';
101
+ if (relativePath.startsWith('rd/code-review'))
102
+ return 'codeReview';
103
+ if (relativePath.startsWith('audit/security') || relativePath.startsWith('rd/security-review')) {
104
+ return 'securityReview';
105
+ }
106
+ if (relativePath.startsWith('audit/perf'))
107
+ return 'perfBaseline';
108
+ return null;
109
+ }
110
+ /**
111
+ * The `gateEvidence` map for a slice of `requestType` — the evidence paths
112
+ * Gate C will check for it. Pure: no disk access, so a caller can compute it
113
+ * without side effects. Empty (`{}`) — never a partial map — when the type has
114
+ * no `rd:qa-handoff` row, because then there is no gate to declare anything to.
115
+ */
116
+ export function deriveGateEvidence(opts) {
117
+ const requirements = getPrerequisitesFor('rd', 'qa-handoff', opts.requestType);
118
+ // docs / chore land here: `MINIMAL_TABLE` has no row at that transition, so
119
+ // nothing is required and nothing is declared. An empty map renders no block
120
+ // at all (`handoff-frontmatter.ts`), so such a capsule is byte-identical to a
121
+ // pre-B2 one and carries no statement that nothing verifies.
122
+ if (requirements.length === 0)
123
+ return {};
124
+ const sessionRoot = join('.peaks', '_runtime', opts.sessionId);
125
+ const evidence = {
126
+ projectScan: PROJECT_SCAN_RELATIVE
127
+ };
128
+ for (const prerequisite of requirements) {
129
+ const key = gateKeyOfPrerequisitePath(prerequisite.relativePath);
130
+ if (key === null)
131
+ continue;
132
+ // `<rid>` is the table's own placeholder, resolved the way the table's
133
+ // resolver resolves it, so the declaration names the file the gate looks
134
+ // for.
135
+ evidence[key] = join(sessionRoot, prerequisite.relativePath.replace('<rid>', opts.requestId));
136
+ }
137
+ return evidence;
138
+ }
139
+ /**
140
+ * Derive for a request by reading its PRD artifact — the only place the
141
+ * request TYPE is recorded (`- type: <t>`, written by `peaks request init`
142
+ * and read back by `request-artifact-service.extractMetadata`).
143
+ *
144
+ * Returns `undefined` when there is NO artifact to read — the one case the
145
+ * producers turn into "write no block" (see the header). That is now the ONLY
146
+ * `undefined`: a `showRequestArtifact` that THROWS is deliberately not caught,
147
+ * so a caller can tell "this slice has nothing to declare" apart from "the
148
+ * artifact could not be read".
149
+ *
150
+ * F4 (`rid-f4-ceiling-breach`). This used to be `catch { return undefined }`,
151
+ * which folded those two into one value — and the repository's own ratchet
152
+ * caught it (`capability-guard-runner/contracts/J03.ts`, rule
153
+ * `catch-return-null`; the ceiling was written for exactly this shape). Nothing
154
+ * is lost by letting the throw through. The refusals `showRequestArtifact` can
155
+ * raise are the rid/sid ones, and BOTH callers reach a byte-identical message
156
+ * on the next statement anyway — `assertSafeHandoffIds` (`handoff-service.ts`)
157
+ * for `prd handoff init`, `generateEvidence`'s own guard for the evidence
158
+ * generator — so the user-visible failure is unchanged; only its origin moves
159
+ * two lines earlier. What the catch DID cover in practice was an I/O failure
160
+ * while reading the artifact file, and swallowing that writes a capsule with no
161
+ * declaration at all, which Gate C reads as "nothing declared, nothing to
162
+ * check" (`field-absent` is not this check's business, below). A silent hole is
163
+ * worse than a loud, already-duplicated one.
164
+ */
165
+ export async function deriveGateEvidenceForRequest(opts) {
166
+ // F4 (`rid-f4-ceiling-breach`). The throw from `showRequestArtifact` is
167
+ // propagated, so a caller can tell "this slice has nothing to declare"
168
+ // (artifact missing → `artifact === null` → `requestType === null` →
169
+ // `undefined`) apart from "the artifact could not be read" (an id the
170
+ // service refuses → rejected with `Invalid request id: ...`). The old
171
+ // `catch { return undefined }` collapsed both into the same value, and the
172
+ // `catch-return-null` ratchet caught it. The refusals `showRequestArtifact`
173
+ // raises are the rid / sid ones, and both callers reach a byte-identical
174
+ // guard a few lines up — `assertSafeHandoffIds` for `prd handoff init`,
175
+ // `generateEvidence`'s own guard for the evidence generator — so the
176
+ // user-visible failure is unchanged; only its origin moves earlier.
177
+ const artifact = await showRequestArtifact({
178
+ projectRoot: opts.projectRoot,
179
+ role: 'prd',
180
+ requestId: opts.requestId,
181
+ sessionId: opts.sessionId
182
+ });
183
+ const requestType = artifact?.requestType ?? null;
184
+ if (requestType === null)
185
+ return undefined;
186
+ return deriveGateEvidence({
187
+ sessionId: opts.sessionId,
188
+ requestId: opts.requestId,
189
+ requestType
190
+ });
191
+ }
192
+ /**
193
+ * GATE C — is the capsule's OWN declaration true?
194
+ *
195
+ * The division of labour, stated so the two checks cannot drift: the
196
+ * prerequisite table says which artifacts this type MUST produce (it owns the
197
+ * requirement and its file existence); this function says the capsule must
198
+ * not CLAIM evidence it does not have (it owns the declaration). Neither
199
+ * re-implements the other, and both read their paths from wherever the path
200
+ * really comes from.
201
+ *
202
+ * A missing declared path FAILS and names the key, because a declaration the
203
+ * gate cannot verify is exactly the "prose pretending to be data" state this
204
+ * field was created to end. Outcomes that are not failures, each for its own
205
+ * reason:
206
+ * - no capsule → not this check's business (`AUDIT_REQUIRES_HANDOFF` owns it)
207
+ * - `field-absent` → the capsule declares nothing; every pre-B1 capsule,
208
+ * and every slice whose producer could not derive a type
209
+ * - an unknown key → a WARNING: the five-key vocabulary is a typo trap, and
210
+ * the type's real requirement is still enforced by the table, so this
211
+ * informs without blocking
212
+ */
213
+ export async function checkDeclaredGateEvidence(opts) {
214
+ const capsule = resolveHandoffPath({
215
+ projectRoot: opts.projectRoot,
216
+ sessionId: opts.sessionId,
217
+ requestId: opts.requestId
218
+ });
219
+ if (capsule === null)
220
+ return { ok: true, missing: [], warnings: [] };
221
+ const declared = await readHandoffGateEvidence(capsule);
222
+ if (declared.status === 'field-absent' || declared.status === 'file-missing') {
223
+ return { ok: true, missing: [], warnings: [] };
224
+ }
225
+ // A capsule with NO frontmatter block cannot declare anything, and
226
+ // `AUDIT_REQUIRES_HANDOFF` — the prereq that owns capsule validity — accepts
227
+ // that shape on a substring check (`schemaVersion: 2` + `sha256:` appearing
228
+ // anywhere). Failing it here would make Gate C refuse capsules the gate next
229
+ // to it accepts, breaking the "the checker and `request transition` agree"
230
+ // property that `pipeline-verify-contract-drift.test.ts` pins. So the line is
231
+ // drawn at the fence, not at readability: no fence ⇒ nothing declared;
232
+ // a fence whose YAML will not parse ⇒ a declaration that may be inside and
233
+ // unreadable, and THAT fails below.
234
+ if (declared.status === 'frontmatter-malformed' && declared.reason === 'no-frontmatter-fence') {
235
+ return { ok: true, missing: [], warnings: [] };
236
+ }
237
+ if (declared.status !== 'ok') {
238
+ return {
239
+ ok: false,
240
+ missing: [
241
+ {
242
+ path: `gateEvidence(${declared.status})`,
243
+ description: `the handoff's gateEvidence declaration is unusable (${declared.status}) — ` +
244
+ 'a declaration that cannot be read is not the same as no declaration'
245
+ }
246
+ ],
247
+ warnings: []
248
+ };
249
+ }
250
+ const missing = [];
251
+ for (const key of GATE_EVIDENCE_KEYS) {
252
+ const declaredPath = declared.evidence[key];
253
+ if (declaredPath === undefined)
254
+ continue;
255
+ if (await pathExists(join(opts.projectRoot, declaredPath)))
256
+ continue;
257
+ missing.push({
258
+ path: `gateEvidence.${key}`,
259
+ description: `gateEvidence.${key} declares "${declaredPath}", which does not exist`
260
+ });
261
+ }
262
+ const warnings = declared.unknownKeys.map((key) => ({
263
+ path: `gateEvidence.${key}`,
264
+ code: 'gate-evidence-unknown-key',
265
+ message: `gateEvidence declares "${key}", which is not one of the five keys ` +
266
+ `(${GATE_EVIDENCE_KEYS.join(', ')}) — a misspelled key is why a required gate ` +
267
+ 'can read as undeclared'
268
+ }));
269
+ return { ok: missing.length === 0, missing, warnings };
270
+ }
@@ -21,6 +21,7 @@ import { dirname, join } from 'node:path';
21
21
  import { createHash } from 'node:crypto';
22
22
  import { showRequestArtifact } from '../artifacts/request-artifact-service.js';
23
23
  import { serializeHandoffFrontmatter } from './handoff-frontmatter.js';
24
+ import { deriveGateEvidence } from './gate-evidence-derivation.js';
24
25
  import { handoffRelativePath, sha256OfBody } from './handoff-service.js';
25
26
  import { normalizePath } from '../../shared/path-utils.js';
26
27
  /**
@@ -64,7 +65,18 @@ export async function autoRegenPrdHandoff(opts) {
64
65
  goals: [],
65
66
  acceptanceCriteria: [],
66
67
  preservedBehavior: [],
67
- handoffPath: normalizePath(handoffPath.replace(opts.projectRoot, '')).replace(/^\//, '')
68
+ handoffPath: normalizePath(handoffPath.replace(opts.projectRoot, '')).replace(/^\//, ''),
69
+ // B2: DERIVED from the request type of the artifact this producer just
70
+ // read. B1 gave this function an optional caller-supplied map instead;
71
+ // with a single-source derivation available that option was the wrong
72
+ // shape — it left the one production caller (`request-commands.ts`) able
73
+ // to pass nothing, which is exactly the F1 hole. There is no input to
74
+ // forget now.
75
+ gateEvidence: deriveGateEvidence({
76
+ sessionId: opts.sessionId,
77
+ requestId: opts.requestId,
78
+ requestType: artifact.requestType
79
+ })
68
80
  };
69
81
  const content = `${serializeHandoffFrontmatter(frontmatter)}${body}`;
70
82
  mkdirSync(dirname(handoffPath), { recursive: true });
@@ -35,7 +35,7 @@
35
35
  * parses the block through YAML and requires a STRING, and a bare all-digit
36
36
  * sha256 would parse as a YAML number and be refused by the shape check.
37
37
  */
38
- import type { HandoffFrontmatter } from './handoff-types.js';
38
+ import { type HandoffFrontmatter } from './handoff-types.js';
39
39
  /**
40
40
  * Serialize `frontmatter` into the fenced block, terminated by the closing
41
41
  * `---` and a trailing newline. Callers append the body verbatim, which keeps
@@ -35,6 +35,7 @@
35
35
  * parses the block through YAML and requires a STRING, and a bare all-digit
36
36
  * sha256 would parse as a YAML number and be refused by the shape check.
37
37
  */
38
+ import { GATE_EVIDENCE_KEYS, isGateEvidenceKey } from './handoff-types.js';
38
39
  /** Render a string as a YAML double-quoted scalar. JSON string escapes are a
39
40
  * subset of YAML 1.2's double-quoted escapes, so this is valid YAML and
40
41
  * handles `\` (Windows paths), quotes, colons and newlines in one step. */
@@ -47,6 +48,61 @@ function blockSequence(key, values) {
47
48
  return [`${key}: []`];
48
49
  return [`${key}:`, ...values.map((value) => ` - ${yamlScalar(value)}`)];
49
50
  }
51
+ /**
52
+ * Render `gateEvidence` as a YAML map of quoted path scalars, or as NOTHING.
53
+ *
54
+ * Two shape decisions, both load-bearing:
55
+ *
56
+ * - **Omitted when absent or empty.** An absent map and an empty map both
57
+ * render zero lines, so a handoff that declares no evidence is
58
+ * byte-identical to a pre-B1 capsule. `gateEvidence: {}` would add a line
59
+ * to every handoff in the repo to say nothing — and the `each key exactly
60
+ * once` assertions in `handoff-auto-regen.test.ts` /
61
+ * `handoff-writer-gate-convergence.test.ts` pin the emitted key SET.
62
+ * - **Canonical key order** (`GATE_EVIDENCE_KEYS`), NOT insertion order of
63
+ * the caller's object. The frontmatter is sha256-adjacent and read by
64
+ * substring/regex consumers; a caller that built its map `{perfBaseline,
65
+ * projectScan}` must not produce different bytes from one that built it
66
+ * the other way round.
67
+ *
68
+ * Values go through `yamlScalar` because these are PATHS: on Windows they
69
+ * contain backslashes, which a plain YAML scalar would escape.
70
+ */
71
+ function gateEvidenceBlock(evidence) {
72
+ if (evidence === undefined)
73
+ return [];
74
+ // F3 of `rid-b1-qa`: this function used to iterate only the five known keys,
75
+ // so anything else was dropped WITHOUT A TRACE — `initHandoff({gateEvidence:
76
+ // {projectScans: 'typo.md'}})` wrote no block at all and the capsule then
77
+ // read back as `field-absent`, i.e. as if nothing had ever been declared.
78
+ // The type system blocks literal typos, but not a value that arrived through
79
+ // `JSON.parse`, an `as` assertion, or a JS caller — and the derivation added
80
+ // in B2 is exactly such an adapter. So the check is runtime, and it is HERE
81
+ // because this serializer is the single funnel every producer passes
82
+ // through: one check covers all three writers and any future one.
83
+ const provided = Object.entries(evidence);
84
+ const unknownKeys = provided
85
+ .map(([key]) => key)
86
+ .filter((key) => !isGateEvidenceKey(key))
87
+ .sort();
88
+ if (unknownKeys.length > 0) {
89
+ throw new Error(`handoff: unknown gateEvidence key(s) [${unknownKeys.join(', ')}]; expected one of ` +
90
+ `${GATE_EVIDENCE_KEYS.join(', ')} — refusing to write a declaration that would be ` +
91
+ 'dropped silently');
92
+ }
93
+ const nonStringKeys = provided
94
+ .filter(([, value]) => typeof value !== 'string')
95
+ .map(([key]) => key)
96
+ .sort();
97
+ if (nonStringKeys.length > 0) {
98
+ throw new Error(`handoff: gateEvidence value(s) for [${nonStringKeys.join(', ')}] must be strings (evidence paths)`);
99
+ }
100
+ const entries = GATE_EVIDENCE_KEYS.flatMap((key) => {
101
+ const value = evidence[key];
102
+ return value === undefined ? [] : [` ${key}: ${yamlScalar(value)}`];
103
+ });
104
+ return entries.length === 0 ? [] : ['gateEvidence:', ...entries];
105
+ }
50
106
  /**
51
107
  * Serialize `frontmatter` into the fenced block, terminated by the closing
52
108
  * `---` and a trailing newline. Callers append the body verbatim, which keeps
@@ -69,6 +125,12 @@ export function serializeHandoffFrontmatter(frontmatter) {
69
125
  ...blockSequence('acceptanceCriteria', frontmatter.acceptanceCriteria),
70
126
  ...blockSequence('preservedBehavior', frontmatter.preservedBehavior),
71
127
  `handoffPath: ${yamlScalar(frontmatter.handoffPath)}`,
128
+ // LAST, after every anchored field: `schemaVersion` / `sha256` are
129
+ // matched as `^`-anchored lines by the gate and both audit loaders, so
130
+ // nothing new may be inserted before them. A nested map also renders
131
+ // indented lines only, leaving the top-level key set exactly as it was
132
+ // for every capsule that declares no evidence.
133
+ ...gateEvidenceBlock(frontmatter.gateEvidence),
72
134
  '---',
73
135
  ];
74
136
  return `${lines.join('\n')}\n`;
@@ -1 +1,95 @@
1
- export declare function readHandoffGateEvidence(filePath: string): Promise<readonly string[] | null>;
1
+ import { type GateEvidence } from './handoff-types.js';
2
+ /**
3
+ * Why a `gateEvidence` read produced what it produced.
4
+ *
5
+ * `ok` is the only success. `field-absent` is NOT a failure — a capsule with
6
+ * no declaration is a valid capsule (every pre-B1 handoff) — while
7
+ * `field-not-map` and `value-not-string` are declarations that exist and are
8
+ * broken, and `file-missing` / `read-error` / `frontmatter-malformed` mean
9
+ * nothing could be read at all. Collapsing any two of these is the defect
10
+ * this reader was rewritten to remove.
11
+ */
12
+ export type HandoffGateEvidenceStatus = 'ok' | 'file-missing' | 'read-error' | 'frontmatter-malformed' | 'field-absent' | 'field-not-map' | 'value-not-string';
13
+ /**
14
+ * Discriminated result. `status` alone decides the branch, so a caller that
15
+ * only wants "is there a usable declaration?" checks `result.status === 'ok'`
16
+ * and reads `result.evidence` — and a caller that must refuse corrupt
17
+ * declarations gets `keys` / `actualType` / `reason` to name the problem in
18
+ * its failure message.
19
+ */
20
+ export type HandoffGateEvidenceResult = {
21
+ readonly status: 'ok';
22
+ /** Only the five known keys. Absent key = not declared. An EMPTY map
23
+ * on disk yields an empty `evidence`, not an error: the field was
24
+ * written and declares nothing, which is a different fact from
25
+ * `field-absent` and is reported as such. */
26
+ readonly evidence: GateEvidence;
27
+ /** Keys present in the YAML that are not one of the five. Sorted.
28
+ * Reported rather than dropped: a typo'd key is why a required gate
29
+ * reads as "not declared", and this is the only place that can say so.
30
+ * The serializer cannot emit these, so they are hand-authored only. */
31
+ readonly unknownKeys: readonly string[];
32
+ } | {
33
+ readonly status: 'file-missing';
34
+ } | {
35
+ readonly status: 'read-error';
36
+ readonly reason: string;
37
+ } | {
38
+ readonly status: 'frontmatter-malformed';
39
+ readonly reason: string;
40
+ } | {
41
+ readonly status: 'field-absent';
42
+ }
43
+ /** `actualType` is the YAML type found (`array`, `null`, `string`, …) —
44
+ * `array` is the pre-B1 shape, present in old test fixtures only. */
45
+ | {
46
+ readonly status: 'field-not-map';
47
+ readonly actualType: string;
48
+ }
49
+ /** At least one value was not a string; `keys` names every offender. The
50
+ * whole declaration is refused rather than filtered down to its good
51
+ * entries — dropping a malformed value is the collapse this replaced. */
52
+ | {
53
+ readonly status: 'value-not-string';
54
+ readonly keys: readonly string[];
55
+ };
56
+ /**
57
+ * THE shape rule for a `gateEvidence` value — one home, two callers.
58
+ *
59
+ * F2 of `rid-b1-qa` found this rule written twice (`handoff-service.ts`'s
60
+ * `isGateEvidenceMap`, added by B1, and this module's own inline checks) with
61
+ * the SAME accept/reject boundary but DIFFERENT results: the same bytes
62
+ * produced a map WITH unknown keys through `readHandoff` and a map WITHOUT
63
+ * them through the reader, and `gateEvidence:` (null) threw on one path and
64
+ * reported a status on the other. That falsified the invariant
65
+ * `handoff-service.ts` stated about itself. Both paths now call this, and
66
+ * only the REFUSAL MECHANISM differs (a status vs a throw) — which is the
67
+ * pre-existing, documented policy split between the throwing `readHandoff`
68
+ * and the permissive reader, not a second opinion about the shape.
69
+ *
70
+ * `absent` is a first-class kind rather than an error: a capsule that
71
+ * declares nothing is valid, and (invariant 1 of B1) every pre-B1 handoff
72
+ * is one.
73
+ */
74
+ export type GateEvidenceShape = {
75
+ readonly kind: 'absent';
76
+ } | {
77
+ readonly kind: 'not-map';
78
+ readonly actualType: string;
79
+ } | {
80
+ readonly kind: 'value-not-string';
81
+ readonly keys: readonly string[];
82
+ } | {
83
+ readonly kind: 'ok';
84
+ readonly evidence: GateEvidence;
85
+ readonly unknownKeys: readonly string[];
86
+ };
87
+ /** Classify a `gateEvidence` VALUE (already located — see the reader for the
88
+ * fence/YAML/presence layers, which are file-level concerns, not shape). */
89
+ export declare function classifyGateEvidence(value: unknown): GateEvidenceShape;
90
+ /**
91
+ * Read + classify the `gateEvidence` map out of `filePath`. Never throws.
92
+ * See the header for the permissive contract and
93
+ * `HandoffGateEvidenceResult` for the outcomes.
94
+ */
95
+ export declare function readHandoffGateEvidence(filePath: string): Promise<HandoffGateEvidenceResult>;