peaks-loop 4.0.47 → 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 (126) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/agents/karpathy-reviewer.md +11 -10
  5. package/dist/cli/cli-helpers.d.ts +34 -0
  6. package/dist/cli/cli-helpers.js +57 -0
  7. package/dist/cli/commands/code-job-shape-commands.js +8 -0
  8. package/dist/cli/commands/code-runtime-commands.js +48 -8
  9. package/dist/cli/commands/compact-command.js +110 -0
  10. package/dist/cli/commands/config-commands.js +15 -9
  11. package/dist/cli/commands/dashboard-long-run.js +6 -0
  12. package/dist/cli/commands/dispatch-commands.js +11 -1
  13. package/dist/cli/commands/doctor/invoke-from-code.js +6 -0
  14. package/dist/cli/commands/feedback-commands.d.ts +11 -7
  15. package/dist/cli/commands/feedback-commands.js +49 -17
  16. package/dist/cli/commands/final-review-commands.js +12 -0
  17. package/dist/cli/commands/hooks-commands.js +4 -4
  18. package/dist/cli/commands/job-commands.js +8 -0
  19. package/dist/cli/commands/loop-eval-commands.js +31 -0
  20. package/dist/cli/commands/perf-audit-commands.js +2 -0
  21. package/dist/cli/commands/playwright-commands.js +12 -0
  22. package/dist/cli/commands/prd-commands.js +1 -1
  23. package/dist/cli/commands/qa-commands.js +22 -0
  24. package/dist/cli/commands/request-commands.js +8 -0
  25. package/dist/cli/commands/scan-commands.js +1 -1
  26. package/dist/cli/commands/security-audit-commands.js +2 -0
  27. package/dist/cli/commands/slice-integrate-commands.js +22 -0
  28. package/dist/cli/commands/statusline-commands.js +44 -4
  29. package/dist/cli/commands/sub-agent/detached.d.ts +14 -1
  30. package/dist/cli/commands/sub-agent/detached.js +47 -22
  31. package/dist/cli/commands/sub-agent-shutdown-commands.js +11 -0
  32. package/dist/cli/commands/verdict-aggregate-command.js +95 -13
  33. package/dist/cli/commands/workflow-commands.js +1 -1
  34. package/dist/cli/index.js +5 -45
  35. package/dist/services/artifacts/artifact-prerequisites.d.ts +38 -7
  36. package/dist/services/artifacts/artifact-prerequisites.js +140 -65
  37. package/dist/services/artifacts/request-artifact-service.d.ts +8 -0
  38. package/dist/services/artifacts/request-artifact-service.js +77 -46
  39. package/dist/services/artifacts/request-artifact-state-helpers.d.ts +57 -0
  40. package/dist/services/artifacts/request-artifact-state-helpers.js +91 -10
  41. package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
  42. package/dist/services/audit-independent/perf-audit-service.d.ts +9 -0
  43. package/dist/services/audit-independent/perf-audit-service.js +27 -5
  44. package/dist/services/audit-independent/security-audit-service.d.ts +12 -2
  45. package/dist/services/audit-independent/security-audit-service.js +28 -6
  46. package/dist/services/code/auto-compact-lifecycle.d.ts +194 -0
  47. package/dist/services/code/auto-compact-lifecycle.js +229 -11
  48. package/dist/services/code/auto-compact-orchestrator.js +118 -7
  49. package/dist/services/code/compact-event-settle.d.ts +134 -0
  50. package/dist/services/code/compact-event-settle.js +240 -0
  51. package/dist/services/compact-history/compact-history-service.d.ts +14 -0
  52. package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
  53. package/dist/services/config/config-restore.d.ts +12 -1
  54. package/dist/services/config/config-restore.js +35 -4
  55. package/dist/services/config/config-rollback.js +6 -1
  56. package/dist/services/context/auto-compact-types.d.ts +20 -2
  57. package/dist/services/context/harness-context-witness.d.ts +310 -0
  58. package/dist/services/context/harness-context-witness.js +606 -0
  59. package/dist/services/evidence/evidence-generator.js +86 -49
  60. package/dist/services/feedback/feedback-promotion-service.d.ts +137 -14
  61. package/dist/services/feedback/feedback-promotion-service.js +341 -20
  62. package/dist/services/feedback/promotion-artifact-evidence.d.ts +69 -0
  63. package/dist/services/feedback/promotion-artifact-evidence.js +332 -0
  64. package/dist/services/final-review/final-review-service.d.ts +9 -0
  65. package/dist/services/final-review/final-review-service.js +36 -12
  66. package/dist/services/ide/ide-registry.d.ts +19 -0
  67. package/dist/services/ide/ide-registry.js +21 -0
  68. package/dist/services/job/job-progress-store.js +18 -3
  69. package/dist/services/job/job-state-store.js +7 -0
  70. package/dist/services/observability/jsonl-store.d.ts +19 -0
  71. package/dist/services/observability/jsonl-store.js +27 -2
  72. package/dist/services/observability/observability-service.d.ts +10 -3
  73. package/dist/services/observability/observability-service.js +16 -3
  74. package/dist/services/polyrepo/polyrepo-dispatcher.js +11 -0
  75. package/dist/services/prd/handoff-auto-regen.js +31 -27
  76. package/dist/services/prd/handoff-frontmatter.d.ts +44 -0
  77. package/dist/services/prd/handoff-frontmatter.js +75 -0
  78. package/dist/services/prd/handoff-service.d.ts +41 -2
  79. package/dist/services/prd/handoff-service.js +124 -8
  80. package/dist/services/prd/handoff-types.d.ts +3 -2
  81. package/dist/services/prd/handoff-types.js +3 -2
  82. package/dist/services/qa/qa-business-review-state.js +23 -0
  83. package/dist/services/sc/sc-service.d.ts +8 -0
  84. package/dist/services/sc/sc-service.js +8 -1
  85. package/dist/services/scan/karpathy-service.js +2 -2
  86. package/dist/services/session/getSessionDir.d.ts +33 -0
  87. package/dist/services/session/getSessionDir.js +60 -0
  88. package/dist/services/session/session-checkpoint-service.js +8 -0
  89. package/dist/services/skill/resume-detector.js +29 -11
  90. package/dist/services/skills/hooks-codegate-superpowers.d.ts +6 -0
  91. package/dist/services/skills/hooks-codegate-superpowers.js +61 -2
  92. package/dist/services/skills/hooks-settings-service.js +14 -4
  93. package/dist/services/skills/session-start-hook-constants.d.ts +45 -0
  94. package/dist/services/skills/session-start-hook-constants.js +45 -0
  95. package/dist/services/skills/skill-statusline-service.d.ts +14 -0
  96. package/dist/services/slice/slice-check-service.js +29 -11
  97. package/dist/services/slice/slice-review-state.js +23 -0
  98. package/dist/services/workflow/pipeline-verify-gate-support.d.ts +47 -10
  99. package/dist/services/workflow/pipeline-verify-gate-support.js +221 -103
  100. package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
  101. package/dist/services/workflow/pipeline-verify-service.js +47 -33
  102. package/dist/services/workflow/pipeline-verify-types.d.ts +15 -6
  103. package/dist/services/workspace/claude-settings-template.d.ts +56 -8
  104. package/dist/services/workspace/claude-settings-template.js +98 -20
  105. package/dist/services/workspace/workspace-claude-settings-materializer.js +78 -7
  106. package/dist/shared/runtime-root.d.ts +73 -0
  107. package/dist/shared/runtime-root.js +77 -0
  108. package/package.json +6 -6
  109. package/skills/bee/peaks-prd/SKILL.md +7 -5
  110. package/skills/bee/peaks-qa/SKILL.md +5 -5
  111. package/skills/bee/peaks-qa/references/qa-runbook.md +2 -2
  112. package/skills/bee/peaks-qa/references/qa-transition-gates.md +7 -7
  113. package/skills/bee/peaks-rd/SKILL.md +8 -6
  114. package/skills/bee/peaks-rd/references/artifact-per-request.md +2 -2
  115. package/skills/bee/peaks-rd/references/parallel-review-fanout.md +7 -5
  116. package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +13 -13
  117. package/skills/bee/peaks-rd/references/rd-runbook.md +9 -5
  118. package/skills/bee/peaks-rd/references/rd-transition-gates.md +9 -7
  119. package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +6 -6
  120. package/skills/peaks-code/SKILL.md +1 -1
  121. package/skills/peaks-code/references/a2a-artifact-mapping.md +3 -3
  122. package/skills/peaks-code/references/local-artifact-workspace.md +1 -1
  123. package/skills/peaks-code/references/resume-detection.md +13 -7
  124. package/skills/peaks-code/references/runbook.md +3 -2
  125. package/skills/peaks-code/references/session-overload-signal-index.md +2 -1
  126. package/skills/peaks-code/references/workflow-gates-and-types.md +8 -6
