peaks-loop 4.0.52 → 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 (46) hide show
  1. package/CHANGELOG.md +89 -2
  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/dispatch/sub-agent-dispatcher.d.ts +0 -1
  17. package/dist/services/dispatch/sub-agent-dispatcher.js +0 -3
  18. package/dist/services/evidence/evidence-generator.js +11 -3
  19. package/dist/services/memory/project-memory-service/index.d.ts +1 -1
  20. package/dist/services/memory/project-memory-service/index.js +1 -1
  21. package/dist/services/memory/project-memory-service/store/atomic-write.d.ts +59 -0
  22. package/dist/services/memory/project-memory-service/store/atomic-write.js +167 -7
  23. package/dist/services/prd/gate-evidence-derivation.d.ts +160 -0
  24. package/dist/services/prd/gate-evidence-derivation.js +270 -0
  25. package/dist/services/prd/handoff-auto-regen.js +13 -1
  26. package/dist/services/prd/handoff-frontmatter.d.ts +1 -1
  27. package/dist/services/prd/handoff-frontmatter.js +62 -0
  28. package/dist/services/prd/handoff-gate-evidence.d.ts +95 -0
  29. package/dist/services/prd/handoff-gate-evidence.js +145 -0
  30. package/dist/services/prd/handoff-service.d.ts +21 -1
  31. package/dist/services/prd/handoff-service.js +45 -2
  32. package/dist/services/prd/handoff-types.d.ts +40 -0
  33. package/dist/services/prd/handoff-types.js +28 -1
  34. package/dist/services/prd/project-scan-reader.d.ts +7 -0
  35. package/dist/services/prd/project-scan-reader.js +7 -1
  36. package/dist/services/rd/reviewer-dispatch-policy.d.ts +36 -8
  37. package/dist/services/rd/reviewer-dispatch-policy.js +36 -8
  38. package/package.json +6 -6
  39. package/skills/bee/peaks-rd/SKILL.md +1 -1
  40. package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +26 -10
  41. package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +1 -1
  42. package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +6 -1
  43. package/skills/peaks-code/references/periodic-checkpoint.md +7 -5
  44. package/skills/peaks-code/references/startup-sequence.md +1 -1
  45. package/skills/peaks-final-review/SKILL.md +1 -1
  46. /package/{docs → contracts}/test-style-contract.md +0 -0
@@ -5,9 +5,13 @@
5
5
  // PEM private keys, JWTs, GitHub / GitLab tokens, AWS access keys. Used
6
6
  // both by the extract path (`assertSafeMemory`) and the backup path
7
7
  // (`assertSafeMemoryFileContent`).
8
+ // - `findSensitiveMemoryTitleTerm` — the PROSE predicate for
9
+ // `memory.title`. It is deliberately NOT the config-key predicate: see
10
+ // the block comment above it (slice C0).
8
11
  // - `assertSafeMemory` — full safety gate applied during extraction.
9
- // Combines title/body content scan + config-service secret check +
10
- // sensitive-path check on the title.
12
+ // Combines the metadata key scan + the content pattern scan + the title
13
+ // scan. Each failure names the check that fired, and the title one names
14
+ // the term it matched.
11
15
  // - `assertSafeMemoryFileContent` — lighter version for backup: just
12
16
  // the content pattern scan, since the file already lives in
13
17
  // `.peaks/memory/` and was authored through the normal pipeline.
@@ -17,7 +21,109 @@
17
21
  // for index.json regeneration.
18
22
  // ---------------------------------------------------------------------------
19
23
  import { closeSync, constants, openSync, writeFileSync } from 'node:fs';
