peaks-loop 4.0.48 → 4.0.49

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 (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/compact-command.js +1 -3
  5. package/dist/cli/commands/feedback-commands.d.ts +11 -7
  6. package/dist/cli/commands/feedback-commands.js +49 -17
  7. package/dist/cli/commands/final-review-commands.js +12 -0
  8. package/dist/cli/commands/loop-eval-commands.js +22 -6
  9. package/dist/cli/commands/slice-integrate-commands.js +17 -0
  10. package/dist/services/artifacts/artifact-prerequisites.js +10 -0
  11. package/dist/services/artifacts/request-artifact-service.js +59 -38
  12. package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
  13. package/dist/services/code/auto-compact-lifecycle.d.ts +75 -0
  14. package/dist/services/code/auto-compact-lifecycle.js +65 -16
  15. package/dist/services/code/auto-compact-orchestrator.js +119 -19
  16. package/dist/services/code/compact-event-settle.d.ts +20 -8
  17. package/dist/services/code/compact-event-settle.js +21 -0
  18. package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
  19. package/dist/services/context/auto-compact-types.d.ts +20 -2
  20. package/dist/services/feedback/feedback-promotion-service.d.ts +137 -14
  21. package/dist/services/feedback/feedback-promotion-service.js +341 -20
  22. package/dist/services/feedback/promotion-artifact-evidence.d.ts +69 -0
  23. package/dist/services/feedback/promotion-artifact-evidence.js +332 -0
  24. package/dist/services/job/job-progress-store.js +18 -3
  25. package/dist/services/observability/jsonl-store.d.ts +19 -0
  26. package/dist/services/observability/jsonl-store.js +27 -2
  27. package/dist/services/observability/observability-service.d.ts +10 -3
  28. package/dist/services/observability/observability-service.js +16 -3
  29. package/dist/services/prd/handoff-service.js +43 -0
  30. package/dist/services/qa/qa-business-review-state.js +19 -5
  31. package/dist/services/sc/sc-service.d.ts +8 -0
  32. package/dist/services/sc/sc-service.js +8 -1
  33. package/dist/services/session/getSessionDir.d.ts +33 -0
  34. package/dist/services/session/getSessionDir.js +60 -0
  35. package/dist/services/slice/slice-review-state.js +19 -4
  36. package/dist/services/workflow/pipeline-verify-gate-support.js +10 -11
  37. package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
  38. package/dist/services/workflow/pipeline-verify-service.js +23 -10
  39. package/dist/services/workflow/pipeline-verify-types.d.ts +5 -3
  40. package/dist/shared/runtime-root.d.ts +73 -0
  41. package/dist/shared/runtime-root.js +77 -0
  42. package/package.json +5 -5
@@ -8,25 +8,30 @@
8
8
  * primitive behind the `peaks feedback promote` and
9
9
  * `peaks feedback check-unpromoted` CLI commands.
10
10
  *
11
- * Promotion tracking convention: a feedback memory is considered
12
- * "promoted" when one of the following is true:
11
+ * Promotion tracking convention — two parts, and BOTH are required:
13
12
  *
14
- * (a) The memory file contains an HTML comment near the top of the
15
- * body: `<!-- peaks-feedback-promoted: layer=<A|B|C> -->`.
16
- * Written by `peaks feedback promote` so a single read of the
17
- * memory file is enough to determine promotion state.
13
+ * (a) A MARKER, either an HTML comment near the top of the body
14
+ * (`<!-- peaks-feedback-promoted: layer=<A|B|C> -->`) or a sibling
15
+ * `.peaks/memory/<name>.promotion.json` sidecar with
16
+ * `{ layer: "A" | "B" | "C", ... }`. Written by `peaks feedback
17
+ * promote` so a single read of the memory file is enough to see the
18
+ * claimed layer.
18
19
  *
19
- * (b) A sibling `.peaks/memory/<name>.promotion.json` exists with
20
- * `{ layer: "A" | "B" | "C", ... }`. Written as a sidecar for
21
- * tooling that prefers machine-readable state over embedded
22
- * comments (e.g. `verify-pipeline` Gate H).
20
+ * (b) The ARTIFACT that layer implies — see `promotionArtifactChecks`.
21
+ * rid 2026-09-14-gate-h-promotion: the marker alone used to count,
22
+ * which made the gate self-certifying, because the only thing a marker
23
+ * proves is that `peaks feedback promote` ran. Every layer-A marker in
24
+ * this repo pointed at `sops/<name>.md`, a file that did not exist and
25
+ * that no engine reads.
23
26
  *
24
- * The comment marker is the SOURCE OF TRUTH for human review; the
25
- * sidecar is the source of truth for the scanner. Either is enough
26
- * to mark a feedback memory as promoted.
27
+ * The comment marker is the SOURCE OF TRUTH for human review; the sidecar is
28
+ * the source of truth for the scanner. Neither is evidence on its own.
27
29
  */
28
30
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
29
31
  import { dirname, join, resolve } from 'node:path';
32
+ import { registerSop } from '../sop/sop-registry-service.js';
33
+ import { projectRegistryPath, projectSopManifestPath } from '../sop/sop-paths.js';
34
+ import { artifactEvidenceFailure } from './promotion-artifact-evidence.js';
30
35
  /**
31
36
  * PRD-002b slice 2 — extract magic numbers used by the promotion
32
37
  * helper. `slice(0, 5)` was used as a heuristic on the user's body
@@ -41,7 +46,131 @@ export const PROMOTION_LAYER_DETAILS = [
41
46
  { layer: 'B', label: 'peaks-hooks PreToolUse', description: 'Append a matcher to .peaks/.claude-settings-template.json. Tool-call interception.' },
42
47
  { layer: 'C', label: 'mode-gate hardFloorCategory', description: 'Extend HardFloorCategory + shouldPauseAtGate. Always pauses regardless of mode.' }
43
48
  ];
49
+ /**
50
+ * rid 2026-09-14-gate-h-promotion — what actually backs a promotion.
51
+ *
52
+ * Before this, a promotion was honored on the marker alone (HTML comment or
53
+ * sidecar). Both are written by `peaks feedback promote` and neither proves
54
+ * that anything was enforced: every layer-A promotion in this repo pointed at
55
+ * `sops/<name>.md`, a file that did not exist and that no engine reads. The
56
+ * gate was therefore self-certifying — it read only what the command it tells
57
+ * you to run had written.
58
+ *
59
+ * A promotion is now honored only when its layer's enforcement surface carries
60
+ * the artifact. The three layers keep artifacts in three different shapes, so
61
+ * the check is a small table rather than one rule:
62
+ *
63
+ * - A (peaks-sop gate): a SOP manifest at `.peaks/sops/<id>/sop.json` AND an
64
+ * entry for `<id>` in `.peaks/sops/registry.json`. The registry half is not
65
+ * decoration: `gate-enforce-service.enforceBashCommand` enumerates SOPs via
66
+ * `readRegistry()`, so an unregistered manifest is off the enforcement path
67
+ * no matter how valid it is.
68
+ * - B (peaks-hooks PreToolUse): `.peaks/.claude-settings-template.json` must
69
+ * register the rule inside its `hooks` block. The file always exists, so
70
+ * existence proves nothing — the evidence is the registration inside it.
71
+ * - C (mode-gate hardFloorCategory): `src/services/code/mode-gate.ts` must
72
+ * register the rule in the hard-floor vocabulary, for the same reason. This
73
+ * is the repo's existing convention: the one real layer-C promotion cites
74
+ * its memory by path, from the category's doc block.
75
+ *
76
+ * R2 (2026-09-14-gate-h-promotion): each of those is now a PARSE plus a shape
77
+ * assertion, in `promotion-artifact-evidence.ts`. Every check used to be a
78
+ * `text.includes(<rule>)` over the whole file, which certified a tree that was
79
+ * a refusal — an invalid-JSON registry, a template saying "do NOT add a
80
+ * matcher", a mode-gate line saying the rule is deliberately not a category —
81
+ * because in each case the rule's name was still in the file's bytes. A name in
82
+ * a file is not a registration, and a file that cannot be parsed is a finding,
83
+ * not a permit.
84
+ */
85
+ /**
86
+ * SOP id used for a feedback memory's layer-A artifact.
87
+ *
88
+ * Prefixed because `peaks-*` SOP ids are reserved for the built-in namespace
89
+ * (`reservedIdReason` in sop-service.ts) and several feedback memories start
90
+ * with `peaks-`, which would make them unregistrable under their own name.
91
+ */
92
+ export function sopIdForFeedback(memoryName) {
93
+ return `feedback-${memoryName}`;
94
+ }
95
+ /** The artifact(s) and the structural evidence each must carry for `layer` to mean anything. */
96
+ export function promotionArtifactChecks(memoryName, layer) {
97
+ if (layer === 'A') {
98
+ const id = sopIdForFeedback(memoryName);
99
+ return [
100
+ { path: `.peaks/sops/${id}/sop.json`, evidence: 'sop-manifest', id },
101
+ { path: '.peaks/sops/registry.json', evidence: 'sop-registry-entry', id }
102
+ ];
103
+ }
104
+ if (layer === 'B') {
105
+ return [{ path: '.peaks/.claude-settings-template.json', evidence: 'hook-registration', id: memoryName }];
106
+ }
107
+ return [{ path: 'src/services/code/mode-gate.ts', evidence: 'hard-floor-category', id: memoryName }];
108
+ }
109
+ /**
110
+ * Which of `checks` are not satisfied under `projectRoot`. Empty means the
111
+ * promotion is backed by its artifact. Never throws, and never permits: a file
112
+ * that is absent, unreadable, or unparseable is a finding, not a warning —
113
+ * "cannot read the evidence" must not read as "the evidence is good".
114
+ */
115
+ export function missingArtifacts(checks, projectRoot) {
116
+ const missing = [];
117
+ for (const check of checks) {
118
+ const absolute = resolve(projectRoot, check.path);
119
+ if (!existsSync(absolute)) {
120
+ missing.push(`${check.path} (absent)`);
121
+ continue;
122
+ }
123
+ let text;
124
+ try {
125
+ text = readFileSync(absolute, 'utf8');
126
+ }
127
+ catch {
128
+ missing.push(`${check.path} (unreadable)`);
129
+ continue;
130
+ }
131
+ const failure = artifactEvidenceFailure(check, text);
132
+ if (failure !== null) {
133
+ missing.push(`${check.path} (${failure})`);
134
+ }
135
+ }
136
+ return missing;
137
+ }
44
138
  const COMMENT_MARKER_RE = /<!--\s*peaks-feedback-promoted:\s*layer=([ABC])\s*-->/;
139
+ /**
140
+ * rid 2026-09-14-gate-h-promotion (classify slice) — the "not to be promoted"
141
+ * declaration.
142
+ *
143
+ * The gate used to know only `has artifact` / `has no artifact`, so a memory that
144
+ * prescribes no action could never pass: promoting it registers a SOP whose only
145
+ * gate is "the source file still exists", which asserts nothing about behaviour.
146
+ * That is a permanent false positive — the old vacuity defect facing the other way.
147
+ *
148
+ * The declaration closes it, but it must not become a way to silence the gate.
149
+ * It differs from the refused grandfather channel (`promotedAt` older than this
150
+ * rule) in that a grandfather exemption is a property of a memory's AGE: every
151
+ * legacy memory has it, it says nothing about content, and nobody has to assert
152
+ * or defend it. This is a bounded claim about the memory's CONTENT:
153
+ *
154
+ * 1. The code is drawn from a closed vocabulary — free text cannot be used.
155
+ * 2. Each code binds to a predicate over the memory's own frontmatter, which
156
+ * the gate recomputes. The declaration may only RESTATE what the memory
157
+ * already says; it cannot introduce a new fact.
158
+ * 3. A reason string is required (the `closedAt` escape hatch beside it has none).
159
+ * 4. Coexisting with a promotion marker is a contradiction and fails, so the
160
+ * channel cannot be used to bury a promotion whose artifact is missing.
161
+ * 5. Exempted memories stay REPORTED via `listPromotionExempt`, so the
162
+ * unpromoted count never drops silently.
163
+ */
164
+ export const NOT_TO_PROMOTE_CODES = ['non-actionable', 'closed-slice-note'];
165
+ /**
166
+ * What each code's predicate requires the memory to already say. The gate does not
167
+ * and cannot verify that "prescribes no action" is TRUE; it verifies that the
168
+ * declaration agrees with a claim the memory makes on its own.
169
+ */
170
+ const NOT_TO_PROMOTE_CORROBORATION = {
171
+ 'non-actionable': 'frontmatter `scope:` containing `non-actionable`, or `nonActionable: true`',
172
+ 'closed-slice-note': 'frontmatter `sourceArtifact:` or `source:` naming the slice it was derived from'
173
+ };
45
174
  /**
46
175
  * Parse a single `.peaks/memory/<file>.md` into a FeedbackMemory, or
47
176
  * `null` when the file is missing / unreadable / not a feedback memory.
@@ -162,12 +291,49 @@ export function listUnpromotedFeedback(opts) {
162
291
  // honours the closed state and reports `0 unpromoted` for closed records.
163
292
  if (isClosedMemory(join(memoryDir, entry.name)))
164
293
  continue;
294
+ // rid 2026-09-14-gate-h-promotion (classify slice): a memory may declare
295
+ // itself out of the gate. A malformed declaration is a FAILURE, not a skip —
296
+ // otherwise "make the fields unusable" would be the quietest way through.
297
+ const declaration = readNotToPromote(join(memoryDir, entry.name));
298
+ if (declaration.kind === 'invalid') {
299
+ out.push({
300
+ name: parsed.name,
301
+ path: parsed.path,
302
+ reason: `not-to-promote declaration rejected: ${declaration.reason}`
303
+ });
304
+ continue;
305
+ }
306
+ if (declaration.kind === 'valid') {
307
+ if (parsed.promotion !== null) {
308
+ out.push({
309
+ name: parsed.name,
310
+ path: parsed.path,
311
+ reason: `carries both a layer ${parsed.promotion.layer} promotion marker and a notToPromote: ${declaration.code} declaration — one of the two claims is false; remove one`
312
+ });
313
+ continue;
314
+ }
315
+ // Deliberately exempt; `listPromotionExempt` reports it so the count is visible.
316
+ continue;
317
+ }
165
318
  if (parsed.promotion === null) {
166
319
  out.push({
167
320
  name: parsed.name,
168
321
  path: parsed.path,
169
322
  reason: 'no promotion marker (comment or sidecar) found — see `peaks feedback promote`'
170
323
  });
324
+ continue;
325
+ }
326
+ // rid 2026-09-14-gate-h-promotion: a marker is a claim, not evidence. It
327
+ // counts only when the layer's enforcement surface actually carries the
328
+ // artifact. Before this, the marker alone was accepted, so the gate
329
+ // certified whatever the promote command had written and nothing else.
330
+ const missing = missingArtifacts(promotionArtifactChecks(parsed.name, parsed.promotion.layer), opts.projectRoot);
331
+ if (missing.length > 0) {
332
+ out.push({
333
+ name: parsed.name,
334
+ path: parsed.path,
335
+ reason: `marker claims layer ${parsed.promotion.layer} but the artifact is missing: ${missing.join('; ')}`
336
+ });
171
337
  }
172
338
  }
173
339
  return out;
@@ -209,6 +375,109 @@ function isClosedMemory(filePath) {
209
375
  }
210
376
  return false;
211
377
  }
378
+ /** Raw frontmatter text of a memory, or `null` when absent/unreadable. */
379
+ function readFrontmatter(filePath) {
380
+ if (!existsSync(filePath))
381
+ return null;
382
+ let raw;
383
+ try {
384
+ raw = readFileSync(filePath, 'utf8');
385
+ }
386
+ catch {
387
+ return null;
388
+ }
389
+ const normalized = raw.replace(/\r\n/g, '\n');
390
+ if (!normalized.startsWith('---\n'))
391
+ return null;
392
+ const endIndex = normalized.indexOf('\n---\n', 4);
393
+ if (endIndex < 0)
394
+ return null;
395
+ return normalized.slice(4, endIndex);
396
+ }
397
+ /** Flat `key: value` lookup over frontmatter text; `null` when unset or empty. */
398
+ function frontmatterValue(frontmatter, key) {
399
+ for (const rawLine of frontmatter.split('\n')) {
400
+ const line = rawLine.trim();
401
+ if (!line.startsWith(`${key}:`))
402
+ continue;
403
+ const value = line.slice(key.length + 1).trim();
404
+ if (value.length === 0 || value === '""' || value === "''")
405
+ return null;
406
+ return value;
407
+ }
408
+ return null;
409
+ }
410
+ /** Does the memory's own frontmatter already say what `code` claims? */
411
+ function corroborates(code, frontmatter) {
412
+ if (code === 'non-actionable') {
413
+ const scope = frontmatterValue(frontmatter, 'scope');
414
+ if (scope !== null && scope.includes('non-actionable'))
415
+ return true;
416
+ return frontmatterValue(frontmatter, 'nonActionable') === 'true';
417
+ }
418
+ return frontmatterValue(frontmatter, 'sourceArtifact') !== null
419
+ || frontmatterValue(frontmatter, 'source') !== null;
420
+ }
421
+ /**
422
+ * Read the memory's not-to-promote declaration. `invalid` is returned rather than
423
+ * `none` when the fields are present but unusable, so the gate fails with a reason
424
+ * instead of quietly treating a malformed declaration as "no declaration".
425
+ */
426
+ export function readNotToPromote(filePath) {
427
+ const frontmatter = readFrontmatter(filePath);
428
+ if (frontmatter === null)
429
+ return { kind: 'none' };
430
+ const code = frontmatterValue(frontmatter, 'notToPromote');
431
+ const reason = frontmatterValue(frontmatter, 'notToPromoteReason');
432
+ if (code === null && reason === null)
433
+ return { kind: 'none' };
434
+ if (code === null) {
435
+ return { kind: 'invalid', reason: '`notToPromoteReason` is set but the `notToPromote` code is missing' };
436
+ }
437
+ if (!NOT_TO_PROMOTE_CODES.includes(code)) {
438
+ return {
439
+ kind: 'invalid',
440
+ reason: `\`notToPromote: ${code}\` is not a recognised code (expected ${NOT_TO_PROMOTE_CODES.join(' | ')})`
441
+ };
442
+ }
443
+ if (reason === null) {
444
+ return { kind: 'invalid', reason: `\`notToPromote: ${code}\` has no \`notToPromoteReason\` — an exemption must state its own reason` };
445
+ }
446
+ const typedCode = code;
447
+ if (!corroborates(typedCode, frontmatter)) {
448
+ return {
449
+ kind: 'invalid',
450
+ reason: `\`notToPromote: ${typedCode}\` is not corroborated by the memory's own frontmatter (needs ${NOT_TO_PROMOTE_CORROBORATION[typedCode]})`
451
+ };
452
+ }
453
+ return { kind: 'valid', code: typedCode, reason };
454
+ }
455
+ /**
456
+ * The feedback memories that declare themselves out of the gate. Exposed so the
457
+ * gate can REPORT them: an exemption nobody can see is the vacuity this whole
458
+ * channel is required to avoid. Never throws.
459
+ */
460
+ export function listPromotionExempt(opts) {
461
+ const memoryDir = resolve(opts.projectRoot, '.peaks', 'memory');
462
+ if (!existsSync(memoryDir))
463
+ return [];
464
+ const out = [];
465
+ for (const entry of readdirSync(memoryDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
466
+ if (!entry.isFile() || !entry.name.endsWith('.md') || entry.name.startsWith('.'))
467
+ continue;
468
+ const filePath = join(memoryDir, entry.name);
469
+ const parsed = parseFeedbackMemory(filePath);
470
+ if (parsed === null)
471
+ continue;
472
+ const declaration = readNotToPromote(filePath);
473
+ if (declaration.kind !== 'valid')
474
+ continue;
475
+ if (parsed.promotion !== null)
476
+ continue; // contradiction — reported as a violation instead
477
+ out.push({ name: parsed.name, path: parsed.path, code: declaration.code, reason: declaration.reason });
478
+ }
479
+ return out;
480
+ }
212
481
  /**
213
482
  * Generate the code stub for a given layer. Returns a Markdown
214
483
  * snippet the LLM / human can paste into the appropriate file. Pure
@@ -219,8 +488,8 @@ export function generatePromotionStub(opts) {
219
488
  const { layer, feedbackName } = opts;
220
489
  if (layer === 'A') {
221
490
  return {
222
- snippet: `# SOP entry for feedback "${feedbackName}"\n\n<!-- Append the rule + acceptance criteria below. Reference from a new peaks-sop gate. -->\n\n## Rule\n\n${opts.feedbackBody.split('\n').slice(0, RULE_BODY_PREVIEW_LINES).join('\n')}\n\n## Enforcement\n\nAdd a check to sops/<name>.md and reference from .claude/rules/.`,
223
- targetFiles: [`sops/${feedbackName}.md`]
491
+ snippet: `# SOP entry for feedback "${feedbackName}"\n\n<!-- Append the rule + acceptance criteria below. Reference from a new peaks-sop gate. -->\n\n## Rule\n\n${opts.feedbackBody.split('\n').slice(0, RULE_BODY_PREVIEW_LINES).join('\n')}\n\n## Enforcement\n\nAuthor the rule's gates in the generated manifest and reference it from .claude/rules/.`,
492
+ targetFiles: [`.peaks/sops/${sopIdForFeedback(feedbackName)}/sop.json`]
224
493
  };
225
494
  }
226
495
  if (layer === 'B') {
@@ -235,6 +504,37 @@ export function generatePromotionStub(opts) {
235
504
  targetFiles: ['src/services/code/mode-gate.ts', `tests/unit/services/code/${feedbackName}-hard-floor.test.ts`]
236
505
  };
237
506
  }
507
+ /**
508
+ * Materialize the layer-A artifact: the SOP manifest the engine reads, plus its
509
+ * registry entry. Registration is not optional — `gate-enforce-service` walks
510
+ * `readRegistry()`, so an unregistered manifest enforces nothing.
511
+ *
512
+ * Returns the paths written. `registerSop` lints the manifest first, so a
513
+ * malformed generation throws here rather than leaving a promotion that only
514
+ * looks real.
515
+ */
516
+ async function generateLayerAArtifact(parsed, projectRoot) {
517
+ const id = sopIdForFeedback(parsed.name);
518
+ const manifestPath = projectSopManifestPath(projectRoot, id);
519
+ const description = parsed.frontmatter.description ?? '';
520
+ const manifest = {
521
+ id,
522
+ name: parsed.name,
523
+ description: `Promoted from feedback memory .peaks/memory/${parsed.name}.md${description.length > 0 ? `: ${description}` : ''}`,
524
+ phases: ['apply'],
525
+ gates: [
526
+ {
527
+ id: 'rule-source-present',
528
+ phase: 'apply',
529
+ check: { type: 'file-exists', path: `.peaks/memory/${parsed.name}.md` }
530
+ }
531
+ ]
532
+ };
533
+ mkdirSync(dirname(manifestPath), { recursive: true });
534
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
535
+ await registerSop({ id, projectRoot });
536
+ return [manifestPath, projectRegistryPath(projectRoot)];
537
+ }
238
538
  /**
239
539
  * Write the promotion marker + sidecar. Also writes the envelope to
240
540
  * `.peaks/_runtime/<sid>/rd/feedback-promote-<name>.json` for QA
@@ -245,28 +545,39 @@ export function generatePromotionStub(opts) {
245
545
  * machine-readable mirror. Both are written; either alone is
246
546
  * enough for the scanner.
247
547
  */
248
- export function promoteFeedback(opts) {
548
+ export async function promoteFeedback(opts) {
249
549
  const parsed = parseFeedbackMemory(opts.feedbackPath);
250
550
  if (parsed === null) {
251
551
  throw new Error(`Not a feedback memory: ${opts.feedbackPath}`);
252
552
  }
553
+ // rid 2026-09-14-gate-h-promotion (classify slice): the tool must not create the
554
+ // contradiction the gate rejects — promoting a memory that declares itself out of
555
+ // the gate would print `effective: true` while Gate H reports a contradiction.
556
+ const declaration = readNotToPromote(opts.feedbackPath);
557
+ if (declaration.kind === 'valid') {
558
+ throw new Error(`${opts.feedbackPath} declares \`notToPromote: ${declaration.code}\` — promoting it would contradict that declaration. Remove the declaration first if the memory is a rule after all.`);
559
+ }
253
560
  const stub = generatePromotionStub({
254
561
  layer: opts.layer,
255
562
  feedbackName: parsed.name,
256
563
  feedbackBody: parsed.body
257
564
  });
258
565
  const now = new Date().toISOString();
566
+ const required = promotionArtifactChecks(parsed.name, opts.layer);
259
567
  const envelope = {
260
568
  name: parsed.name,
261
569
  feedbackPath: parsed.path,
262
570
  layer: opts.layer,
263
571
  layerDetail: PROMOTION_LAYER_DETAILS.find((l) => l.layer === opts.layer)?.label ?? opts.layer,
264
- generatedFiles: stub.targetFiles,
572
+ generatedFiles: [],
573
+ requiredArtifacts: required.map((check) => check.path),
574
+ effective: false,
265
575
  snippet: stub.snippet,
266
576
  promotedAt: now,
267
577
  promotedBy: opts.promotedBy
268
578
  };
269
579
  if (opts.dryRun === true) {
580
+ envelope.effective = missingArtifacts(required, opts.projectRoot).length === 0;
270
581
  return envelope;
271
582
  }
272
583
  // 1. Embed comment marker in the memory file.
@@ -283,23 +594,33 @@ export function promoteFeedback(opts) {
283
594
  : `${marker}\n${body}`;
284
595
  const newContent = normalized.slice(0, endIndex + '\n---\n'.length) + newBody;
285
596
  writeFileSync(opts.feedbackPath, newContent, 'utf8');
597
+ envelope.generatedFiles.push(opts.feedbackPath);
598
+ }
599
+ // 2. Layer A: the promotion only means something once the SOP engine can see
600
+ // it. Generated before the sidecar so the sidecar records the full list.
601
+ if (opts.layer === 'A') {
602
+ envelope.generatedFiles.push(...(await generateLayerAArtifact(parsed, opts.projectRoot)));
286
603
  }
287
- // 2. Sidecar (machine-readable mirror).
604
+ // 3. Sidecar (machine-readable mirror).
288
605
  const sidecarPath = opts.feedbackPath.replace(/\.md$/, '.promotion.json');
289
606
  writeFileSync(sidecarPath, JSON.stringify({
290
607
  name: parsed.name,
291
608
  layer: opts.layer,
292
609
  layerDetail: envelope.layerDetail,
293
- generatedFiles: stub.targetFiles,
610
+ generatedFiles: envelope.generatedFiles,
611
+ requiredArtifacts: envelope.requiredArtifacts,
294
612
  promotedAt: now,
295
613
  promotedBy: opts.promotedBy
296
614
  }, null, 2), 'utf8');
297
- // 3. Envelope to `.peaks/_runtime/<sid>/rd/`.
615
+ envelope.generatedFiles.push(sidecarPath);
616
+ // 4. Envelope to `.peaks/_runtime/<sid>/rd/`.
298
617
  const envelopePath = join(opts.projectRoot, '.peaks', '_runtime', opts.sessionId, 'rd', `feedback-promote-${parsed.name}.json`);
299
618
  const envelopeDir = dirname(envelopePath);
300
619
  if (!existsSync(envelopeDir)) {
301
620
  mkdirSync(envelopeDir, { recursive: true });
302
621
  }
622
+ envelope.generatedFiles.push(envelopePath);
623
+ envelope.effective = missingArtifacts(required, opts.projectRoot).length === 0;
303
624
  writeFileSync(envelopePath, JSON.stringify(envelope, null, 2), 'utf8');
304
625
  return envelope;
305
626
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * rid 2026-09-14-gate-h-promotion (R2; repaired by R8) — what a promotion's
3
+ * artifact has to PROVE.
4
+ *
5
+ * The three layers used to be checked with `text.includes(<rule name>)` over the
6
+ * whole file. A substring test cannot tell "the rule is registered" from "a
7
+ * comment saying the rule is absent", so a refusal tree passed every layer:
8
+ *
9
+ * - layer A: a `.peaks/sops/registry.json` that is not valid JSON, with the id
10
+ * still somewhere in its bytes;
11
+ * - layer B: a settings template whose only mention of the rule reads
12
+ * "do NOT add a matcher for <rule>";
13
+ * - layer C: a `mode-gate.ts` whose only mention reads
14
+ * "// TODO: <rule> is DELIBERATELY NOT a hard-floor category".
15
+ *
16
+ * All three are the same defect facing the other way: an unreadable artifact is
17
+ * treated as a permitted one. Each check below therefore parses its evidence and
18
+ * asserts the SHAPE, and every failure is a finding — never a warning that
19
+ * permits. Nothing here throws: an unparseable file is reported, not swallowed.
20
+ *
21
+ * R8 (same rid) — the same defect survived inside R2's repair, in two places:
22
+ *
23
+ * - layer C matched a member's `doc` by name MENTION, so an adverse line
24
+ * sitting between two vocabulary members became the following member's doc
25
+ * and the verdict flipped on where the comment sat. The predicate is now this
26
+ * repo's own citation form — the memory cited by PATH,
27
+ * `.peaks/memory/<id>.md`, exactly as `mode-gate.ts:41` does — rather than
28
+ * the memory merely named. That citation requirement is what closes the
29
+ * placement dependence; the doc is also read from the comment SPANS attached
30
+ * to the member rather than from the raw slice, which states "code is not a
31
+ * doc" as an invariant rather than relying on the raw slice to honour it;
32
+ * - layer B kept `matcher.includes(id)` / `command.includes(id)` over free
33
+ * text, so `command: "echo 'do NOT add a matcher for rule-x'"` registered a
34
+ * rule by writing a sentence about it. Both fields are now parsed: a matcher
35
+ * is a tool selector, a command is argv.
36
+ *
37
+ * R10 (same rid) — layer C read the `HardFloorCategory` union and the
38
+ * `HARD_FLOOR_CATEGORIES` array as ONE flattened member list, so a member of the
39
+ * union alone satisfied the check. Measured on the R8 bytes: a union-only literal
40
+ * — and a union-only member whose doc cited `.peaks/memory/<id>.md` — were both
41
+ * reported BACKED, while `isHardFloorCategory` (which reads the array) returned
42
+ * false and `shouldPauseAtGate` returned `shouldPause: false`. The gate certified
43
+ * a hard floor that paused nothing, contradicting its own message. Only the array
44
+ * enforces, so only the array is now evidence — for the name and for the citation
45
+ * alike. The union is still READ, for one reason only: a category's doc belongs
46
+ * to the category, and this repo documents `commit-boundary-side-effect` beside
47
+ * the union member while the array enforces it.
48
+ */
49
+ export type PromotionEvidence =
50
+ /** A SOP manifest: a JSON object whose `id` is the SOP's and whose `gates` is an array. */
51
+ 'sop-manifest'
52
+ /** An entry with the SOP's `id` inside `<registry>.sops[]` — what `readRegistry()` enumerates. */
53
+ | 'sop-registry-entry'
54
+ /** A hook registration (hook command) inside `hooks` that runs something named after the rule. */
55
+ | 'hook-registration'
56
+ /** A member of `HARD_FLOOR_CATEGORIES` — the array `isHardFloorCategory` reads — that names the rule. */
57
+ | 'hard-floor-category';
58
+ export type PromotionArtifactCheck = {
59
+ /** Project-relative POSIX path whose CONTENT must carry the evidence. */
60
+ path: string;
61
+ evidence: PromotionEvidence;
62
+ /** The rule id the evidence must name (a SOP id for layer A, a memory name for B and C). */
63
+ id: string;
64
+ };
65
+ /**
66
+ * The reason `check` is unsatisfied by `text`, or `null` when it is satisfied.
67
+ * Never throws: a file that cannot be parsed yields the reason it could not be.
68
+ */
69
+ export declare function artifactEvidenceFailure(check: PromotionArtifactCheck, text: string): string | null;