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
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* result (fire-and-forget by convention).
|
|
20
20
|
*/
|
|
21
21
|
import { z } from 'zod';
|
|
22
|
-
import { appendMetricLine,
|
|
22
|
+
import { appendMetricLine, pruneMetricsFiles, readMetricLines, tryMetricsFilePath } from './jsonl-store.js';
|
|
23
23
|
export const OBSERVABILITY_SCHEMA_VERSION = 1;
|
|
24
24
|
export const OBSERVABILITY_CATEGORIES = [
|
|
25
25
|
'slice-transition',
|
|
@@ -74,7 +74,17 @@ export const ObservabilityEventSchema = z.object({
|
|
|
74
74
|
* session count is below `MAX_METRICS_FILES`.
|
|
75
75
|
*/
|
|
76
76
|
export function emitObservabilityEvent(event, options) {
|
|
77
|
-
|
|
77
|
+
// The session id is resolved through the axis's TOTAL entry, before anything
|
|
78
|
+
// else. The contract two doc comments above is that this function never
|
|
79
|
+
// throws; the previous first line called the axis's THROWING entry, so an
|
|
80
|
+
// unsafe session id made it throw — measured (repair R4) as
|
|
81
|
+
// `Invalid session id: ../../../../RD-R4-PWNED`, against a legal control
|
|
82
|
+
// that returned `written: true`. A guard refusal is a failure like any
|
|
83
|
+
// other here: it becomes a reason, not an exception.
|
|
84
|
+
const path = tryMetricsFilePath(options.projectRoot, event.sessionId);
|
|
85
|
+
if (path === null) {
|
|
86
|
+
return { written: false, path: '', reason: 'invalid-session-id' };
|
|
87
|
+
}
|
|
78
88
|
const validation = ObservabilityEventSchema.safeParse(event);
|
|
79
89
|
if (!validation.success) {
|
|
80
90
|
return { written: false, path, reason: 'invalid-schema' };
|
|
@@ -93,7 +103,10 @@ export function emitObservabilityEvent(event, options) {
|
|
|
93
103
|
* lines and any record whose `schemaVersion` does not match the
|
|
94
104
|
* current `OBSERVABILITY_SCHEMA_VERSION` (forward-compat per Q3).
|
|
95
105
|
*
|
|
96
|
-
* Returns [] when the session has no metrics file yet
|
|
106
|
+
* Returns [] when the session has no metrics file yet, and [] when the
|
|
107
|
+
* session id names no session directory — both are "no events are
|
|
108
|
+
* readable here", and this reader does not throw (repair R6: it used to,
|
|
109
|
+
* via `readMetricLines` → `metricsFilePath`).
|
|
97
110
|
*/
|
|
98
111
|
export function readObservabilityEvents(projectRoot, sessionId) {
|
|
99
112
|
const lines = readMetricLines(projectRoot, sessionId);
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
27
|
import { dirname, join } from 'node:path';
|
|
28
|
+
import { isUnsafePathInput } from '../../shared/path-safety.js';
|
|
28
29
|
/** Compute the child-side path where the artifact should be mirrored.
|
|
29
30
|
* Mirrors the parent shape exactly: `.peaks/_runtime/<sid>/<role>/`. */
|
|
30
31
|
function childArtifactPath(childRoot, sid, role, sourcePath) {
|
|
@@ -37,6 +38,16 @@ function ensureDirFor(file) {
|
|
|
37
38
|
export function dispatchArtifact(opts) {
|
|
38
39
|
const warnings = [];
|
|
39
40
|
const perChild = [];
|
|
41
|
+
// `sid` becomes a path segment in every child (`childArtifactPath`). It has no
|
|
42
|
+
// pinned format (`--sid` is a free-form override), so it gets the repo's
|
|
43
|
+
// segment check — the same `isUnsafePathInput` `peaks evidence generate` and
|
|
44
|
+
// `peaks verdict aggregate` apply to their session id. Guard before the first
|
|
45
|
+
// mkdir so a rejected dispatch leaves nothing behind. Measured before this
|
|
46
|
+
// line existed: `--sid '../../../../../../PWNED-SID-ESCAPE'` wrote
|
|
47
|
+
// `<parent-of-fixture>/PWNED-SID-ESCAPE/prd/src.md`, above every project root.
|
|
48
|
+
if (isUnsafePathInput(opts.sid)) {
|
|
49
|
+
throw new Error(`Invalid session id: ${opts.sid} (must be a single path segment)`);
|
|
50
|
+
}
|
|
40
51
|
// Validate targets against the manifest.
|
|
41
52
|
const knownIds = new Set(opts.manifest.children.map((c) => c.id));
|
|
42
53
|
const validTargets = [];
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* v2.13.2 AC-4 — prd/handoff
|
|
2
|
+
* v2.13.2 AC-4 — prd/handoff-<rid>.md auto-regen on prd:handed-off.
|
|
3
3
|
*
|
|
4
4
|
* When `peaks request transition --role prd --state handed-off` succeeds
|
|
5
|
-
* and
|
|
5
|
+
* and this slice's capsule is missing, this helper writes a sha256-locked
|
|
6
6
|
* handoff (schemaVersion: 2) using the request artifact body as the
|
|
7
|
-
* handoff body. If the
|
|
7
|
+
* handoff body. If the capsule already exists, it's NOT overwritten —
|
|
8
8
|
* the existing handoff is canonical (it may carry a richer body that
|
|
9
9
|
* peaks-prd produced in an earlier session).
|
|
10
10
|
*
|
|
11
|
+
* Slice `2026-09-14-prd-capsule-rid-scoping`: the path carries the rid, so
|
|
12
|
+
* "already exists" is now asked per SLICE. The pre-rid-scoping bare name is
|
|
13
|
+
* deliberately NOT consulted — writing there would recreate the
|
|
14
|
+
* one-slot-per-session collision for the next slice in the session.
|
|
15
|
+
*
|
|
11
16
|
* Karpathy §3 (Surgical Changes): this file only owns the auto-regen
|
|
12
17
|
* path. The other 11 transitions are untouched.
|
|
13
18
|
*/
|
|
@@ -15,7 +20,8 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
|
15
20
|
import { dirname, join } from 'node:path';
|
|
16
21
|
import { createHash } from 'node:crypto';
|
|
17
22
|
import { showRequestArtifact } from '../artifacts/request-artifact-service.js';
|
|
18
|
-
import {
|
|
23
|
+
import { serializeHandoffFrontmatter } from './handoff-frontmatter.js';
|
|
24
|
+
import { handoffRelativePath, sha256OfBody } from './handoff-service.js';
|
|
19
25
|
import { normalizePath } from '../../shared/path-utils.js';
|
|
20
26
|
/**
|
|
21
27
|
* Auto-regen the prd/handoff.md under `.peaks/_runtime/<sid>/prd/`.
|
|
@@ -25,7 +31,7 @@ export async function autoRegenPrdHandoff(opts) {
|
|
|
25
31
|
if (opts.role !== 'prd') {
|
|
26
32
|
return { status: 'failed', reason: 'role must be prd' };
|
|
27
33
|
}
|
|
28
|
-
const handoffPath = join(opts.projectRoot,
|
|
34
|
+
const handoffPath = join(opts.projectRoot, handoffRelativePath(opts.sessionId, opts.requestId));
|
|
29
35
|
if (existsSync(handoffPath)) {
|
|
30
36
|
return { status: 'skipped-exists', path: handoffPath };
|
|
31
37
|
}
|
|
@@ -41,28 +47,26 @@ export async function autoRegenPrdHandoff(opts) {
|
|
|
41
47
|
const body = artifact.content;
|
|
42
48
|
const sha256 = sha256OfBody(body);
|
|
43
49
|
// v2.13.3 AC-4 — align with `AUDIT_REQUIRES_HANDOFF` prereq which
|
|
44
|
-
// pins `mustContain: ['schemaVersion: 2', 'sha256:']`.
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
].join('\n');
|
|
65
|
-
const content = `${frontmatter}${body}`;
|
|
50
|
+
// pins `mustContain: ['schemaVersion: 2', 'sha256:']`. Primary field is
|
|
51
|
+
// `sha256`; `handoffHash` is kept as a literal alias for the readers that
|
|
52
|
+
// still use the old key.
|
|
53
|
+
//
|
|
54
|
+
// Slice `2026-09-14-handoff-writer-gate-divergence`: this block used to be
|
|
55
|
+
// hand-rolled here while `handoff-service.serializeHandoff` rendered the
|
|
56
|
+
// SAME contract a second, incompatible way. Both now go through
|
|
57
|
+
// `serializeHandoffFrontmatter`, so the two producers cannot drift again.
|
|
58
|
+
const frontmatter = {
|
|
59
|
+
requestId: opts.requestId,
|
|
60
|
+
sessionId: opts.sessionId,
|
|
61
|
+
schemaVersion: '2',
|
|
62
|
+
handoffHash: sha256,
|
|
63
|
+
writtenAt: new Date().toISOString(),
|
|
64
|
+
goals: [],
|
|
65
|
+
acceptanceCriteria: [],
|
|
66
|
+
preservedBehavior: [],
|
|
67
|
+
handoffPath: normalizePath(handoffPath.replace(opts.projectRoot, '')).replace(/^\//, '')
|
|
68
|
+
};
|
|
69
|
+
const content = `${serializeHandoffFrontmatter(frontmatter)}${body}`;
|
|
66
70
|
mkdirSync(dirname(handoffPath), { recursive: true });
|
|
67
71
|
writeFileSync(handoffPath, content, 'utf8');
|
|
68
72
|
const recomputed = createHash('sha256').update(body, 'utf8').digest('hex');
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE canonical serialization of `prd/handoff.md` frontmatter.
|
|
3
|
+
*
|
|
4
|
+
* Why this module exists (slice `2026-09-14-handoff-writer-gate-divergence`):
|
|
5
|
+
* the handoff capsule is written by two producers and read by four
|
|
6
|
+
* consumers, and they did not agree on the bytes:
|
|
7
|
+
*
|
|
8
|
+
* | consumer / producer | requirement |
|
|
9
|
+
* |---|---|
|
|
10
|
+
* | `AUDIT_REQUIRES_HANDOFF` (`artifact-prerequisites.ts`) | SUBSTRING `schemaVersion: 2` (unquoted) + `sha256:` |
|
|
11
|
+
* | `audit-independent/{security,perf}-audit-service.ts` | anchored `^schemaVersion:\s*(\d+)\s*$` and `^sha256:\s*([a-f0-9]{64})\s*$` |
|
|
12
|
+
* | `handoff-service.readHandoff` | YAML string `handoffHash` |
|
|
13
|
+
* | `handoff-service.verifyHandoff` | `handoffHash` === sha256(body), bare hex |
|
|
14
|
+
*
|
|
15
|
+
* `handoff-service.serializeHandoff` fed the frontmatter through
|
|
16
|
+
* `yaml.stringify`, which emits `schemaVersion: "2"` and a bare
|
|
17
|
+
* `handoffHash:` — failing the gate AND both loaders. `handoff-auto-regen.ts`
|
|
18
|
+
* hand-rolled a second serialization that happened to satisfy all four. Two
|
|
19
|
+
* producers, two facts about the same contract, one of them wrong: that is
|
|
20
|
+
* the defect, and copying the right shape into a third place would keep it.
|
|
21
|
+
*
|
|
22
|
+
* So both producers call THIS function. `sha256` is emitted only here, and
|
|
23
|
+
* it is emitted plain, because the two audit loaders anchor a regex on it.
|
|
24
|
+
*
|
|
25
|
+
* Scalar-quoting rule — one rule, one documented exception set:
|
|
26
|
+
* - every ordinary string is emitted through `yamlScalar` (a JSON
|
|
27
|
+
* double-quoted scalar, which is valid YAML 1.2 and round-trips
|
|
28
|
+
* backslashes correctly on Windows paths);
|
|
29
|
+
* - `schemaVersion` and `sha256` are the exception: emitted PLAIN, because
|
|
30
|
+
* they are the two scalars the gate and the loaders match textually and a
|
|
31
|
+
* quoted scalar is invisible to all three. Both values are structurally
|
|
32
|
+
* constrained (a version digit and 64 hex chars), so plain is unambiguous.
|
|
33
|
+
*
|
|
34
|
+
* `handoffHash` is quoted even though it is also a 64-hex value: the reader
|
|
35
|
+
* parses the block through YAML and requires a STRING, and a bare all-digit
|
|
36
|
+
* sha256 would parse as a YAML number and be refused by the shape check.
|
|
37
|
+
*/
|
|
38
|
+
import type { HandoffFrontmatter } from './handoff-types.js';
|
|
39
|
+
/**
|
|
40
|
+
* Serialize `frontmatter` into the fenced block, terminated by the closing
|
|
41
|
+
* `---` and a trailing newline. Callers append the body verbatim, which keeps
|
|
42
|
+
* the sha256 of the body independent of how the frontmatter renders.
|
|
43
|
+
*/
|
|
44
|
+
export declare function serializeHandoffFrontmatter(frontmatter: HandoffFrontmatter): string;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE canonical serialization of `prd/handoff.md` frontmatter.
|
|
3
|
+
*
|
|
4
|
+
* Why this module exists (slice `2026-09-14-handoff-writer-gate-divergence`):
|
|
5
|
+
* the handoff capsule is written by two producers and read by four
|
|
6
|
+
* consumers, and they did not agree on the bytes:
|
|
7
|
+
*
|
|
8
|
+
* | consumer / producer | requirement |
|
|
9
|
+
* |---|---|
|
|
10
|
+
* | `AUDIT_REQUIRES_HANDOFF` (`artifact-prerequisites.ts`) | SUBSTRING `schemaVersion: 2` (unquoted) + `sha256:` |
|
|
11
|
+
* | `audit-independent/{security,perf}-audit-service.ts` | anchored `^schemaVersion:\s*(\d+)\s*$` and `^sha256:\s*([a-f0-9]{64})\s*$` |
|
|
12
|
+
* | `handoff-service.readHandoff` | YAML string `handoffHash` |
|
|
13
|
+
* | `handoff-service.verifyHandoff` | `handoffHash` === sha256(body), bare hex |
|
|
14
|
+
*
|
|
15
|
+
* `handoff-service.serializeHandoff` fed the frontmatter through
|
|
16
|
+
* `yaml.stringify`, which emits `schemaVersion: "2"` and a bare
|
|
17
|
+
* `handoffHash:` — failing the gate AND both loaders. `handoff-auto-regen.ts`
|
|
18
|
+
* hand-rolled a second serialization that happened to satisfy all four. Two
|
|
19
|
+
* producers, two facts about the same contract, one of them wrong: that is
|
|
20
|
+
* the defect, and copying the right shape into a third place would keep it.
|
|
21
|
+
*
|
|
22
|
+
* So both producers call THIS function. `sha256` is emitted only here, and
|
|
23
|
+
* it is emitted plain, because the two audit loaders anchor a regex on it.
|
|
24
|
+
*
|
|
25
|
+
* Scalar-quoting rule — one rule, one documented exception set:
|
|
26
|
+
* - every ordinary string is emitted through `yamlScalar` (a JSON
|
|
27
|
+
* double-quoted scalar, which is valid YAML 1.2 and round-trips
|
|
28
|
+
* backslashes correctly on Windows paths);
|
|
29
|
+
* - `schemaVersion` and `sha256` are the exception: emitted PLAIN, because
|
|
30
|
+
* they are the two scalars the gate and the loaders match textually and a
|
|
31
|
+
* quoted scalar is invisible to all three. Both values are structurally
|
|
32
|
+
* constrained (a version digit and 64 hex chars), so plain is unambiguous.
|
|
33
|
+
*
|
|
34
|
+
* `handoffHash` is quoted even though it is also a 64-hex value: the reader
|
|
35
|
+
* parses the block through YAML and requires a STRING, and a bare all-digit
|
|
36
|
+
* sha256 would parse as a YAML number and be refused by the shape check.
|
|
37
|
+
*/
|
|
38
|
+
/** Render a string as a YAML double-quoted scalar. JSON string escapes are a
|
|
39
|
+
* subset of YAML 1.2's double-quoted escapes, so this is valid YAML and
|
|
40
|
+
* handles `\` (Windows paths), quotes, colons and newlines in one step. */
|
|
41
|
+
function yamlScalar(value) {
|
|
42
|
+
return JSON.stringify(value);
|
|
43
|
+
}
|
|
44
|
+
/** Render `key: []` or a YAML block sequence of quoted scalars. */
|
|
45
|
+
function blockSequence(key, values) {
|
|
46
|
+
if (values.length === 0)
|
|
47
|
+
return [`${key}: []`];
|
|
48
|
+
return [`${key}:`, ...values.map((value) => ` - ${yamlScalar(value)}`)];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Serialize `frontmatter` into the fenced block, terminated by the closing
|
|
52
|
+
* `---` and a trailing newline. Callers append the body verbatim, which keeps
|
|
53
|
+
* the sha256 of the body independent of how the frontmatter renders.
|
|
54
|
+
*/
|
|
55
|
+
export function serializeHandoffFrontmatter(frontmatter) {
|
|
56
|
+
const lines = [
|
|
57
|
+
'---',
|
|
58
|
+
`requestId: ${yamlScalar(frontmatter.requestId)}`,
|
|
59
|
+
`sessionId: ${yamlScalar(frontmatter.sessionId)}`,
|
|
60
|
+
// PLAIN by construction (`HandoffSchemaVersion` is the literal '2').
|
|
61
|
+
`schemaVersion: ${frontmatter.schemaVersion}`,
|
|
62
|
+
// PLAIN: this is the field both audit loaders read, via an anchored
|
|
63
|
+
// regex that a quoted scalar would not match.
|
|
64
|
+
`sha256: ${frontmatter.handoffHash}`,
|
|
65
|
+
// Quoted: `readHandoff` requires a YAML string here.
|
|
66
|
+
`handoffHash: ${yamlScalar(frontmatter.handoffHash)}`,
|
|
67
|
+
`writtenAt: ${yamlScalar(frontmatter.writtenAt)}`,
|
|
68
|
+
...blockSequence('goals', frontmatter.goals),
|
|
69
|
+
...blockSequence('acceptanceCriteria', frontmatter.acceptanceCriteria),
|
|
70
|
+
...blockSequence('preservedBehavior', frontmatter.preservedBehavior),
|
|
71
|
+
`handoffPath: ${yamlScalar(frontmatter.handoffPath)}`,
|
|
72
|
+
'---',
|
|
73
|
+
];
|
|
74
|
+
return `${lines.join('\n')}\n`;
|
|
75
|
+
}
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* `v2-11-rm-rd-techdoc-immutable-handoff`).
|
|
4
4
|
*
|
|
5
5
|
* Owns the immutable handoff at
|
|
6
|
-
* `.peaks/_runtime/<sessionId>/prd/handoff
|
|
6
|
+
* `.peaks/_runtime/<sessionId>/prd/handoff-<rid>.md` (one capsule per slice;
|
|
7
|
+
* the pre-rid-scoping `.peaks/_runtime/<sessionId>/prd/handoff.md` stays
|
|
8
|
+
* readable through `resolveHandoffPath`):
|
|
7
9
|
*
|
|
8
10
|
* - `initHandoff` — pure; computes sha256 of the body and returns
|
|
9
11
|
* a Handoff whose frontmatter `handoffHash` matches.
|
|
@@ -23,6 +25,43 @@
|
|
|
23
25
|
import type { Handoff, HandoffProbe } from './handoff-types.js';
|
|
24
26
|
/** Compute the lowercase hex sha256 of a UTF-8 string. */
|
|
25
27
|
export declare function sha256OfBody(body: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* The canonical capsule path for ONE SLICE, relative to the project root.
|
|
30
|
+
*
|
|
31
|
+
* Slice `2026-09-14-prd-capsule-rid-scoping`: the capsule used to be one slot
|
|
32
|
+
* per SESSION (`prd/handoff.md`), so the second slice's handoff silently
|
|
33
|
+
* overwrote the first slice's — and `AUDIT_REQUIRES_HANDOFF` stayed green
|
|
34
|
+
* because it never checked WHOSE rid the file named. Measured on
|
|
35
|
+
* `2026-09-13-session-21878f`: a four-slice job passed that prerequisite on a
|
|
36
|
+
* capsule left by a different line of work.
|
|
37
|
+
*
|
|
38
|
+
* This is the WRITE target, so it always carries the rid — a consumer-side
|
|
39
|
+
* fallback here would leave a rid-scoped requirement with a bare-name writer,
|
|
40
|
+
* which is the defect shape this slice exists to remove. Readers that must
|
|
41
|
+
* tolerate pre-rid-scoping sessions call `resolveHandoffPath` instead.
|
|
42
|
+
*/
|
|
43
|
+
export declare function handoffRelativePath(sessionId: string, requestId: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* The capsule a CONSUMER should read for (session, requestId): the rid-scoped
|
|
46
|
+
* path when it is on disk, else the pre-rid-scoping bare name. Returns null
|
|
47
|
+
* when neither exists.
|
|
48
|
+
*
|
|
49
|
+
* Three sessions on disk still hold only the bare file
|
|
50
|
+
* (`2026-09-06-session-a87ca4`, `2026-09-12-session-e37ef0`,
|
|
51
|
+
* `2026-09-13-session-21878f`), so the legacy tier has to keep resolving for
|
|
52
|
+
* the gate and for every reader below.
|
|
53
|
+
*
|
|
54
|
+
* `requestId` is optional because the detect-only audit surface reaches its
|
|
55
|
+
* service without one. Such a caller can name only the bare path: a session
|
|
56
|
+
* holding nothing but rid-scoped capsules reports missing rather than picking
|
|
57
|
+
* among its siblings' capsules, which would re-open the cross-slice mix-up
|
|
58
|
+
* this scoping exists to close (fail closed, not "some capsule is there").
|
|
59
|
+
*/
|
|
60
|
+
export declare function resolveHandoffPath(opts: {
|
|
61
|
+
projectRoot: string;
|
|
62
|
+
sessionId: string;
|
|
63
|
+
requestId?: string;
|
|
64
|
+
}): string | null;
|
|
26
65
|
/** Pure: produce a Handoff with the frontmatter populated. Hash is
|
|
27
66
|
* computed here; callers MUST NOT pre-populate `handoffHash`. */
|
|
28
67
|
export declare function initHandoff(opts: {
|
|
@@ -33,7 +72,7 @@ export declare function initHandoff(opts: {
|
|
|
33
72
|
goals: readonly string[];
|
|
34
73
|
acceptanceCriteria: readonly string[];
|
|
35
74
|
preservedBehavior: readonly string[];
|
|
36
|
-
/** Override path; defaults to `.peaks/_runtime/<sid>/prd/handoff
|
|
75
|
+
/** Override path; defaults to `.peaks/_runtime/<sid>/prd/handoff-<rid>.md`. */
|
|
37
76
|
handoffPath?: string;
|
|
38
77
|
}): Handoff;
|
|
39
78
|
/** Write a Handoff to disk. `projectRoot` is the absolute project
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* `v2-11-rm-rd-techdoc-immutable-handoff`).
|
|
4
4
|
*
|
|
5
5
|
* Owns the immutable handoff at
|
|
6
|
-
* `.peaks/_runtime/<sessionId>/prd/handoff
|
|
6
|
+
* `.peaks/_runtime/<sessionId>/prd/handoff-<rid>.md` (one capsule per slice;
|
|
7
|
+
* the pre-rid-scoping `.peaks/_runtime/<sessionId>/prd/handoff.md` stays
|
|
8
|
+
* readable through `resolveHandoffPath`):
|
|
7
9
|
*
|
|
8
10
|
* - `initHandoff` — pure; computes sha256 of the body and returns
|
|
9
11
|
* a Handoff whose frontmatter `handoffHash` matches.
|
|
@@ -21,20 +23,111 @@
|
|
|
21
23
|
* markdown source — no normalization, no trailing-newline padding.
|
|
22
24
|
*/
|
|
23
25
|
import { createHash } from 'node:crypto';
|
|
26
|
+
import { existsSync } from 'node:fs';
|
|
24
27
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
25
28
|
import { dirname, join } from 'node:path';
|
|
26
|
-
import { parse as parseYaml
|
|
29
|
+
import { parse as parseYaml } from 'yaml';
|
|
30
|
+
import { isUnsafePathInput } from '../../shared/path-safety.js';
|
|
31
|
+
import { REQUEST_ID_PATTERN } from '../artifacts/request-artifact-service.js';
|
|
32
|
+
import { serializeHandoffFrontmatter } from './handoff-frontmatter.js';
|
|
27
33
|
/** Required schema version for new handoffs. */
|
|
28
34
|
const HANDOFF_SCHEMA_VERSION = '2';
|
|
29
35
|
/** Compute the lowercase hex sha256 of a UTF-8 string. */
|
|
30
36
|
export function sha256OfBody(body) {
|
|
31
37
|
return createHash('sha256').update(body, 'utf8').digest('hex');
|
|
32
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Both ids in a handoff path are caller-supplied path segments, so both are
|
|
41
|
+
* checked at the join. Added 2026-09-14 (repair R1, security audit F2 of
|
|
42
|
+
* `2026-09-14-cli-id-escape-instrumentation`).
|
|
43
|
+
*
|
|
44
|
+
* This function was introduced by `0536d5bd` — the commit that instrumented
|
|
45
|
+
* this defect class — with neither id checked, and it sat outside rule D's
|
|
46
|
+
* scanned layer, so the instrument could not see its own new member.
|
|
47
|
+
* Measured on the pre-fix tree (`prd handoff init --apply`, temp project,
|
|
48
|
+
* `ok: true` both times):
|
|
49
|
+
*
|
|
50
|
+
* --rid '../../../../../../README' replaced the project-root README.md
|
|
51
|
+
* --sid '../../../../SIDOUT' wrote 4 levels above the project root
|
|
52
|
+
*
|
|
53
|
+
* The two axes need two different controls, for a recorded reason: the rid has
|
|
54
|
+
* a pinned format (`REQUEST_ID_PATTERN`, no separator, no dot-dot, no drive)
|
|
55
|
+
* and the sid has none, so it gets the segment check. `isUnsafePathInput`
|
|
56
|
+
* alone is NOT enough for the rid — it admits `a/b` (two non-empty segments),
|
|
57
|
+
* which `request-artifact-service.ts` would reject.
|
|
58
|
+
*
|
|
59
|
+
* Guarding HERE rather than at the three `prd`/`env` flags means every producer
|
|
60
|
+
* that writes through this constructor — `initHandoff`'s default,
|
|
61
|
+
* `handoff-auto-regen.ts`, `evidence-generator.ts` — is covered by the join
|
|
62
|
+
* itself, not by each caller re-deciding.
|
|
63
|
+
*/
|
|
64
|
+
function assertSafeHandoffIds(sessionId, requestId) {
|
|
65
|
+
if (!REQUEST_ID_PATTERN.test(requestId)) {
|
|
66
|
+
throw new Error(`Invalid request id: ${requestId} (expected letters, digits, dots, underscores, or dashes)`);
|
|
67
|
+
}
|
|
68
|
+
if (isUnsafePathInput(sessionId)) {
|
|
69
|
+
throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The canonical capsule path for ONE SLICE, relative to the project root.
|
|
74
|
+
*
|
|
75
|
+
* Slice `2026-09-14-prd-capsule-rid-scoping`: the capsule used to be one slot
|
|
76
|
+
* per SESSION (`prd/handoff.md`), so the second slice's handoff silently
|
|
77
|
+
* overwrote the first slice's — and `AUDIT_REQUIRES_HANDOFF` stayed green
|
|
78
|
+
* because it never checked WHOSE rid the file named. Measured on
|
|
79
|
+
* `2026-09-13-session-21878f`: a four-slice job passed that prerequisite on a
|
|
80
|
+
* capsule left by a different line of work.
|
|
81
|
+
*
|
|
82
|
+
* This is the WRITE target, so it always carries the rid — a consumer-side
|
|
83
|
+
* fallback here would leave a rid-scoped requirement with a bare-name writer,
|
|
84
|
+
* which is the defect shape this slice exists to remove. Readers that must
|
|
85
|
+
* tolerate pre-rid-scoping sessions call `resolveHandoffPath` instead.
|
|
86
|
+
*/
|
|
87
|
+
export function handoffRelativePath(sessionId, requestId) {
|
|
88
|
+
assertSafeHandoffIds(sessionId, requestId);
|
|
89
|
+
return join('.peaks', '_runtime', sessionId, 'prd', `handoff-${requestId}.md`);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The capsule a CONSUMER should read for (session, requestId): the rid-scoped
|
|
93
|
+
* path when it is on disk, else the pre-rid-scoping bare name. Returns null
|
|
94
|
+
* when neither exists.
|
|
95
|
+
*
|
|
96
|
+
* Three sessions on disk still hold only the bare file
|
|
97
|
+
* (`2026-09-06-session-a87ca4`, `2026-09-12-session-e37ef0`,
|
|
98
|
+
* `2026-09-13-session-21878f`), so the legacy tier has to keep resolving for
|
|
99
|
+
* the gate and for every reader below.
|
|
100
|
+
*
|
|
101
|
+
* `requestId` is optional because the detect-only audit surface reaches its
|
|
102
|
+
* service without one. Such a caller can name only the bare path: a session
|
|
103
|
+
* holding nothing but rid-scoped capsules reports missing rather than picking
|
|
104
|
+
* among its siblings' capsules, which would re-open the cross-slice mix-up
|
|
105
|
+
* this scoping exists to close (fail closed, not "some capsule is there").
|
|
106
|
+
*/
|
|
107
|
+
export function resolveHandoffPath(opts) {
|
|
108
|
+
// The legacy bare-name candidate below is a second join of the same sid, in a
|
|
109
|
+
// second function, so it needs the sid guarded in its own right — the
|
|
110
|
+
// optional-requestId branch reaches `handoffRelativePath` (guarded there), but
|
|
111
|
+
// the branch that is taken when a caller has NO rid reaches this join only.
|
|
112
|
+
if (isUnsafePathInput(opts.sessionId)) {
|
|
113
|
+
throw new Error(`Invalid session id: ${opts.sessionId} (must be a single path segment)`);
|
|
114
|
+
}
|
|
115
|
+
const candidates = [
|
|
116
|
+
...(opts.requestId !== undefined ? [handoffRelativePath(opts.sessionId, opts.requestId)] : []),
|
|
117
|
+
join('.peaks', '_runtime', opts.sessionId, 'prd', 'handoff.md')
|
|
118
|
+
];
|
|
119
|
+
for (const relative of candidates) {
|
|
120
|
+
const absolute = join(opts.projectRoot, relative);
|
|
121
|
+
if (existsSync(absolute))
|
|
122
|
+
return absolute;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
33
126
|
/** Pure: produce a Handoff with the frontmatter populated. Hash is
|
|
34
127
|
* computed here; callers MUST NOT pre-populate `handoffHash`. */
|
|
35
128
|
export function initHandoff(opts) {
|
|
36
129
|
const handoffPath = opts.handoffPath ??
|
|
37
|
-
|
|
130
|
+
handoffRelativePath(opts.sessionId, opts.requestId);
|
|
38
131
|
const handoffHash = sha256OfBody(opts.body);
|
|
39
132
|
const frontmatter = {
|
|
40
133
|
requestId: opts.requestId,
|
|
@@ -146,8 +239,11 @@ function parseHandoffContent(content) {
|
|
|
146
239
|
};
|
|
147
240
|
}
|
|
148
241
|
function serializeHandoff(handoff) {
|
|
149
|
-
|
|
150
|
-
|
|
242
|
+
// The one canonical frontmatter rendering, shared with
|
|
243
|
+
// `handoff-auto-regen.ts`. `yaml.stringify` used to render this block and
|
|
244
|
+
// emitted `schemaVersion: "2"` + a bare `handoffHash:`, which the
|
|
245
|
+
// `AUDIT_REQUIRES_HANDOFF` gate and both audit loaders all reject.
|
|
246
|
+
return `${serializeHandoffFrontmatter(handoff.frontmatter)}${handoff.body}`;
|
|
151
247
|
}
|
|
152
248
|
/**
|
|
153
249
|
* N1: accept BOTH shapes of `schemaVersion` — the string `'2'` and the bare
|
|
@@ -161,13 +257,33 @@ function serializeHandoff(handoff) {
|
|
|
161
257
|
* readable handoff was satisfied by a handoff the parser would not read, and
|
|
162
258
|
* `peaks prd handoff verify` exited 1 on a healthy file.
|
|
163
259
|
*
|
|
164
|
-
*
|
|
165
|
-
* the
|
|
166
|
-
*
|
|
260
|
+
* Slice `2026-09-14-handoff-writer-gate-divergence` then fixed the other half:
|
|
261
|
+
* the writer no longer emits the quoted form at all (see
|
|
262
|
+
* `handoff-frontmatter.ts`). This tolerance stays because handoffs already on
|
|
263
|
+
* disk were written by the old writer and by hand; the writer fix must not
|
|
264
|
+
* retroactively make them unreadable.
|
|
167
265
|
*/
|
|
168
266
|
function isSchemaVersion2(value) {
|
|
169
267
|
return value === HANDOFF_SCHEMA_VERSION || value === 2;
|
|
170
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* The READER's shape check — deliberately looser than `verifyHandoff`:
|
|
271
|
+
* `handoffHash` must be a string, and nothing about its VALUE is validated here.
|
|
272
|
+
*
|
|
273
|
+
* The accepted-but-unverifiable shape, stated rather than left to be
|
|
274
|
+
* discovered: a capsule carrying `handoffHash: sha256:<hex>` — the form the
|
|
275
|
+
* pre-fix writer and this session's hand-corrected capsules use — is READ by
|
|
276
|
+
* `readHandoff` and can NEVER pass `verifyHandoff`. The verifier compares the
|
|
277
|
+
* value to `sha256OfBody(body)` byte for byte (`:127-135`) and no prefix
|
|
278
|
+
* normalization exists anywhere in this module, so the leading `sha256:`
|
|
279
|
+
* guarantees `hash-mismatch`. ONLY the bare-hex form verifies, which is what
|
|
280
|
+
* every writer now emits (`handoff-frontmatter.ts`).
|
|
281
|
+
*
|
|
282
|
+
* So `readHandoff` succeeding is NOT evidence that a capsule is verifiable —
|
|
283
|
+
* the tolerance above exists so old capsules stay READABLE, not so they become
|
|
284
|
+
* acceptable. Request §三 bullet 3 asked for exactly this confirmation
|
|
285
|
+
* (rid `2026-09-14-handoff-writer-gate-divergence`).
|
|
286
|
+
*/
|
|
171
287
|
function isHandoffFrontmatter(value) {
|
|
172
288
|
if (!value || typeof value !== 'object')
|
|
173
289
|
return false;
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
* (D1 in `v2-11-rm-rd-techdoc-immutable-handoff`).
|
|
15
15
|
*
|
|
16
16
|
* Path convention: the file lands at
|
|
17
|
-
* `.peaks/_runtime/<sessionId>/prd/handoff
|
|
18
|
-
*
|
|
17
|
+
* `.peaks/_runtime/<sessionId>/prd/handoff-<rid>.md` (one capsule per slice;
|
|
18
|
+
* `.peaks/_runtime/<sessionId>/prd/handoff.md` is the pre-rid-scoping tier and
|
|
19
|
+
* stays readable). Gitignored session artifact; the binding to `<sessionId>` lives in
|
|
19
20
|
* `.peaks/_runtime/current-change`). NEVER write under
|
|
20
21
|
* `.peaks/_runtime/<change-id>/...` directly (slice 2.8.3 hard ban).
|
|
21
22
|
*/
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
* (D1 in `v2-11-rm-rd-techdoc-immutable-handoff`).
|
|
15
15
|
*
|
|
16
16
|
* Path convention: the file lands at
|
|
17
|
-
* `.peaks/_runtime/<sessionId>/prd/handoff
|
|
18
|
-
*
|
|
17
|
+
* `.peaks/_runtime/<sessionId>/prd/handoff-<rid>.md` (one capsule per slice;
|
|
18
|
+
* `.peaks/_runtime/<sessionId>/prd/handoff.md` is the pre-rid-scoping tier and
|
|
19
|
+
* stays readable). Gitignored session artifact; the binding to `<sessionId>` lives in
|
|
19
20
|
* `.peaks/_runtime/current-change`). NEVER write under
|
|
20
21
|
* `.peaks/_runtime/<change-id>/...` directly (slice 2.8.3 hard ban).
|
|
21
22
|
*/
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
18
18
|
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { isUnsafePathInput } from '../../shared/path-safety.js';
|
|
20
|
+
import { REQUEST_ID_PATTERN } from '../artifacts/request-artifact-service.js';
|
|
19
21
|
/** 6-item business checklist (12 Gaps QA perspective). */
|
|
20
22
|
export const QA_BUSINESS_ITEMS = [
|
|
21
23
|
{ id: 'business-flow', question: '这个功能"用起来"对吗?(业务流程顺不顺,操作路径是否反人类,跟现有系统交互有没有断层)' },
|
|
@@ -36,9 +38,30 @@ export function buildEmptyQaReview(requestId, sessionId, now = new Date()) {
|
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
40
|
export function getQaReviewDir(projectRoot, sessionId) {
|
|
41
|
+
// Sid axis ONLY. The guard here covers the session segment of every
|
|
42
|
+
// `peaks qa-business-review|score|accept|reject` subcommand — measured:
|
|
43
|
+
// `--session-id ../../../../…/PWNED` wrote `qa-business-reviews/<rid>.json`
|
|
44
|
+
// outside every project root under an `ok: true` envelope (RD sweep case A17).
|
|
45
|
+
//
|
|
46
|
+
// It does NOT cover the rid axis: the request id is joined one function later,
|
|
47
|
+
// in `getQaReviewPath` below. Corrected 2026-09-14 (repair R1) — this comment
|
|
48
|
+
// previously said "one guard here covers the whole family", and the security
|
|
49
|
+
// audit of `2026-09-14-cli-id-escape-instrumentation` (F1b) measured that claim
|
|
50
|
+
// false: `qa-business-review '../../../../…/EVILQA3'` wrote a `.json` file
|
|
51
|
+
// outside the project root under `ok: true`.
|
|
52
|
+
if (isUnsafePathInput(sessionId)) {
|
|
53
|
+
throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
|
|
54
|
+
}
|
|
39
55
|
return resolve(projectRoot, '.peaks', '_runtime', sessionId, 'qa-business-reviews');
|
|
40
56
|
}
|
|
41
57
|
export function getQaReviewPath(projectRoot, sessionId, requestId) {
|
|
58
|
+
// Rid axis. The requestId is the CLI positional (`qa-business-review
|
|
59
|
+
// <request-id>`), so it is caller-supplied and it becomes a filename segment
|
|
60
|
+
// here. `isUnsafePathInput` alone would admit `a/b`; the rid has a pinned
|
|
61
|
+
// format, so the format check is the control.
|
|
62
|
+
if (!REQUEST_ID_PATTERN.test(requestId)) {
|
|
63
|
+
throw new Error(`Invalid request id: ${requestId} (expected letters, digits, dots, underscores, or dashes)`);
|
|
64
|
+
}
|
|
42
65
|
return join(getQaReviewDir(projectRoot, sessionId), `${requestId}.json`);
|
|
43
66
|
}
|
|
44
67
|
export function readQaReview(projectRoot, sessionId, requestId) {
|
|
@@ -51,6 +51,14 @@ export type CommitBoundary = {
|
|
|
51
51
|
syncState: 'synced' | 'pending' | 'failed';
|
|
52
52
|
rollbackPoint: string | null;
|
|
53
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* The repo's slice-id control — no separator, no drive, no `..`, non-empty, and
|
|
56
|
+
* not a bare `.`/`..`. Exported 2026-09-14 (repair R1) so the slice-id axis has
|
|
57
|
+
* ONE control: `slice-review-state.getReviewPath` joins a caller-supplied slice
|
|
58
|
+
* id into a filename and previously had none, and a second copy of this regex
|
|
59
|
+
* there would be the same axis decided twice.
|
|
60
|
+
*/
|
|
61
|
+
export declare const SLICE_ID_PATTERN: RegExp;
|
|
54
62
|
/**
|
|
55
63
|
* Resolution sources for `resolveArtifactSession`, in priority order.
|
|
56
64
|
* - `active-skill`: the canonical sid-scoped lease projection
|
|
@@ -44,7 +44,14 @@ const MODERN_RETENTION_REQUIREMENTS = [
|
|
|
44
44
|
'qa/test-reports/{sliceId}.md',
|
|
45
45
|
'txt/handoff.md'
|
|
46
46
|
];
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The repo's slice-id control — no separator, no drive, no `..`, non-empty, and
|
|
49
|
+
* not a bare `.`/`..`. Exported 2026-09-14 (repair R1) so the slice-id axis has
|
|
50
|
+
* ONE control: `slice-review-state.getReviewPath` joins a caller-supplied slice
|
|
51
|
+
* id into a filename and previously had none, and a second copy of this regex
|
|
52
|
+
* there would be the same axis decided twice.
|
|
53
|
+
*/
|
|
54
|
+
export const SLICE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/;
|
|
48
55
|
function getPeaksPath(workspaceRoot) {
|
|
49
56
|
return resolve(workspaceRoot, '.peaks');
|
|
50
57
|
}
|
|
@@ -68,7 +68,7 @@ export async function scanKarpathy(options) {
|
|
|
68
68
|
warnings: [
|
|
69
69
|
`Karpathy review file missing: ${reviewRel}`,
|
|
70
70
|
'Per karpathy §1 Think Before Coding: state your assumptions. Without a review file, no 5-way fanout evidence is available.',
|
|
71
|
-
'Per karpathy §3 Surgical Changes: touch only what the request requires.
|
|
71
|
+
'Per karpathy §3 Surgical Changes: touch only what the request requires. The rd:qa-handoff gate is satisfied by rd/karpathy-review-<rid>.md; this scanner has no rid and reads only the back-compat name rd/karpathy-review.md, so a rid-scoped-only slice still reads as missing here. `peaks request transition --state qa-handoff` is the authoritative gate.'
|
|
72
72
|
]
|
|
73
73
|
};
|
|
74
74
|
}
|
|
@@ -223,6 +223,6 @@ export function formatKarpathyMarkdown(report, opts = {}) {
|
|
|
223
223
|
}
|
|
224
224
|
lines.push('### Karpathy-Gate');
|
|
225
225
|
lines.push('');
|
|
226
|
-
lines.push('Per `andrej-karpathy-skills:karpathy-guidelines` §1 Think Before Coding / §3 Surgical Changes, the hard Karpathy-Gate requires `rd/karpathy-review
|
|
226
|
+
lines.push('Per `andrej-karpathy-skills:karpathy-guidelines` §1 Think Before Coding / §3 Surgical Changes, the hard Karpathy-Gate requires `rd/karpathy-review-<rid>.md` to be present with all 4 guideline sections before `peaks request transition --state qa-handoff`. (`rd/karpathy-review.md` is the accepted back-compat tier, and the only name this scanner probes.)');
|
|
227
227
|
return lines.join('\n');
|
|
228
228
|
}
|