20
- import { containsSensitiveConfigValue, isSensitiveConfigPath } from '../../../config/config-service.js';
24
+ import { containsSensitiveConfigValue } from '../../../config/config-service.js';
25
+ /**
26
+ * The names of the three checks `assertSafeMemory` can fail on, written once.
27
+ *
28
+ * The name of the check that fired rides the refusal message, so the reader is
29
+ * told WHICH rule stopped the write instead of only that something was
30
+ * refused. Callers route their remedy on these same constants (see
31
+ * `memoryExtractNextActions` in `src/cli/commands/core/memory-command.ts`) —
32
+ * two strings kept equal by hand is how a message and its advice drift apart.
33
+ */
34
+ export const SENSITIVE_MEMORY_CHECKS = {
35
+ metadataKey: 'metadata key scan',
36
+ content: 'content scan',
37
+ title: 'title scan'
38
+ };
39
+ /** The stable prefix of every refusal this module raises on the extract path. */
40
+ const SENSITIVE_MEMORY_REFUSAL = 'Refusing to store sensitive memory content';
41
+ /**
42
+ * Credential TERMS as they appear in prose — the word-level counterpart of the
43
+ * config-key predicate `config-service.isSensitiveConfigPath`.
44
+ *
45
+ * WHY THE TITLE CHECK EXISTS AT ALL (it is not redundant with the content
46
+ * scanner). `hasSensitiveMemoryContent` looks for a credential *value*: its
47
+ * first pattern needs a `:` or `=`, so a title that is nothing but `apiKey`
48
+ * carries no value and slips past it. The title scan is the only rule that can
49
+ * see "the title IS a credential name" — so this check was kept, and only its
50
+ * predicate was replaced.
51
+ *
52
+ * WHY IT DOES NOT REUSE `isSensitiveConfigPath`. That predicate answers "is
53
+ * this a CONFIG KEY", and it answers by SUBSTRING: `includes('auth')` is true
54
+ * for `authority`, `author`, `unauthorized`. On a config key that breadth is
55
+ * harmless — an auth-bearing key IS a credential key, and the config domain is
56
+ * untouched here. On a prose title it is a false refusal the author cannot see
57
+ * the cause of: slice C0, where `peaks memory extract` refused a memory titled
58
+ * "Derive from the authority, never re-declare it" and told the user to
59
+ * "remove secrets" from a memory that had none.
60
+ *
61
+ * HOW IT MATCHES. The title is cut into alphanumeric segments (camelCase
62
+ * boundaries included) and a match is a CONTIGUOUS RUN of segments whose
63
+ * concatenation is one of the terms below. Runs — not single words — are what
64
+ * keep the credential-name spellings a substring check caught and a naive
65
+ * word-per-word check would lose: `private_key`, `api key`, `myApiKey` and
66
+ * `access token` all still match, while `authority`, `author`, `tokenizer`,
67
+ * `secretary` and `credentialed` do not.
68
+ *
69
+ * The residual narrowing is named rather than hidden: an UNSEPARATED compound
70
+ * with a term buried mid-word (`mysecretstuff`) no longer matches. That is the
71
+ * same class as the false refusals above — a word containing `secret` is not a
72
+ * credential term — and the memory body is scanned for values either way.
73
+ *
74
+ * The plural forms are listed explicitly instead of deriving them by stripping
75
+ * a trailing `s` from each match: that rule would turn `secretaries` into
76
+ * `secretarie` → `secret`, which is precisely the substring confusion this
77
+ * predicate exists to end.
78
+ */
79
+ const SENSITIVE_PROSE_TERMS = new Set([
80
+ 'apikey', 'apikeys',
81
+ 'accesskey', 'accesskeys',
82
+ 'privatekey', 'privatekeys',
83
+ 'secretkey', 'secretkeys',
84
+ 'accesstoken', 'accesstokens',
85
+ 'authtoken', 'authtokens',
86
+ 'authkey', 'authkeys',
87
+ 'refreshtoken', 'refreshtokens',
88
+ 'token', 'tokens',
89
+ 'secret', 'secrets',
90
+ 'password', 'passwords',
91
+ 'passwd',
92
+ 'bearer',
93
+ 'credential', 'credentials'
94
+ ]);
95
+ /** A lower→upper transition: the boundary between the words of `apiKey`. */
96
+ const CAMEL_CASE_BOUNDARY = /([a-z0-9])([A-Z])/g;
97
+ /** `myApiKey` → `['my', 'api', 'key']`; `private_key` → `['private', 'key']`. */
98
+ function proseSegments(text) {
99
+ return text
100
+ .replace(CAMEL_CASE_BOUNDARY, '$1 $2')
101
+ .toLowerCase()
102
+ .split(/[^a-z0-9]+/)
103
+ .filter((segment) => segment.length > 0);
104
+ }
105
+ /**
106
+ * The credential term this title contains (as a run of words), or `null`.
107
+ *
108
+ * Returns the MATCHED TERM rather than a boolean because the refusal message
109
+ * names it: `title scan matched the credential term "apikey"` is actionable,
110
+ * while "Refusing to store sensitive memory content" is what sent slice C0's
111
+ * user looking for a secret that was not there. The returned string is a
112
+ * dictionary word from the list above — never the title's own text, and never
113
+ * a value — so echoing it into the error envelope cannot leak anything.
114
+ */
115
+ export function findSensitiveMemoryTitleTerm(title) {
116
+ const segments = proseSegments(title);
117
+ for (let start = 0; start < segments.length; start += 1) {
118
+ let run = '';
119
+ for (let end = start; end < segments.length; end += 1) {
120
+ run += segments[end];
121
+ if (SENSITIVE_PROSE_TERMS.has(run))
122
+ return run;
123
+ }
124
+ }
125
+ return null;
126
+ }
21
127
  export function hasSensitiveMemoryContent(content) {
22
128
  return /(?:api[_-]?key|token|secret|password|credential|bearer)\s*[:=]/i.test(content)
23
129
  || /\bauthorization\s*:\s*bearer\s+\S+/i.test(content)
@@ -30,14 +136,68 @@ export function hasSensitiveMemoryContent(content) {
30
136
  || /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(content)
31
137
  || /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/.test(content);
32
138
  }
139
+ /**
140
+ * The refusal `assertSafeMemory` raises: the check that fired and the term it
141
+ * matched travel as DATA, not only as prose.
142
+ *
143
+ * WHY A TYPE RATHER THAN A MESSAGE. The envelope redactor behind every
144
+ * `fail()` (`packages/peaks-loop-shared/src/result.ts`) strips the words
145
+ * secret / token / password / api-key out of every failure MESSAGE — a blanket
146
+ * rule, applied to messages this module does not own. So a message that names
147
+ * the matched term reaches the CLI reading `… the credential term
148
+ * "[redacted]" …`: the term is named, and then removed, on the way out.
149
+ *
150
+ * The term is a word from `SENSITIVE_PROSE_TERMS` — a fixed vocabulary, never a
151
+ * value — so there is nothing about it to redact, and it rides a field
152
+ * instead: `fail()` redacts `message` only. The CLI routes its remedy on
153
+ * `check` for the same reason — a substring test against a message whose words
154
+ * can be rewritten is not a routing rule, it is a guess.
155
+ *
156
+ * NOTHING HERE WEAKENS THE REDACTOR. The content scan never puts its match in
157
+ * this object (see `matchedTerm` below), so no value can reach an envelope
158
+ * through it; the redactor's guarantee is intact and is pinned by the cases in
159
+ * `tests/unit/services/memory/memory-title-sensitive-scan.test.ts`.
160
+ */
161
+ export class UnsafeMemoryError extends Error {
162
+ /** One of `SENSITIVE_MEMORY_CHECKS` — which rule refused the write. */
163
+ check;
164
+ /**
165
+ * The credential term the TITLE scan matched, or `null` for the other two
166
+ * checks. `null` is not an omission: their match can BE the credential
167
+ * (`ghp_…`, the PEM header, the JWT), which is why their messages describe
168
+ * the pattern family and never echo the text.
169
+ */
170
+ matchedTerm;
171
+ constructor(check, detail, matchedTerm = null) {
172
+ super(`${SENSITIVE_MEMORY_REFUSAL}: ${check} ${detail}.`);
173
+ this.name = 'UnsafeMemoryError';
174
+ this.check = check;
175
+ this.matchedTerm = matchedTerm;
176
+ }
177
+ }
33
178
  export function assertSafeMemory(memory) {
34
179
  const content = `${memory.title}\n${memory.kind}\n${memory.body}`;
35
180
  const metadata = { title: memory.title, kind: memory.kind, body: memory.body };
36
- if (containsSensitiveConfigValue(metadata) || hasSensitiveMemoryContent(content)) {
37
- throw new Error('Refusing to store sensitive memory content');
181
+ if (containsSensitiveConfigValue(metadata)) {
182
+ throw new UnsafeMemoryError(SENSITIVE_MEMORY_CHECKS.metadataKey, 'matched a credential key in the memory metadata');
183
+ }
184
+ if (hasSensitiveMemoryContent(content)) {
185
+ // The match is deliberately NOT echoed, in the message or in the error's
186
+ // fields: for most of these patterns the match IS the credential, so
187
+ // copying it would write the value this scan exists to keep out.
188
+ //
189
+ // The DETAIL is worded around the envelope redactor's vocabulary on
190
+ // purpose. Its catch-all (`/(secret|token|password|api[-_ ]?key)/gi`)
191
+ // rewrites those four words wherever they appear in a failure message, so
192
+ // naming the pattern families in their own words arrives on the CLI as
193
+ // "an [redacted] / [redacted] / [redacted] assignment" — a remedy sentence
194
+ // redacted into uselessness by the very policy it agrees with. Measured on
195
+ // the real CLI, both wordings; this one survives intact.
196
+ throw new UnsafeMemoryError(SENSITIVE_MEMORY_CHECKS.content, 'matched a credential value in the memory content (a `key=value` credential assignment, a Bearer header, a PEM private key, a JWT, or a provider credential)');
38
197
  }
39
- if (isSensitiveConfigPath(memory.title)) {
40
- throw new Error('Refusing to store sensitive memory content');
198
+ const titleTerm = findSensitiveMemoryTitleTerm(memory.title);
199
+ if (titleTerm !== null) {
200
+ throw new UnsafeMemoryError(SENSITIVE_MEMORY_CHECKS.title, `matched the credential term "${titleTerm}" in memory.title`, titleTerm);
41
201
  }
42
202
  }
43
203
  export function assertSafeMemoryFileContent(content) {
@@ -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 });