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
@@ -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`;
@@ -0,0 +1,95 @@
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>;
@@ -0,0 +1,145 @@
1
+ // src/services/prd/handoff-gate-evidence.ts
2
+ //
3
+ // The SOLE reader of the handoff frontmatter's `gateEvidence` field.
4
+ //
5
+ // WHAT THE FIELD IS (slice B1, rid `rid-b1-gate-evidence-producer`): a map
6
+ // from one of five fixed gate keys to the PATH of that gate's evidence file.
7
+ // The normative description is
8
+ // `skills/bee/peaks-rd/references/writing-handoff-frontmatter.md:35-41`; the
9
+ // key set is `GATE_EVIDENCE_KEYS` in `./handoff-types.js`.
10
+ //
11
+ // WHAT THIS FILE USED TO SAY, AND WHY THAT WAS FALSE: its header claimed the
12
+ // field was a `string[]` of "gate names" and that `initHandoff` wrote it.
13
+ // Neither held. `grep -rn gateEvidence src/` hit this file and nothing else —
14
+ // there was no producer (`HandoffFrontmatter` had no such field,
15
+ // `serializeHandoffFrontmatter` never emitted it, `initHandoff` rejected it)
16
+ // and no consumer. The field was prose describing data that did not exist,
17
+ // and the comment asserting a producer was the reason nobody noticed.
18
+ //
19
+ // WHAT IS ON DISK NOW, stated precisely because a header that overclaims is
20
+ // the defect above. B1 added the producer FUNCTIONS; B2 wired their callers,
21
+ // which is what made the difference — until then every producer passed
22
+ // nothing, so no capsule carried the field and saying otherwise would have
23
+ // described a fact that held only inside tests (F1 of `rid-b1-qa`). At
24
+ // HEAD after B2: the three producers DERIVE the map from the request type
25
+ // (`services/prd/gate-evidence-derivation.ts`), so a capsule written by
26
+ // `peaks prd handoff init`, by the auto-regen on `prd:handed-off`, or by
27
+ // `peaks evidence generate` carries this field whenever that slice's PRD
28
+ // artifact is readable — and carries no `gateEvidence` block at all when it
29
+ // is not. Gate C (`checkPrerequisites` at `rd:qa-handoff`) fails a declared
30
+ // path that is not on disk. Every clause above is asserted end-to-end in
31
+ // `tests/unit/prd/gate-evidence-derivation.test.ts`.
32
+ //
33
+ // WHY IT IS STILL STAND-ALONE: keeping the typed-value logic here, rather
34
+ // than in `handoff-service.ts`, keeps `HandoffFrontmatter` a pure data shape
35
+ // and lets this reader stay PERMISSIVE where `readHandoff` must throw.
36
+ //
37
+ // PERMISSIVE, RE-ARGUED (the old rationale did not survive contact with its
38
+ // own return type): the pre-B1 reader returned `null` for "field absent",
39
+ // "field malformed", "YAML broken" and "file missing" alike. Its stated
40
+ // justification — "a malformed frontmatter is the caller's signal to surface
41
+ // the failure to the operator" — was therefore unimplementable: the caller
42
+ // could not distinguish a handoff that DECLARED NOTHING from one whose
43
+ // declaration was CORRUPT, so it could not tell whether to proceed or to
44
+ // stop. The rationale was a promise the shape could not keep.
45
+ //
46
+ // It holds now, because the shape changed rather than the policy: this reader
47
+ // still never throws (a broken capsule must not crash the gate pipeline, and
48
+ // `readHandoff` already owns the throwing contract for capsules that must be
49
+ // valid), but every outcome is a DISTINCT `status`. Under the map shape the
50
+ // callers that matter are Gate C and the QA role, and both must fail closed on
51
+ // `field-not-map` / `value-not-string` while passing `field-absent` — which is
52
+ // only expressible if those are different values. So: permissive stays, and it
53
+ // is now a decision the return type can actually support.
54
+ import { readFile } from 'node:fs/promises';
55
+ import { parse as parseYaml } from 'yaml';
56
+ import { isGateEvidenceKey } from './handoff-types.js';
57
+ /** Frontmatter is the `---`-fenced YAML block at the top of the file. */
58
+ const FRONTMATTER_FENCE = /^---\r?\n([\s\S]*?)\r?\n---/;
59
+ function isPlainMap(value) {
60
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
61
+ }
62
+ function describeType(value) {
63
+ if (Array.isArray(value))
64
+ return 'array';
65
+ if (value === null)
66
+ return 'null';
67
+ return typeof value;
68
+ }
69
+ /** Classify a `gateEvidence` VALUE (already located — see the reader for the
70
+ * fence/YAML/presence layers, which are file-level concerns, not shape). */
71
+ export function classifyGateEvidence(value) {
72
+ if (value === undefined)
73
+ return { kind: 'absent' };
74
+ if (!isPlainMap(value))
75
+ return { kind: 'not-map', actualType: describeType(value) };
76
+ const nonStringKeys = Object.keys(value).filter((key) => typeof value[key] !== 'string');
77
+ if (nonStringKeys.length > 0) {
78
+ return { kind: 'value-not-string', keys: nonStringKeys.sort() };
79
+ }
80
+ const evidence = {};
81
+ const unknownKeys = [];
82
+ for (const key of Object.keys(value)) {
83
+ if (isGateEvidenceKey(key)) {
84
+ evidence[key] = value[key];
85
+ }
86
+ else {
87
+ unknownKeys.push(key);
88
+ }
89
+ }
90
+ return { kind: 'ok', evidence, unknownKeys: unknownKeys.sort() };
91
+ }
92
+ /**
93
+ * Read + classify the `gateEvidence` map out of `filePath`. Never throws.
94
+ * See the header for the permissive contract and
95
+ * `HandoffGateEvidenceResult` for the outcomes.
96
+ */
97
+ export async function readHandoffGateEvidence(filePath) {
98
+ let raw;
99
+ try {
100
+ raw = await readFile(filePath, 'utf8');
101
+ }
102
+ catch (error) {
103
+ // N1 of `handoff-service.verifyHandoff` is the same lesson one layer up:
104
+ // folding every read failure into `file-missing` sends an operator to
105
+ // look for a file that is right there. Only a genuine absence is
106
+ // `file-missing`.
107
+ const code = error.code;
108
+ if (code === 'ENOENT')
109
+ return { status: 'file-missing' };
110
+ return { status: 'read-error', reason: `${code ?? 'unknown'}` };
111
+ }
112
+ const match = FRONTMATTER_FENCE.exec(raw);
113
+ if (!match) {
114
+ return { status: 'frontmatter-malformed', reason: 'no-frontmatter-fence' };
115
+ }
116
+ let parsed;
117
+ try {
118
+ parsed = parseYaml(match[1] ?? '');
119
+ }
120
+ catch (error) {
121
+ const message = error instanceof Error ? error.message : String(error);
122
+ return { status: 'frontmatter-malformed', reason: `yaml-parse-error: ${message}` };
123
+ }
124
+ if (!isPlainMap(parsed)) {
125
+ return { status: 'frontmatter-malformed', reason: 'frontmatter-not-a-map' };
126
+ }
127
+ // `in`, not `!== undefined`: `gateEvidence:` with an empty value parses to
128
+ // `null`, which IS a (broken) declaration and must not be reported as if
129
+ // the author had written nothing.
130
+ if (!('gateEvidence' in parsed))
131
+ return { status: 'field-absent' };
132
+ // The shape rule is shared with `handoff-service.isHandoffFrontmatter` — see
133
+ // `classifyGateEvidence`. This reader only decides how to REPORT it.
134
+ const shape = classifyGateEvidence(parsed['gateEvidence']);
135
+ switch (shape.kind) {
136
+ case 'absent':
137
+ return { status: 'field-absent' };
138
+ case 'not-map':
139
+ return { status: 'field-not-map', actualType: shape.actualType };
140
+ case 'value-not-string':
141
+ return { status: 'value-not-string', keys: shape.keys };
142
+ case 'ok':
143
+ return { status: 'ok', evidence: shape.evidence, unknownKeys: shape.unknownKeys };
144
+ }
145
+ }
@@ -22,7 +22,7 @@
22
22
  * the body content as UTF-8 bytes. The body MUST be the literal
23
23
  * markdown source — no normalization, no trailing-newline padding.
24
24
  */
25
- import type { Handoff, HandoffProbe } from './handoff-types.js';
25
+ import type { GateEvidence, Handoff, HandoffProbe } from './handoff-types.js';
26
26
  /** Compute the lowercase hex sha256 of a UTF-8 string. */
27
27
  export declare function sha256OfBody(body: string): string;
28
28
  /**
@@ -74,6 +74,26 @@ export declare function initHandoff(opts: {
74
74
  preservedBehavior: readonly string[];
75
75
  /** Override path; defaults to `.peaks/_runtime/<sid>/prd/handoff-<rid>.md`. */
76
76
  handoffPath?: string;
77
+ /** Paths to the gate evidence files, keyed by gate. Omitted field and
78
+ * empty map are equivalent here: neither renders a `gateEvidence` block,
79
+ * so a caller that declares nothing writes bytes identical to a pre-B1
80
+ * capsule.
81
+ *
82
+ * THIS IS NOT A SECOND SOURCE (B2). In production the value reaching this
83
+ * parameter is always `deriveGateEvidence(...)`
84
+ * (`services/prd/gate-evidence-derivation.ts`), which computes it from the
85
+ * request type and the gate table. This function stays pure — it does not
86
+ * read the type itself — so the map can be constructed without touching
87
+ * the disk; that is why the parameter exists rather than an internal call.
88
+ *
89
+ * SHAPE CONTRACT (F2 of `rid-b1-qa`, now enforced in both directions): a
90
+ * map this accepts is EXACTLY what `readHandoffGateEvidence` reports as
91
+ * `evidence` — `classifyGateEvidence` is the one shape rule behind both,
92
+ * and `parseHandoffContent` stores the classified map rather than the raw
93
+ * YAML. A declaration both paths REFUSE (`not-map` / `value-not-string`)
94
+ * is refused here by a throw and there by a status; neither silently
95
+ * produces a map the other cannot. */
96
+ gateEvidence?: GateEvidence;
77
97
  }): Handoff;
78
98
  /** Write a Handoff to disk. `projectRoot` is the absolute project
79
99
  * root (so the `.peaks/_runtime/...` path is resolved absolutely).
@@ -30,6 +30,7 @@ import { parse as parseYaml } from 'yaml';
30
30
  import { isUnsafePathInput } from '../../shared/path-safety.js';
31
31
  import { REQUEST_ID_PATTERN } from '../artifacts/request-artifact-service.js';
32
32
  import { serializeHandoffFrontmatter } from './handoff-frontmatter.js';
33
+ import { classifyGateEvidence } from './handoff-gate-evidence.js';
33
34
  /** Required schema version for new handoffs. */
34
35
  const HANDOFF_SCHEMA_VERSION = '2';
35
36
  /** Compute the lowercase hex sha256 of a UTF-8 string. */
@@ -139,6 +140,12 @@ export function initHandoff(opts) {
139
140
  acceptanceCriteria: [...opts.acceptanceCriteria],
140
141
  preservedBehavior: [...opts.preservedBehavior],
141
142
  handoffPath,
143
+ // Conditionally spread rather than `gateEvidence: undefined`: this
144
+ // tsconfig sets `exactOptionalPropertyTypes`, and an explicit `undefined`
145
+ // would also make `serializeHandoffFrontmatter`'s
146
+ // `frontmatter.gateEvidence` key present-but-undefined for every caller
147
+ // that declares nothing.
148
+ ...(opts.gateEvidence === undefined ? {} : { gateEvidence: opts.gateEvidence }),
142
149
  };
143
150
  return { frontmatter, body: opts.body };
144
151
  }
@@ -233,8 +240,21 @@ function parseHandoffContent(content) {
233
240
  // frontmatter is returned with the canonical `'2'` rather than the raw scalar
234
241
  // — otherwise every downstream `=== '2'` comparison would depend on which
235
242
  // producer wrote the file.
243
+ //
244
+ // F2: `gateEvidence` is normalized the same way, for the same reason. The
245
+ // raw YAML value is not returned: the CLASSIFIED map is. Unknown keys are
246
+ // not part of `GateEvidence`, so storing the raw object made the interface
247
+ // claim a five-key map while holding a six-key one — and made
248
+ // `frontmatter.gateEvidence` disagree with what the field's reader reports
249
+ // for the same bytes. The predicate above has already rejected every shape
250
+ // that is not `absent` or `ok`, so this spread only ever adds a clean map.
251
+ const gateEvidenceShape = classifyGateEvidence(parsed.gateEvidence);
236
252
  return {
237
- frontmatter: { ...parsed, schemaVersion: HANDOFF_SCHEMA_VERSION },
253
+ frontmatter: {
254
+ ...parsed,
255
+ schemaVersion: HANDOFF_SCHEMA_VERSION,
256
+ ...(gateEvidenceShape.kind === 'ok' ? { gateEvidence: gateEvidenceShape.evidence } : {})
257
+ },
238
258
  body
239
259
  };
240
260
  }
@@ -296,5 +316,28 @@ function isHandoffFrontmatter(value) {
296
316
  Array.isArray(v.goals) &&
297
317
  Array.isArray(v.acceptanceCriteria) &&
298
318
  Array.isArray(v.preservedBehavior) &&
299
- typeof v.handoffPath === 'string');
319
+ typeof v.handoffPath === 'string' &&
320
+ isGateEvidence(v.gateEvidence));
321
+ }
322
+ /**
323
+ * B1: `gateEvidence` is optional, but when present it MUST be a map of
324
+ * strings — an ARRAY here is the pre-B1 shape its own test file used to
325
+ * write, and it is a broken declaration, not a claim.
326
+ *
327
+ * F2 of `rid-b1-qa` removed this function's own copy of the shape rule. It
328
+ * used to be a second predicate (same boundary, different downstream result)
329
+ * that let the same bytes read one way here and another way through
330
+ * `readHandoffGateEvidence`; both now ask `classifyGateEvidence`. Only
331
+ * `absent` and `ok` are accepted: a malformed declaration must be refused by
332
+ * `readHandoff` (which is documented to throw on malformed input) exactly as
333
+ * the reader refuses it, so the two can never disagree about the same file.
334
+ *
335
+ * Unknown keys are still accepted HERE — the map is normalized to the five
336
+ * known keys by the caller — because refusing them would make a typo render
337
+ * a whole capsule unreadable, and the reader's `unknownKeys` is the surface
338
+ * that makes the typo diagnosable instead.
339
+ */
340
+ function isGateEvidence(value) {
341
+ const kind = classifyGateEvidence(value).kind;
342
+ return kind === 'absent' || kind === 'ok';
300
343
  }
@@ -24,6 +24,39 @@
24
24
  * IDs in frontmatter). Every consumer MUST refuse a handoff whose
25
25
  * frontmatter is missing or carries `schemaVersion !== '2'`. */
26
26
  export type HandoffSchemaVersion = '2';
27
+ /**
28
+ * The FIVE fixed `gateEvidence` keys, in canonical serialization order.
29
+ *
30
+ * This array is the single source of truth for the key set: the
31
+ * `GateEvidenceKey` type is derived from it, the serializer iterates it,
32
+ * and the reader matches against it. Slice B1
33
+ * (`2026-09-17-4-0-51-cleanup`) collapsed four mutually contradictory
34
+ * descriptions of this field (a `string[]` in the reader's code comment,
35
+ * a `string[]` of PATHS in its tests, a map of paths in
36
+ * `skills/bee/peaks-rd/references/writing-handoff-frontmatter.md:35-41`)
37
+ * onto the documented map. Do NOT re-spell these five literals anywhere
38
+ * else — a second copy is how the four descriptions diverged.
39
+ */
40
+ export declare const GATE_EVIDENCE_KEYS: readonly ["projectScan", "prdHandoff", "codeReview", "securityReview", "perfBaseline"];
41
+ /** One of the five `gateEvidence` keys. Derived, never re-spelled. */
42
+ export type GateEvidenceKey = (typeof GATE_EVIDENCE_KEYS)[number];
43
+ /**
44
+ * Is `key` one of the five? THE membership test — the serializer's
45
+ * unknown-key check, the reader's classifier and the shape predicate all ask
46
+ * this one function, so no caller can disagree about what the key set is.
47
+ */
48
+ export declare function isGateEvidenceKey(key: string): key is GateEvidenceKey;
49
+ /**
50
+ * `gateEvidence` — a partial map from gate key to the PATH of that gate's
51
+ * evidence file. Partial because a slice may legitimately not have run
52
+ * every gate; the consumer (Gate C, wired in slice B2) fails on a missing
53
+ * key, which is where "did you declare it?" is decided — NOT here.
54
+ *
55
+ * Unknown keys are not expressible in this type. The reader still reports
56
+ * them at runtime (`unknownKeys`) so a typo is diagnosable rather than
57
+ * silently dropped.
58
+ */
59
+ export type GateEvidence = Readonly<Partial<Record<GateEvidenceKey, string>>>;
27
60
  /**
28
61
  * Handoff frontmatter — the structured header block between the
29
62
  * leading and trailing `---` fences. `readonly` enforces D1's
@@ -47,6 +80,13 @@ export interface HandoffFrontmatter {
47
80
  readonly preservedBehavior: readonly string[];
48
81
  /** Absolute path to the handoff file on disk (for re-verify). */
49
82
  readonly handoffPath: string;
83
+ /** Paths to the evidence files peaks-qa validates at Gate C, keyed by
84
+ * gate. ABSENT (not `{}`) when the slice declared none — the serializer
85
+ * omits the block entirely rather than emitting an empty map, so old
86
+ * handoffs gain no noise line. Read it through
87
+ * `readHandoffGateEvidence`, which reports WHY it is unusable instead of
88
+ * collapsing every failure to `null`. */
89
+ readonly gateEvidence?: GateEvidence;
50
90
  }
51
91
  /** Handoff — frontmatter + body. The body is the markdown source of
52
92
  * truth that sub-agents and downstream consumers parse. */
@@ -20,4 +20,31 @@
20
20
  * `.peaks/_runtime/current-change`). NEVER write under
21
21
  * `.peaks/_runtime/<change-id>/...` directly (slice 2.8.3 hard ban).
22
22
  */
23
- export {};
23
+ /**
24
+ * The FIVE fixed `gateEvidence` keys, in canonical serialization order.
25
+ *
26
+ * This array is the single source of truth for the key set: the
27
+ * `GateEvidenceKey` type is derived from it, the serializer iterates it,
28
+ * and the reader matches against it. Slice B1
29
+ * (`2026-09-17-4-0-51-cleanup`) collapsed four mutually contradictory
30
+ * descriptions of this field (a `string[]` in the reader's code comment,
31
+ * a `string[]` of PATHS in its tests, a map of paths in
32
+ * `skills/bee/peaks-rd/references/writing-handoff-frontmatter.md:35-41`)
33
+ * onto the documented map. Do NOT re-spell these five literals anywhere
34
+ * else — a second copy is how the four descriptions diverged.
35
+ */
36
+ export const GATE_EVIDENCE_KEYS = [
37
+ 'projectScan',
38
+ 'prdHandoff',
39
+ 'codeReview',
40
+ 'securityReview',
41
+ 'perfBaseline'
42
+ ];
43
+ /**
44
+ * Is `key` one of the five? THE membership test — the serializer's
45
+ * unknown-key check, the reader's classifier and the shape predicate all ask
46
+ * this one function, so no caller can disagree about what the key set is.
47
+ */
48
+ export function isGateEvidenceKey(key) {
49
+ return GATE_EVIDENCE_KEYS.includes(key);
50
+ }
@@ -10,6 +10,13 @@
10
10
  * errors propagate (no silent failures).
11
11
  */
12
12
  import type { BusinessKnowledge, ProjectScan } from './project-scan-types.js';
13
+ /**
14
+ * `.peaks/project-scan/project-scan.md`, project-relative. Exported so the
15
+ * gate-evidence derivation (`gate-evidence-derivation.ts`) can declare this
16
+ * path without spelling the literal a fourth time — it was already inlined in
17
+ * `project-commands.ts`, `workspace/init-command.ts` and here.
18
+ */
19
+ export declare const PROJECT_SCAN_RELATIVE: string;
13
20
  /** Read `.peaks/project-scan/project-scan.md`. Returns `null` when the
14
21
  * file or its parent dir is absent (fresh project). Throws on other
15
22
  * IO failures or malformed YAML. */
@@ -12,7 +12,13 @@
12
12
  import { readFile } from 'node:fs/promises';
13
13
  import { join } from 'node:path';
14
14
  import { parse as parseYaml } from 'yaml';
15
- const PROJECT_SCAN_RELATIVE = join('.peaks', 'project-scan', 'project-scan.md');
15
+ /**
16
+ * `.peaks/project-scan/project-scan.md`, project-relative. Exported so the
17
+ * gate-evidence derivation (`gate-evidence-derivation.ts`) can declare this
18
+ * path without spelling the literal a fourth time — it was already inlined in
19
+ * `project-commands.ts`, `workspace/init-command.ts` and here.
20
+ */
21
+ export const PROJECT_SCAN_RELATIVE = join('.peaks', 'project-scan', 'project-scan.md');
16
22
  const BUSINESS_KNOWLEDGE_RELATIVE = join('.peaks', 'project-scan', 'business-knowledge.md');
17
23
  /** Read `.peaks/project-scan/project-scan.md`. Returns `null` when the
18
24
  * file or its parent dir is absent (fresh project). Throws on other
@@ -26,12 +26,33 @@
26
26
  * PRD handoff + the project-scoped audit templates (slice v2.12.0
27
27
  * Tier 1+2+3 = Group A).
28
28
  *
29
- * The dispatch policy here is the **canonical decision table** consumed
30
- * by the LLM-side runner and pinned by tests:
31
- * - `tests/unit/rd/karpathy-skip-on-config-docs-chore.test.ts`
32
- * (5 → 3 element pinning)
33
- * - `tests/unit/rd/deprecated-reviewer-back-compat.test.ts`
34
- * (NEW in v2.12.0 — 8 cases; back-compat for the 2 removed slots)
29
+ * This module holds the machine-readable decision table for that collapse;
30
+ * the prose the LLM runner actually reads is
31
+ * `skills/bee/peaks-rd/references/parallel-review-fanout.md`. It used to
32
+ * describe itself as "consumed by the LLM-side runner", which named no
33
+ * mechanism — an LLM reads prose, not TypeScript exports — and that is the
34
+ * reason every export below sat at zero importers until slice F2.
35
+ *
36
+ * Pinning is half-done. The two test files this block used to cite —
37
+ * `tests/unit/rd/karpathy-skip-on-config-docs-chore.test.ts` (the 5 → 3
38
+ * element pinning) and
39
+ * `tests/unit/rd/deprecated-reviewer-back-compat.test.ts` (the 8
40
+ * back-compat cases) — were both deleted in `f17aa377`. The **predicate
41
+ * half** was re-pinned by `tests/unit/rd/reviewer-dispatch-policy.test.ts`
42
+ * (`7191140f`, 6 cases: `RD_DEPRECATED_REVIEWERS` + `isDeprecatedReviewer`).
43
+ * The **decision-table half** — `RD_FANOUT_REVIEWERS`' 3-element
44
+ * membership, `reviewerListFor`, `karpathySlotIndex`,
45
+ * `shouldDispatchKarpathy` — still has no pin.
46
+ *
47
+ * Slice F2 (rid-f2-ac1-wiring) gave this module its FIRST caller in
48
+ * `src/cli/commands/sub-agent-shared.ts` (`deprecatedReviewerWarnings`),
49
+ * invoked from the dispatch chokepoint in
50
+ * `src/cli/commands/dispatch-commands.ts` and pinned by
51
+ * `tests/unit/cli/sub-agent-dispatch-deprecated-reviewer.test.ts`. Before
52
+ * that, all 13 exports had zero importers across src/ + packages/ +
53
+ * scripts/, so nothing rejected or rerouted `security-reviewer` /
54
+ * `perf-baseline-reviewer` on the way in. That import is still the only
55
+ * one: the other 12 exports below remain unreferenced in this repo.
35
56
  *
36
57
  * For `config | docs | chore` request types, the slice already skips
37
58
  * the entire fanout (SKILL.md line 132 says "Config / docs / chore: no
@@ -47,8 +68,15 @@
47
68
  * back-compat window. The `isDeprecatedReviewer(name)` predicate lets
48
69
  * dispatchers (or legacy on-disk rd/{security-review,perf-baseline}.md
49
70
  * readers) detect a removed slot and route to the new audit skill
50
- * instead of failing the gate. See Tier 5 (`artifact-prerequisites.ts`)
51
- * for the matching prereq-side back-compat (`mustContainAny` form).
71
+ * instead of failing the gate. Tier 5 (`artifact-prerequisites.ts`)
72
+ * holds the matching prereq-side back-compat: `AUDIT_SECURITY` /
73
+ * `AUDIT_PERF` accept the legacy `rd/security-review.md` /
74
+ * `rd/perf-baseline.md` artifacts via `legacyRelativePaths`.
75
+ *
76
+ * Both halves are wired as of slice F2 and both ACCEPT the legacy slot.
77
+ * The dispatch side emits the reroute notice as a warning
78
+ * (`deprecatedReviewerWarnings`) rather than a refusal, precisely so it
79
+ * does not disagree with the prereq side about the same deprecation.
52
80
  */
53
81
  export declare const RD_REVIEW_REQUEST_TYPES: readonly ["feat", "bugfix", "refactor", "config", "docs", "chore"];
54
82
  export type RdReviewRequestType = (typeof RD_REVIEW_REQUEST_TYPES)[number];