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.
- package/CHANGELOG.md +44 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/agents/karpathy-reviewer.md +11 -10
- package/dist/cli/cli-helpers.d.ts +34 -0
- package/dist/cli/cli-helpers.js +57 -0
- package/dist/cli/commands/code-job-shape-commands.js +8 -0
- package/dist/cli/commands/code-runtime-commands.js +48 -8
- package/dist/cli/commands/compact-command.js +110 -0
- package/dist/cli/commands/config-commands.js +15 -9
- package/dist/cli/commands/dashboard-long-run.js +6 -0
- package/dist/cli/commands/dispatch-commands.js +11 -1
- package/dist/cli/commands/doctor/invoke-from-code.js +6 -0
- package/dist/cli/commands/feedback-commands.d.ts +11 -7
- package/dist/cli/commands/feedback-commands.js +49 -17
- package/dist/cli/commands/final-review-commands.js +12 -0
- package/dist/cli/commands/hooks-commands.js +4 -4
- package/dist/cli/commands/job-commands.js +8 -0
- package/dist/cli/commands/loop-eval-commands.js +31 -0
- package/dist/cli/commands/perf-audit-commands.js +2 -0
- package/dist/cli/commands/playwright-commands.js +12 -0
- package/dist/cli/commands/prd-commands.js +1 -1
- package/dist/cli/commands/qa-commands.js +22 -0
- package/dist/cli/commands/request-commands.js +8 -0
- package/dist/cli/commands/scan-commands.js +1 -1
- package/dist/cli/commands/security-audit-commands.js +2 -0
- package/dist/cli/commands/slice-integrate-commands.js +22 -0
- package/dist/cli/commands/statusline-commands.js +44 -4
- package/dist/cli/commands/sub-agent/detached.d.ts +14 -1
- package/dist/cli/commands/sub-agent/detached.js +47 -22
- package/dist/cli/commands/sub-agent-shutdown-commands.js +11 -0
- package/dist/cli/commands/verdict-aggregate-command.js +95 -13
- package/dist/cli/commands/workflow-commands.js +1 -1
- package/dist/cli/index.js +5 -45
- package/dist/services/artifacts/artifact-prerequisites.d.ts +38 -7
- package/dist/services/artifacts/artifact-prerequisites.js +140 -65
- package/dist/services/artifacts/request-artifact-service.d.ts +8 -0
- package/dist/services/artifacts/request-artifact-service.js +77 -46
- package/dist/services/artifacts/request-artifact-state-helpers.d.ts +57 -0
- package/dist/services/artifacts/request-artifact-state-helpers.js +91 -10
- package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
- package/dist/services/audit-independent/perf-audit-service.d.ts +9 -0
- package/dist/services/audit-independent/perf-audit-service.js +27 -5
- package/dist/services/audit-independent/security-audit-service.d.ts +12 -2
- package/dist/services/audit-independent/security-audit-service.js +28 -6
- package/dist/services/code/auto-compact-lifecycle.d.ts +194 -0
- package/dist/services/code/auto-compact-lifecycle.js +229 -11
- package/dist/services/code/auto-compact-orchestrator.js +118 -7
- package/dist/services/code/compact-event-settle.d.ts +134 -0
- package/dist/services/code/compact-event-settle.js +240 -0
- package/dist/services/compact-history/compact-history-service.d.ts +14 -0
- package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
- package/dist/services/config/config-restore.d.ts +12 -1
- package/dist/services/config/config-restore.js +35 -4
- package/dist/services/config/config-rollback.js +6 -1
- package/dist/services/context/auto-compact-types.d.ts +20 -2
- package/dist/services/context/harness-context-witness.d.ts +310 -0
- package/dist/services/context/harness-context-witness.js +606 -0
- package/dist/services/evidence/evidence-generator.js +86 -49
- package/dist/services/feedback/feedback-promotion-service.d.ts +137 -14
- package/dist/services/feedback/feedback-promotion-service.js +341 -20
- package/dist/services/feedback/promotion-artifact-evidence.d.ts +69 -0
- package/dist/services/feedback/promotion-artifact-evidence.js +332 -0
- package/dist/services/final-review/final-review-service.d.ts +9 -0
- package/dist/services/final-review/final-review-service.js +36 -12
- package/dist/services/ide/ide-registry.d.ts +19 -0
- package/dist/services/ide/ide-registry.js +21 -0
- package/dist/services/job/job-progress-store.js +18 -3
- package/dist/services/job/job-state-store.js +7 -0
- package/dist/services/observability/jsonl-store.d.ts +19 -0
- package/dist/services/observability/jsonl-store.js +27 -2
- package/dist/services/observability/observability-service.d.ts +10 -3
- package/dist/services/observability/observability-service.js +16 -3
- package/dist/services/polyrepo/polyrepo-dispatcher.js +11 -0
- package/dist/services/prd/handoff-auto-regen.js +31 -27
- package/dist/services/prd/handoff-frontmatter.d.ts +44 -0
- package/dist/services/prd/handoff-frontmatter.js +75 -0
- package/dist/services/prd/handoff-service.d.ts +41 -2
- package/dist/services/prd/handoff-service.js +124 -8
- package/dist/services/prd/handoff-types.d.ts +3 -2
- package/dist/services/prd/handoff-types.js +3 -2
- package/dist/services/qa/qa-business-review-state.js +23 -0
- package/dist/services/sc/sc-service.d.ts +8 -0
- package/dist/services/sc/sc-service.js +8 -1
- package/dist/services/scan/karpathy-service.js +2 -2
- package/dist/services/session/getSessionDir.d.ts +33 -0
- package/dist/services/session/getSessionDir.js +60 -0
- package/dist/services/session/session-checkpoint-service.js +8 -0
- package/dist/services/skill/resume-detector.js +29 -11
- package/dist/services/skills/hooks-codegate-superpowers.d.ts +6 -0
- package/dist/services/skills/hooks-codegate-superpowers.js +61 -2
- package/dist/services/skills/hooks-settings-service.js +14 -4
- package/dist/services/skills/session-start-hook-constants.d.ts +45 -0
- package/dist/services/skills/session-start-hook-constants.js +45 -0
- package/dist/services/skills/skill-statusline-service.d.ts +14 -0
- package/dist/services/slice/slice-check-service.js +29 -11
- package/dist/services/slice/slice-review-state.js +23 -0
- package/dist/services/workflow/pipeline-verify-gate-support.d.ts +47 -10
- package/dist/services/workflow/pipeline-verify-gate-support.js +221 -103
- package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
- package/dist/services/workflow/pipeline-verify-service.js +47 -33
- package/dist/services/workflow/pipeline-verify-types.d.ts +15 -6
- package/dist/services/workspace/claude-settings-template.d.ts +56 -8
- package/dist/services/workspace/claude-settings-template.js +98 -20
- package/dist/services/workspace/workspace-claude-settings-materializer.js +78 -7
- package/dist/shared/runtime-root.d.ts +73 -0
- package/dist/shared/runtime-root.js +77 -0
- package/package.json +6 -6
- package/skills/bee/peaks-prd/SKILL.md +7 -5
- package/skills/bee/peaks-qa/SKILL.md +5 -5
- package/skills/bee/peaks-qa/references/qa-runbook.md +2 -2
- package/skills/bee/peaks-qa/references/qa-transition-gates.md +7 -7
- package/skills/bee/peaks-rd/SKILL.md +8 -6
- package/skills/bee/peaks-rd/references/artifact-per-request.md +2 -2
- package/skills/bee/peaks-rd/references/parallel-review-fanout.md +7 -5
- package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +13 -13
- package/skills/bee/peaks-rd/references/rd-runbook.md +9 -5
- package/skills/bee/peaks-rd/references/rd-transition-gates.md +9 -7
- package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +6 -6
- package/skills/peaks-code/SKILL.md +1 -1
- package/skills/peaks-code/references/a2a-artifact-mapping.md +3 -3
- package/skills/peaks-code/references/local-artifact-workspace.md +1 -1
- package/skills/peaks-code/references/resume-detection.md +13 -7
- package/skills/peaks-code/references/runbook.md +3 -2
- package/skills/peaks-code/references/session-overload-signal-index.md +2 -1
- 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
|
|
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
|
|
56
|
-
// `audit/perf
|
|
57
|
-
// `
|
|
58
|
-
//
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
121
|
-
|
|
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
|
|
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
|
|
475
|
+
if (hasBodyChecks(prerequisite)) {
|
|
373
476
|
const body = await readFile(absolute, 'utf8');
|
|
374
|
-
const
|
|
375
|
-
|
|
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
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
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:
|
|
475
|
-
// root
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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(
|
|
196
|
-
const path = join(
|
|
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
|
-
//
|
|
203
|
-
//
|
|
204
|
-
// the
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
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
|
|
245
|
+
sessionId,
|
|
215
246
|
requestId,
|
|
216
247
|
path,
|
|
217
248
|
state,
|
|
218
249
|
requestType,
|
|
219
|
-
writerSessionId: bodySessionId ??
|
|
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(
|
|
283
|
+
scopes.push(options.sessionId);
|
|
250
284
|
}
|
|
251
285
|
else {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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.
|
|
342
|
+
return await readRequestArtifact(dir, options.role, found, options.sessionId);
|
|
311
343
|
}
|
|
312
|
-
const
|
|
313
|
-
|
|
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(
|
|
318
|
-
const dir =
|
|
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(
|
|
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(
|
|
330
|
-
const summary = await readSummary(
|
|
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;
|