@@ -2,6 +2,7 @@ import { join, dirname, basename } from 'node:path';
2
2
  import { readFile, readdir } from 'node:fs/promises';
3
3
  import { pathExists } from 'peaks-loop-shared/fs';
4
4
  import { emitObservabilityEvent } from '../observability/observability-service.js';
5
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
5
6
  export const VALID_REQUEST_TYPES = [
6
7
  'feature',
7
8
  'bugfix',
@@ -26,8 +27,17 @@ const BUG_ANALYSIS = {
26
27
  description: 'Bug root-cause analysis (reproduction, affected paths, fix approach, regression test plan)',
27
28
  mustContain: ['## Root cause', '## Fix approach']
28
29
  };
30
+ // Slice `2026-09-14-audit-artifact-rid-scoping`: the rid is part of the
31
+ // filename (`code-review-<rid>.md`). A fixed per-session path cannot hold
32
+ // two slices' evidence at once — the second slice's write silently
33
+ // destroyed the first slice's on 2026-09-13, and the gate stayed green
34
+ // because it only checks that *some* file is there, not whose it is.
35
+ // The bare path stays accepted (see `legacyRelativePath`) so sessions
36
+ // written before this change — and any writer still emitting it — keep
37
+ // passing.
29
38
  const CODE_REVIEW = {
30
- relativePath: 'rd/code-review.md',
39
+ relativePath: 'rd/code-review-<rid>.md',
40
+ legacyRelativePath: 'rd/code-review.md',
31
41
  description: 'Code review evidence (CRITICAL/HIGH must be fixed before handoff)',
32
42
  mustContain: ['## Findings', 'CRITICAL']
33
43
  };
@@ -52,23 +62,31 @@ const PERF_BASELINE = {
52
62
  // are dispatched as pre-RD audit runs that consume the public PRD
53
63
  // handoff (`prd/handoff.md`) — see `AUDIT_REQUIRES_HANDOFF` below.
54
64
  //
55
- // Back-compat: the canonical location is `audit/security.md` (and
56
- // `audit/perf.md`); the legacy `rd/security-review.md` (and
57
- // `rd/perf-baseline.md`) path is also accepted via `legacyRelativePath`
58
- // for the 1-minor-release window. v2.13.0 hard-deletes the legacy paths.
65
+ // Back-compat: the canonical location is `audit/security-<rid>.md`
66
+ // (and `audit/perf-<rid>.md`); the pre-rid-scoping `audit/security.md`
67
+ // (and `audit/perf.md`) plus the older `rd/security-review.md` (and
68
+ // `rd/perf-baseline.md`) are accepted via `legacyRelativePaths`, in
69
+ // that order, for the back-compat window.
59
70
  const AUDIT_SECURITY = {
60
- relativePath: 'audit/security.md',
61
- legacyRelativePath: 'rd/security-review.md',
62
- description: 'Independent security audit output (peaks-security-audit skill, v2.12.0+). Replaces the v2.11.x rd/security-review.md slot for fanout-trigger request types. The legacy path is accepted during the 1-minor-release back-compat window via legacyRelativePath.',
71
+ relativePath: 'audit/security-<rid>.md',
72
+ // Two legacy tiers behind the rid-scoped path: the pre-rid-scoping
73
+ // v2.12.0 location, then the v2.11.x one. Both are live on disk.
74
+ legacyRelativePath: 'audit/security.md',
75
+ legacyRelativePaths: ['rd/security-review.md'],
76
+ description: 'Independent security audit output (peaks-security-audit skill, v2.12.0+). Replaces the v2.11.x rd/security-review.md slot for fanout-trigger request types. The rid is part of the filename so two slices in one session do not collide; the bare and rd/-prefixed legacy locations are accepted during the back-compat window via legacyRelativePaths.',
63
77
  // New canonical path writes a "## Verdict" header on the audit
64
78
  // envelope. The legacy rd/security-review.md writes a "## Findings"
65
79
  // header. Both pass the gate.
66
80
  mustContainAny: ['## Verdict', '## Findings']
67
81
  };
68
82
  const AUDIT_PERF = {
69
- relativePath: 'audit/perf.md',
70
- legacyRelativePath: 'rd/perf-baseline.md',
71
- description: 'Independent perf audit output (peaks-perf-audit skill, v2.12.0+). Replaces the v2.11.x rd/perf-baseline.md slot for fanout-trigger request types. The legacy path is accepted during the 1-minor-release back-compat window via legacyRelativePath.',
83
+ relativePath: 'audit/perf-<rid>.md',
84
+ // Same two-tier history as AUDIT_SECURITY. This is the pair that
85
+ // produced the 2026-09-13 evidence: `audit/perf.md` was overwritten by
86
+ // the second slice and the first slice's audit is unrecoverable.
87
+ legacyRelativePath: 'audit/perf.md',
88
+ legacyRelativePaths: ['rd/perf-baseline.md'],
89
+ description: 'Independent perf audit output (peaks-perf-audit skill, v2.12.0+). Replaces the v2.11.x rd/perf-baseline.md slot for fanout-trigger request types. The rid is part of the filename so two slices in one session do not collide; the bare and rd/-prefixed legacy locations are accepted during the back-compat window via legacyRelativePaths.',
72
90
  // New schema writes "## Baseline" header; the legacy schema writes
73
91
  // "## Results". Both pass the gate, as does the explicit
74
92
  // no-perf-surface stub (slices whose surface is purely logic /
@@ -93,6 +111,16 @@ const AUDIT_PERF = {
93
111
  // failure. The hard-fail behavior for `passed: false` is preserved
94
112
  // (the body still has to carry `"passed": true` when present).
95
113
  // v2.14.0 will remove `backCompat` and re-elevate missing → throw.
114
+ //
115
+ // Slice `2026-09-14-audit-artifact-rid-scoping` deliberately does NOT
116
+ // rid-scope this path, unlike the four audit/review artifacts above. The
117
+ // repo's own producer/reader constant is `mutReportPath()` in
118
+ // `packages/peaks-loop-mut/src/services/mut/report-loader.ts`, which names
119
+ // `mut/mut-report.json`, and `peaks mut run`'s `--out` is supplied by the
120
+ // caller. A `<rid>`-templated requirement here would be a name no producer
121
+ // in the repo can write — a gate that exists only in prose, sitting behind
122
+ // `backCompat: true` so it gates nothing at all. If mut ever gains a
123
+ // rid-scoped producer, the constant and this path move together.
96
124
  const MUT_REPORT = {
97
125
  relativePath: 'mut/mut-report.json',
98
126
  description: 'peaks-mut mutation + weak-assertion report (Plan 2, v2.12.0+). v2.13.1: required at rd:qa-handoff for feature / bugfix / refactor slices. v2.13.2: back-compat window — missing file downgrades to a warning (1-minor-release); passed:false still throws. The body must carry `"passed": true` when present; failed runs are blocked at this gate. CONFIG / DOCS / CHORE slices retain the legacy "no acceptance surface" exemption.',
@@ -105,10 +133,11 @@ const MUT_REPORT = {
105
133
  backCompat: true
106
134
  };
107
135
  // v2.12.0 Group B Tier 5 — gate that the peaks-prd handoff (the
108
- // immutable handoff capsule at `prd/handoff.md`) exists before any
136
+ // immutable, per-slice handoff capsule at `prd/handoff-<rid>.md`
137
+ // since slice `2026-09-14-prd-capsule-rid-scoping`) exists before any
109
138
  // audit skill is allowed to consume it. The peaks-security-audit
110
- // and peaks-perf-audit CLI commands read frontmatter from
111
- // `prd/handoff.md` (AC-2.4 / AC-3.4); if the handoff is missing the
139
+ // and peaks-perf-audit CLI commands read frontmatter from the
140
+ // capsule (AC-2.4 / AC-3.4); if the handoff is missing the
112
141
  // audit skill aborts — and so should the prereq gate when those
113
142
  // audits are required at rd:qa-handoff.
114
143
  //
@@ -117,8 +146,21 @@ const MUT_REPORT = {
117
146
  // the old form (no PRD handoff chain — config slices may run before
118
147
  // PRD handoff exists for small CONFIG-only commits).
119
148
  const AUDIT_REQUIRES_HANDOFF = {
120
- relativePath: 'prd/handoff.md',
121
- description: 'PRD handoff capsule (v2.12.0+) — peaks-security-audit / peaks-perf-audit both read frontmatter from this file. Must exist before audit prereqs are evaluated at rd:qa-handoff.',
149
+ relativePath: 'prd/handoff-<rid>.md',
150
+ // Slice `2026-09-14-prd-capsule-rid-scoping`: the rid is part of the
151
+ // filename, like the four audit/review artifacts above. A single slot per
152
+ // SESSION cannot hold two slices' capsules — `peaks prd handoff init`
153
+ // overwrote it on every call, and this gate stayed green because it pinned
154
+ // only `schemaVersion: 2` + `sha256:`, never WHOSE rid the file named.
155
+ // Measured on `2026-09-13-session-21878f`: a four-slice job passed this
156
+ // prerequisite on a capsule written for a different line of work.
157
+ // The bare path stays accepted (see `legacyRelativePath`) so the three
158
+ // sessions on disk that hold only `prd/handoff.md` keep passing. That tier
159
+ // is rid-blind by design — the scoping binds for capsules written from now
160
+ // on, and every producer (`handoff-service.initHandoff`,
161
+ // `handoff-auto-regen`, `evidence generate`) writes the rid-scoped name.
162
+ legacyRelativePath: 'prd/handoff.md',
163
+ description: 'PRD handoff capsule (v2.12.0+) — peaks-security-audit / peaks-perf-audit both read frontmatter from this file. Must exist before audit prereqs are evaluated at rd:qa-handoff. The rid is part of the filename so two slices in one session do not collide; the bare pre-rid-scoping location is accepted as the legacy tier.',
122
164
  // Empty `mustContain` is fine — file existence is the contract.
123
165
  // The peaks-prd handoff service writes frontmatter with
124
166
  // `schemaVersion: 2` and a sha256 fingerprint; we pin a
@@ -138,7 +180,8 @@ const AUDIT_REQUIRES_HANDOFF = {
138
180
  // header remains a substring match (it is the file's own gate header, not
139
181
  // a structural section anchor).
140
182
  const KARPATHY_REVIEW = {
141
- relativePath: 'rd/karpathy-review.md',
183
+ relativePath: 'rd/karpathy-review-<rid>.md',
184
+ legacyRelativePath: 'rd/karpathy-review.md',
142
185
  description: 'RD-side karpathy review (peaks-rd 5-way fanout) — must contain a "## Karpathy-Gate" header AND the 4 guideline section markers (Think Before Coding / Simplicity First / Surgical Changes / Goal-Driven Execution) as actual markdown headings. Per karpathy §1 / §3.',
143
186
  mustContain: ['## Karpathy-Gate'],
144
187
  headingMustContain: [
@@ -328,11 +371,71 @@ async function resolvePrerequisiteAbsolutePath(sessionRoot, prerequisite, reques
328
371
  const match = entries.find((name) => /^\d+-/.test(name) && name.endsWith(targetSuffix));
329
372
  return match ? join(dir, match) : null;
330
373
  }
374
+ /**
375
+ * The contract's BODY checks for one prerequisite, applied to `body`. Returns
376
+ * one human-readable message per failed check, empty when the body satisfies
377
+ * the contract.
378
+ *
379
+ * Exported because this contract has a SECOND enforcer: `peaks workflow
380
+ * verify-pipeline` probes the same table for the paths it checks
381
+ * (`pipeline-verify-gate-support.ts`), and a resolver that probed only
382
+ * `existsSync` passed files this function rejects — a guard laxer than the
383
+ * contract it claims to read. Both callers now share this one implementation,
384
+ * so the checker cannot drift from the table again.
385
+ */
386
+ export function prerequisiteBodyViolations(prerequisite, body) {
387
+ const violations = [];
388
+ const lowered = body.toLowerCase();
389
+ if (prerequisite.mustContain && prerequisite.mustContain.length > 0) {
390
+ const missingMarkers = prerequisite.mustContain.filter((marker) => !lowered.includes(marker.toLowerCase()));
391
+ if (missingMarkers.length > 0) {
392
+ violations.push(`${prerequisite.description} — missing section(s): ${missingMarkers.join(', ')}`);
393
+ }
394
+ }
395
+ if (prerequisite.headingMustContain && prerequisite.headingMustContain.length > 0) {
396
+ // Slice 2.6.1.F: a line beginning with `#`, `##`, or `###` followed by
397
+ // the marker (case-insensitive). Fenced code blocks are NOT excluded
398
+ // here — a "heading" inside a code fence is rare and, when present,
399
+ // should still be reported as missing to keep the contract strict.
400
+ const headingLines = body
401
+ .split('\n')
402
+ .map((line) => line.trim())
403
+ .filter((line) => /^#{1,3}\s+/.test(line));
404
+ const loweredHeadings = headingLines.map((h) => h.toLowerCase());
405
+ const missingHeadings = prerequisite.headingMustContain.filter((marker) => !loweredHeadings.some((h) => h.includes(marker.toLowerCase())));
406
+ if (missingHeadings.length > 0) {
407
+ violations.push(`${prerequisite.description} — missing heading(s): ${missingHeadings.join(', ')}`);
408
+ }
409
+ }
410
+ if (prerequisite.mustContainAny && prerequisite.mustContainAny.length > 0) {
411
+ const hitAny = prerequisite.mustContainAny.some((marker) => lowered.includes(marker.toLowerCase()));
412
+ if (!hitAny) {
413
+ violations.push(`${prerequisite.description} — none of the escape-hatch markers present: ${prerequisite.mustContainAny.join(', ')}`);
414
+ }
415
+ }
416
+ return violations;
417
+ }
418
+ /** True when `prerequisite` declares any body check at all. Guards the read so
419
+ * a prereq with no body contract is never opened (unchanged behaviour). */
420
+ function hasBodyChecks(prerequisite) {
421
+ return (prerequisite.mustContain?.length ?? 0) > 0
422
+ || (prerequisite.headingMustContain?.length ?? 0) > 0
423
+ || (prerequisite.mustContainAny?.length ?? 0) > 0;
424
+ }
331
425
  export async function checkPrerequisites(options) {
332
426
  const requirements = getPrerequisitesFor(options.role, options.newState, options.requestType);
333
427
  if (requirements.length === 0) {
334
428
  return { ok: true, missing: [], warnings: [] };
335
429
  }
430
+ // Repair R5. The session id is joined into BOTH roots below and then probed
431
+ // on disk. `transitionRequestArtifact` is the only caller and it passes
432
+ // `existing.sessionId`, which pre-R5 was the caller's raw `--session-id`: with
433
+ // `../../../PWNED-R34` the two joins resolved outside the project root and
434
+ // the prerequisite probes ran there. Guarded at the sink, not at the caller,
435
+ // for the same reason `requestArtifactRequestsDir` is.
436
+ if (options.sessionId !== undefined && isUnsafePathInput(options.sessionId)) {
437
+ throw new Error(`Invalid session id: ${options.sessionId} (must be a single path segment)`);
438
+ }
336
439
  // Slice 006 simplifies the resolution to a 2-tier fallback. The
337
440
  // per-change-id scope (`.peaks/_runtime/<sessionId>/<role>/`) is gone — new
338
441
  // artifacts go to the session dir directly. The 2 tiers are:
@@ -369,45 +472,10 @@ export async function checkPrerequisites(options) {
369
472
  missing.push({ path: relative, description: prerequisite.description });
370
473
  continue;
371
474
  }
372
- if (prerequisite.mustContain && prerequisite.mustContain.length > 0) {
475
+ if (hasBodyChecks(prerequisite)) {
373
476
  const body = await readFile(absolute, 'utf8');
374
- const lowered = body.toLowerCase();
375
- const missingMarkers = prerequisite.mustContain.filter((marker) => !lowered.includes(marker.toLowerCase()));
376
- if (missingMarkers.length > 0) {
377
- missing.push({
378
- path: relative,
379
- description: `${prerequisite.description} — missing section(s): ${missingMarkers.join(', ')}`
380
- });
381
- }
382
- }
383
- if (prerequisite.headingMustContain && prerequisite.headingMustContain.length > 0) {
384
- const body = await readFile(absolute, 'utf8');
385
- // Slice 2.6.1.F: a line beginning with `#`, `##`, or `###` followed by
386
- // the marker (case-insensitive). Fenced code blocks are NOT excluded
387
- // here — a "heading" inside a code fence is rare and, when present,
388
- // should still be reported as missing to keep the contract strict.
389
- const headingLines = body
390
- .split('\n')
391
- .map((line) => line.trim())
392
- .filter((line) => /^#{1,3}\s+/.test(line));
393
- const loweredHeadings = headingLines.map((h) => h.toLowerCase());
394
- const missingHeadings = prerequisite.headingMustContain.filter((marker) => !loweredHeadings.some((h) => h.includes(marker.toLowerCase())));
395
- if (missingHeadings.length > 0) {
396
- missing.push({
397
- path: relative,
398
- description: `${prerequisite.description} — missing heading(s): ${missingHeadings.join(', ')}`
399
- });
400
- }
401
- }
402
- if (prerequisite.mustContainAny && prerequisite.mustContainAny.length > 0) {
403
- const body = await readFile(absolute, 'utf8');
404
- const lowered = body.toLowerCase();
405
- const hitAny = prerequisite.mustContainAny.some((marker) => lowered.includes(marker.toLowerCase()));
406
- if (!hitAny) {
407
- missing.push({
408
- path: relative,
409
- description: `${prerequisite.description} — none of the escape-hatch markers present: ${prerequisite.mustContainAny.join(', ')}`
410
- });
477
+ for (const description of prerequisiteBodyViolations(prerequisite, body)) {
478
+ missing.push({ path: relative, description });
411
479
  }
412
480
  }
413
481
  }
@@ -454,12 +522,13 @@ function emitPrereqTransitionEvent(opts) {
454
522
  * (e.g. `001-<rid>.md`) at every tier. Returns the matched absolute
455
523
  * path, or null when nothing matches.
456
524
  *
457
- * v2.12.0 Group B Tier 5: if `prerequisite.legacyRelativePath` is set
458
- * and neither primary session-root tier resolved, the resolver tries
459
- * the legacy relative path at BOTH session roots before declaring the
460
- * prereq missing. This is the 1-minor-release back-compat mechanism
461
- * for artifacts that moved location (e.g. `rd/security-review.md` →
462
- * `audit/security.md`).
525
+ * Back-compat: once neither primary session-root tier resolved, the
526
+ * resolver walks `prerequisite.legacyRelativePaths` in declared order
527
+ * and tries each at BOTH session roots before declaring the prereq
528
+ * missing. This covers both artifacts that moved location
529
+ * (`rd/security-review.md` → `audit/security.md`) and ones that later
530
+ * gained a rid in the filename (`audit/security.md` →
531
+ * `audit/security-<rid>.md`, slice `2026-09-14-audit-artifact-rid-scoping`).
463
532
  */
464
533
  async function resolvePrerequisiteAbsolutePathWithFallback(canonicalSessionRoot, legacySessionRoot, prerequisite, requestId) {
465
534
  const roots = [canonicalSessionRoot, legacySessionRoot];
@@ -471,13 +540,19 @@ async function resolvePrerequisiteAbsolutePathWithFallback(canonicalSessionRoot,
471
540
  if (found !== null)
472
541
  return found;
473
542
  }
474
- // Second pass: if a legacyRelativePath is declared, try it at every
475
- // root. Only the relativePath is swapped — the same numbered-prefix
543
+ // Second pass: the single previous location, then any further older
544
+ // ones, at every root — newest first, so a session that somehow holds
545
+ // both a pre-rid and a v2.11.x file resolves to the newer shape.
546
+ // Only the relativePath is swapped — the same numbered-prefix
476
547
  // tolerance applies via the shared resolver.
477
- if (prerequisite.legacyRelativePath !== undefined) {
548
+ const legacyTiers = [
549
+ ...(prerequisite.legacyRelativePath !== undefined ? [prerequisite.legacyRelativePath] : []),
550
+ ...(prerequisite.legacyRelativePaths ?? [])
551
+ ];
552
+ for (const legacy of legacyTiers) {
478
553
  const legacyPrereq = {
479
554
  ...prerequisite,
480
- relativePath: prerequisite.legacyRelativePath
555
+ relativePath: legacy
481
556
  };
482
557
  for (const root of roots) {
483
558
  if (root === null)
@@ -50,6 +50,14 @@ export type CreateRequestArtifactResult = {
50
50
  */
51
51
  scopeDir: string;
52
52
  };
53
+ /**
54
+ * F-1 (slice 025 security): reject rids that contain path separators, null
55
+ * bytes, or traversal sequences. A request id is a single path segment, so
56
+ * anything that builds a filename from one must test it against this first —
57
+ * it is exported so those call sites reuse it instead of re-declaring a copy
58
+ * that can drift.
59
+ */
60
+ export declare const REQUEST_ID_PATTERN: RegExp;
53
61
  export declare function createRequestArtifact(options: CreateRequestArtifactOptions): Promise<CreateRequestArtifactResult>;
54
62
  export type RequestArtifactSummary = {
55
63
  role: RequestArtifactRole;
@@ -11,6 +11,8 @@ import { ensureSession, getSessionIdCanonical } from '../session/session-manager
11
11
  // live at `shared/path-safety.ts` if this module ever needs them.
12
12
  import { getNextNumber, buildNumberedFilename, slugifyDescription } from '../../shared/incrementing-number.js';
13
13
  import { lintRequestArtifact } from './artifact-lint-service.js';
14
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
15
+ import { guardRuntimeSegment, runtimeRoot } from '../../shared/runtime-root.js';
14
16
  import { checkTypeSanity } from '../scan/type-sanity-service.js';
15
17
  import { requireUserConfirmation } from '../mode/mode-enforcement.js';
16
18
  import { scanFileSize } from '../scan/file-size-scan.js';
@@ -27,7 +29,14 @@ export { VALID_REQUEST_TYPES, DEFAULT_REQUEST_TYPE, isRequestType };
27
29
  // the local namespace under `isolatedModules`).
28
30
  import { renderTemplate } from './artifact-templates.js';
29
31
  export { formatHandoffPath, formatCommitBoundaryPath, formatSkillUsageLessonsPath } from './artifact-templates.js';
30
- const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
32
+ /**
33
+ * F-1 (slice 025 security): reject rids that contain path separators, null
34
+ * bytes, or traversal sequences. A request id is a single path segment, so
35
+ * anything that builds a filename from one must test it against this first —
36
+ * it is exported so those call sites reuse it instead of re-declaring a copy
37
+ * that can drift.
38
+ */
39
+ export const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
31
40
  const VALID_ROLES = new Set(['prd', 'ui', 'rd', 'qa', 'sc']);
32
41
  function defaultClock() {
33
42
  return new Date().toISOString();
@@ -38,6 +47,30 @@ function dateSlugFromIso(iso) {
38
47
  function defaultSessionId(iso) {
39
48
  return `${dateSlugFromIso(iso)}-session`;
40
49
  }
50
+ /**
51
+ * The single place a request-artifact directory is built, and therefore the
52
+ * single place the ids that reach it are guarded.
53
+ *
54
+ * Repair R5. This join used to be written at four sites; `createRequestArtifact`
55
+ * guarded its session id and the other three did not. The two ids are the whole
56
+ * of the invariant — `role` is a path segment too, and the closed-set check its
57
+ * callers perform is not visible from here.
58
+ *
59
+ * The guard belongs HERE and not at the entry points because the invariant is
60
+ * the resolved path, not the flag. `transitionRequestArtifact` is the measured
61
+ * cost: it performs no join of its own (it delegates the path to
62
+ * `showRequestArtifact`), so it had no guard and no join to hang one on.
63
+ * `--session-id ../../../PWNED-R34` resolved `.peaks/_runtime/../../../PWNED-R34`,
64
+ * wrote `state: blocked` to a file outside the project root, and only then threw
65
+ * — from `emitObservabilityEvent`'s own session-id check, i.e. after the write.
66
+ */
67
+ function requestArtifactRequestsDir(projectRoot, sessionId, role) {
68
+ // Slice 2026-09-15 (runtime-path-unrepresentable): the two ids are branded by
69
+ // `guardRuntimeSegment`, which performs the same `isUnsafePathInput` check
70
+ // this function used to spell inline. The join itself now *requires* the
71
+ // brand, so a caller that reaches this dir without a guard does not compile.
72
+ return runtimeRoot(projectRoot).join(guardRuntimeSegment(sessionId, 'session id'), guardRuntimeSegment(role, 'role'), guardRuntimeSegment('requests', 'leaf'));
73
+ }
41
74
  export async function createRequestArtifact(options) {
42
75
  if (!VALID_ROLES.has(options.role)) {
43
76
  throw new Error(`Invalid role: ${String(options.role)} (expected prd, ui, rd, qa, or sc)`);
@@ -60,6 +93,13 @@ export async function createRequestArtifact(options) {
60
93
  // in the artifact body's frontmatter (under `- change-id:`) for
61
94
  // human navigation; it is no longer a filesystem path key.
62
95
  const sessionId = options.sessionId ?? await ensureSession(options.projectRoot);
96
+ // Sid axis. The rid axis is guarded three times above (`REQUEST_ID_PATTERN`
97
+ // at :110, and again in the numbered-filename path); the session id was
98
+ // never checked, so `--session-id ../../x` wrote the artifact outside the
99
+ // project root under an `ok: true` envelope.
100
+ if (isUnsafePathInput(sessionId)) {
101
+ throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
102
+ }
63
103
  // Slice 2026-06-29-change-id-root-removal: the `current-change`
64
104
  // binding file is gone. Resolution order for the change-id (file
65
105
  // body metadata) is now:
@@ -80,7 +120,7 @@ export async function createRequestArtifact(options) {
80
120
  // `mkdir(..., { recursive: true })`.
81
121
  const LOOKS_LIKE_SESSION_ID = /^\d{4}-\d{2}-\d{2}-session-/;
82
122
  if (LOOKS_LIKE_SESSION_ID.test(sessionId)) {
83
- const sessionDir = join(options.projectRoot, '.peaks', '_runtime', sessionId);
123
+ const sessionDir = runtimeRoot(options.projectRoot).join(guardRuntimeSegment(sessionId, 'session id'));
84
124
  if (!(await isDirectory(sessionDir))) {
85
125
  const canonicalSid = getSessionIdCanonical(options.projectRoot);
86
126
  const hint = canonicalSid !== null
@@ -90,7 +130,7 @@ export async function createRequestArtifact(options) {
90
130
  }
91
131
  }
92
132
  // Build numbered path under the session dir (canonical post-F3 home).
93
- const requestsDir = join(options.projectRoot, '.peaks', '_runtime', sessionId, options.role, 'requests');
133
+ const requestsDir = requestArtifactRequestsDir(options.projectRoot, sessionId, options.role);
94
134
  // Check if a file with this requestId already exists (regardless of number prefix)
95
135
  if (await isDirectory(requestsDir)) {
96
136
  const existingFiles = await listMarkdownFiles(requestsDir);
@@ -118,7 +158,7 @@ export async function createRequestArtifact(options) {
118
158
  // Slice 2026-06-29-change-id-root-removal: scopeDir is the
119
159
  // session-axis dir (`.peaks/_runtime/<sid>/`). Pre-resolved here
120
160
  // so dry-run output reports the canonical scope location.
121
- const scopeDir = join(options.projectRoot, '.peaks', '_runtime', sessionId);
161
+ const scopeDir = runtimeRoot(options.projectRoot).join(guardRuntimeSegment(sessionId, 'session id'));
122
162
  return {
123
163
  role: options.role,
124
164
  requestId: options.requestId,
@@ -135,7 +175,7 @@ export async function createRequestArtifact(options) {
135
175
  // Create QA initiated marker so rd:qa-handoff gate can verify QA was invoked.
136
176
  // The marker lives under the SESSION dir (canonical post-F3 home).
137
177
  if (options.role === 'qa') {
138
- const qaDir = join(options.projectRoot, '.peaks', '_runtime', sessionId, 'qa');
178
+ const qaDir = runtimeRoot(options.projectRoot).join(guardRuntimeSegment(sessionId, 'session id'), guardRuntimeSegment('qa', 'role'));
139
179
  const initiatedPath = join(qaDir, '.initiated');
140
180
  if (!existsSync(initiatedPath)) {
141
181
  await mkdir(qaDir, { recursive: true });
@@ -149,22 +189,17 @@ export async function createRequestArtifact(options) {
149
189
  path,
150
190
  content,
151
191
  applied: true,
152
- scopeDir: join(options.projectRoot, '.peaks', '_runtime', sessionId),
192
+ scopeDir: runtimeRoot(options.projectRoot).join(guardRuntimeSegment(sessionId, 'session id')),
153
193
  ...(options.callerId !== undefined ? { callerId: options.callerId } : {})
154
194
  };
155
195
  }
156
196
  function extractMetadata(markdown) {
157
- let state = 'unknown';
197
+ const state = readArtifactState(markdown) ?? 'unknown';
158
198
  let createdAt;
159
199
  let requestType = DEFAULT_REQUEST_TYPE;
160
200
  let sessionId;
161
201
  for (const rawLine of markdown.split(/\r?\n/)) {
162
202
  const line = rawLine.trim();
163
- const stateMatch = /^-\s*state:\s*(.+?)\s*$/.exec(line);
164
- if (stateMatch !== null && stateMatch[1] !== undefined) {
165
- state = stateMatch[1];
166
- continue;
167
- }
168
203
  const createdMatch = /^-\s*created:\s*(.+?)\s*$/.exec(line);
169
204
  if (createdMatch !== null && createdMatch[1] !== undefined) {
170
205
  createdAt = createdMatch[1];
@@ -192,31 +227,27 @@ function extractMetadata(markdown) {
192
227
  base.sessionId = sessionId;
193
228
  return base;
194
229
  }
195
- async function readSummary(projectRoot, sessionId, role, fileName) {
196
- const path = join(projectRoot, '.peaks', sessionId, role, 'requests', fileName);
230
+ async function readSummary(dir, role, fileName, sessionId) {
231
+ const path = join(dir, fileName);
197
232
  const body = await readFile(path, 'utf8');
198
233
  const { state, createdAt, requestType, sessionId: bodySessionId } = extractMetadata(body);
199
234
  // Strip numbered prefix (e.g., "001-requestId.md" -> "requestId")
200
235
  // Only strip 3-digit zero-padded prefixes (our incrementing number format)
201
236
  const requestId = fileName.replace(/^0\d{2}-/, '').replace(/\.md$/, '');
202
- // The `sessionId` parameter is the *scope* path fragment
203
- // (`_runtime/<sid>`); consumers expect the bare session id. Strip
204
- // the `_runtime/` prefix when recording the summary so downstream
205
- // calls (observability emit, prereq check, lint gate) see just the
206
- // session id. Pre-2.19.0 the field carried the scope verbatim, which
207
- // caused the observability metrics file to land at
208
- // `.peaks/_runtime/_runtime/<sid>/...` instead of the canonical
209
- // `.peaks/_runtime/<sid>/metrics/...`. `writerSessionId` falls back
210
- // to the parsed body session line (or the bare sid) — same intent.
211
- const bareSessionId = sessionId.replace(/^_runtime[\\/]/, '');
237
+ // Repair R5: this used to take the *scope* path fragment (`_runtime/<sid>`)
238
+ // and re-join it onto the project root, then strip the prefix back off to
239
+ // recover the bare session id. The round-trip was the escape's carrier — an
240
+ // unsafe id rode it into `path` unguarded. The directory now arrives already
241
+ // resolved and already guarded (`requestArtifactRequestsDir`), and the bare
242
+ // session id arrives as itself, so neither is re-derived here.
212
243
  const summary = {
213
244
  role,
214
- sessionId: bareSessionId,
245
+ sessionId,
215
246
  requestId,
216
247
  path,
217
248
  state,
218
249
  requestType,
219
- writerSessionId: bodySessionId ?? bareSessionId
250
+ writerSessionId: bodySessionId ?? sessionId
220
251
  };
221
252
  if (createdAt !== undefined) {
222
253
  summary.createdAt = createdAt;
@@ -244,26 +275,28 @@ export async function listRequestArtifacts(options) {
244
275
  // scanned. The user has forbidden the `.peaks/_runtime/<id>/` root layout —
245
276
  // the CLI guarantees no such dirs are created. See
246
277
  // `.peaks/memory/2026-06-21-peaks-request-session-id-leaks-into-change-id.md`.
278
+ // Repair R5: `scopes` holds bare session ids. It used to hold the joined
279
+ // fragment `_runtime/<sid>` so that `readSummary` could re-join it to the
280
+ // project root; the directory is built once, below, by the guard.
247
281
  const scopes = [];
248
282
  if (options.sessionId !== undefined) {
249
- scopes.push(join('_runtime', options.sessionId));
283
+ scopes.push(options.sessionId);
250
284
  }
251
285
  else {
252
- const runtimeRoot = join(peaksRoot, '_runtime');
253
- if (await isDirectory(runtimeRoot)) {
254
- for (const sid of await listDirectories(runtimeRoot)) {
255
- scopes.push(join('_runtime', sid));
256
- }
286
+ // Read-only enumeration of the root itself, so `dir()` and not `join()`.
287
+ const runtimeDir = runtimeRoot(options.projectRoot).dir();
288
+ if (await isDirectory(runtimeDir)) {
289
+ scopes.push(...(await listDirectories(runtimeDir)));
257
290
  }
258
291
  }
259
292
  const roles = options.role !== undefined ? [options.role] : Array.from(VALID_ROLES);
260
293
  const summaries = [];
261
294
  for (const scope of scopes) {
262
295
  for (const role of roles) {
263
- const dir = join(peaksRoot, scope, role, 'requests');
296
+ const dir = requestArtifactRequestsDir(options.projectRoot, scope, role);
264
297
  const fileNames = await listMarkdownFiles(dir);
265
298
  for (const fileName of fileNames) {
266
- summaries.push(await readSummary(options.projectRoot, scope, role, fileName));
299
+ summaries.push(await readSummary(dir, role, fileName, scope));
267
300
  }
268
301
  }
269
302
  }
@@ -301,24 +334,22 @@ export async function showRequestArtifact(options) {
301
334
  // `.peaks/_runtime/<sid>/<role>/requests/` legacy home is no longer
302
335
  // scanned. The user has forbidden the `.peaks/_runtime/<id>/` root layout.
303
336
  if (options.sessionId !== undefined) {
304
- const dir = join(options.projectRoot, '.peaks', '_runtime', options.sessionId, options.role, 'requests');
305
- const scope = join('_runtime', options.sessionId);
337
+ const dir = requestArtifactRequestsDir(options.projectRoot, options.sessionId, options.role);
306
338
  const found = await findFileInDir(dir);
307
339
  if (found === null) {
308
340
  return null;
309
341
  }
310
- return await readRequestArtifact(options.projectRoot, scope, options.role, found);
342
+ return await readRequestArtifact(dir, options.role, found, options.sessionId);
311
343
  }
312
- const peaksRoot = join(options.projectRoot, '.peaks');
313
- const runtimeRoot = join(peaksRoot, '_runtime');
314
- if (!(await isDirectory(runtimeRoot))) {
344
+ const runtimeDir = runtimeRoot(options.projectRoot).dir();
345
+ if (!(await isDirectory(runtimeDir))) {
315
346
  return null;
316
347
  }
317
- for (const sid of await listDirectories(runtimeRoot)) {
318
- const dir = join(runtimeRoot, sid, options.role, 'requests');
348
+ for (const sid of await listDirectories(runtimeDir)) {
349
+ const dir = requestArtifactRequestsDir(options.projectRoot, sid, options.role);
319
350
  const found = await findFileInDir(dir);
320
351
  if (found !== null) {
321
- return await readRequestArtifact(options.projectRoot, join('_runtime', sid), options.role, found);
352
+ return await readRequestArtifact(dir, options.role, found, sid);
322
353
  }
323
354
  }
324
355
  return null;
@@ -326,8 +357,8 @@ export async function showRequestArtifact(options) {
326
357
  /** Read the summary + content for a found request file; treat a read
327
358
  * error on the content as "not found" so the caller can fall through
328
359
  * to the next candidate (the on-disk file may be partially written). */
329
- async function readRequestArtifact(projectRoot, scope, role, found) {
330
- const summary = await readSummary(projectRoot, scope, role, found.fileName);
360
+ async function readRequestArtifact(dir, role, found, sessionId) {
361
+ const summary = await readSummary(dir, role, found.fileName, sessionId);
331
362
  try {
332
363
  const content = await readFile(found.path, 'utf8');
333
364
  return { ...summary, content };
@@ -343,7 +374,7 @@ async function readRequestArtifact(projectRoot, scope, role, found) {
343
374
  // the sibling `request-artifact-state-helpers.ts` module — see
344
375
  // v2.18.3 file-split for the rationale. Function signatures and
345
376
  // behaviour are unchanged (verbatim move).
346
- import { ALLOWED_STATES_PER_ROLE, FileSizeViolationError, LintGateError, PrerequisitesNotSatisfiedError, TypeSanityViolationError, updateStatusBlock, } from './request-artifact-state-helpers.js';
377
+ import { ALLOWED_STATES_PER_ROLE, FileSizeViolationError, LintGateError, PrerequisitesNotSatisfiedError, readArtifactState, TypeSanityViolationError, updateStatusBlock, } from './request-artifact-state-helpers.js';
347
378
  export { allowedStatesForRole, FileSizeViolationError, LintGateError, PrerequisitesNotSatisfiedError, TypeSanityViolationError, updateStatusBlock } from './request-artifact-state-helpers.js';
348
379
  export async function transitionRequestArtifact(options) {
349
380
  if (!VALID_ROLES.has(options.role)) {
@@ -45,6 +45,63 @@ export declare class FileSizeViolationError extends Error {
45
45
  lines: number;
46
46
  }>, threshold: number);
47
47
  }
48
+ export interface ArtifactStateLocation {
49
+ /** Index of the authoritative `- state:` line, or -1 when the document has none. */
50
+ stateLineIndex: number;
51
+ /** The authoritative state, or null when `stateLineIndex` is -1. */
52
+ state: string | null;
53
+ }
54
+ /**
55
+ * The ONE rule for "which `state:` line is this artifact's state": the LAST
56
+ * one. A request artifact is an append-only log — each QA round appends a
57
+ * section ending in its own `## Status`, and `request transition` rewrites the
58
+ * newest state line in place — so the last line is the current round and the
59
+ * earlier ones are history.
60
+ *
61
+ * Every reader (`verify-pipeline`, `request show`, the resume detector) and the
62
+ * writer (`updateStatusBlock`) MUST go through this. The 2026-09-14 defect was
63
+ * three readers disagreeing about one file: `verify-pipeline` and the resume
64
+ * detector took the first match, `request show` took the last, so an artifact
65
+ * appended to more than once read as its first round to the checker and the
66
+ * resume detector while reading as its last round to the viewer and the writer.
67
+ *
68
+ * Scoping the search to the last `## Status` block was evaluated as an
69
+ * alternative and rejected — as a SECOND locator, not as a broken rule. It is
70
+ * well defined on the specimen that motivated this slice (four blocks) and
71
+ * returns `verdict-issued`, the right answer; and a trailing appended
72
+ * `- state:` line does not defeat it the way it defeats this rule — on that
73
+ * input the two rules disagree, and the one the block rule then disagrees with
74
+ * is the writer. `updateStatusBlock` rewrites the last `- state:` line wherever
75
+ * it sits and never moves it into the newest block, so a block-scoped reader
76
+ * parts company with the writer as soon as those two positions differ: the
77
+ * writer writes the appended line while the block reader keeps reporting the
78
+ * block's own line. That is this slice's reader-vs-writer divergence on a new
79
+ * axis. Sharing the writer's locator makes the agreement structural, not
80
+ * accidental.
81
+ *
82
+ * Known boundary of this rule, pinned in
83
+ * `request-artifact-state-authority.test.ts` rather than hidden: a process that
84
+ * appends a bare `- state:` line takes over the field — consistent with the
85
+ * writer being its only sanctioned producer.
86
+ *
87
+ * Two narrowings keep that boundary to lines the writer could have produced.
88
+ * A line inside a fenced code region is skipped, and the line must start at
89
+ * column 0. Neither is defensive decoration: a document that *describes* the
90
+ * state machine quotes `- state: qa-block` inside a fence, and this job's own
91
+ * `qa/requests/*.md` artifacts do exactly that — under the unfenced rule the
92
+ * quoted example was an input to the transition checker, and it survived only
93
+ * because the quoted copies happened not to be last. Both narrowings were
94
+ * measured against every `*.md` under `.peaks/` (833 files) and change no
95
+ * artifact's answer, and the writer already satisfies both by construction
96
+ * (`updateStatusBlock` writes `- state: <state>` at column 0), so reader and
97
+ * writer stay the same locator.
98
+ *
99
+ * Fuller record: the slice-3 section of this session's `rd/tech-doc.md` and
100
+ * the repair-round section of `rd/repair2-meta-integrity-fixes.md`.
101
+ */
102
+ export declare function locateArtifactState(lines: ReadonlyArray<string>): ArtifactStateLocation;
103
+ /** `locateArtifactState` over a whole document. Null when there is no `- state:` line. */
104
+ export declare function readArtifactState(markdown: string): string | null;
48
105
  export declare function updateStatusBlock(markdown: string, newState: RequestArtifactState, timestamp: string, reason?: string): {
49
106
  updated: string;
50
107
  previousState: string;