peaks-loop 4.0.48 → 4.0.50
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/dist/cli/commands/audit-commands.js +1 -0
- package/dist/cli/commands/baseline-commands.js +163 -25
- package/dist/cli/commands/compact-command.js +1 -3
- package/dist/cli/commands/core/skill-command.js +53 -4
- package/dist/cli/commands/core/standards-command.d.ts +24 -0
- package/dist/cli/commands/core/standards-command.js +74 -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 +55 -38
- package/dist/cli/commands/loop-eval-commands.js +22 -6
- package/dist/cli/commands/share-commands.js +37 -11
- package/dist/cli/commands/slice-integrate-commands.js +17 -0
- package/dist/cli/commands/web-commands.js +8 -1
- package/dist/cli/commands/workflow-lifecycle-commands.d.ts +6 -0
- package/dist/cli/commands/workflow-lifecycle-commands.js +64 -3
- package/dist/services/adapter/adapter.d.ts +30 -0
- package/dist/services/adapter/auto-adapter.d.ts +13 -0
- package/dist/services/adapter/claude-adapter.js +12 -0
- package/dist/services/adapter/codex-adapter.d.ts +12 -0
- package/dist/services/adapter/codex-adapter.js +12 -0
- package/dist/services/adapter/copilot-adapter.d.ts +12 -0
- package/dist/services/adapter/copilot-adapter.js +12 -0
- package/dist/services/artifacts/artifact-prerequisites.js +10 -0
- package/dist/services/artifacts/request-artifact-service.js +59 -38
- package/dist/services/audit/backing-detector.d.ts +25 -7
- package/dist/services/audit/backing-detector.js +33 -17
- package/dist/services/audit/enforcer-liveness.d.ts +12 -0
- package/dist/services/audit/enforcer-liveness.js +100 -0
- package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
- package/dist/services/audit/enforcers/lint-catalog-governance.d.ts +23 -11
- package/dist/services/audit/enforcers/lint-catalog-governance.js +10 -14
- package/dist/services/audit/enforcers/lint-rd-handoff-coverage.d.ts +5 -15
- package/dist/services/audit/enforcers/lint-rd-handoff-coverage.js +94 -25
- package/dist/services/audit/enforcers/lint-style.d.ts +9 -1
- package/dist/services/audit/enforcers/lint-style.js +38 -2
- package/dist/services/audit/prose-ratio-calculator.d.ts +28 -17
- package/dist/services/audit/prose-ratio-calculator.js +25 -18
- package/dist/services/audit/red-line-catalog-p2-a.js +1 -1
- package/dist/services/audit/red-lines-service.js +51 -7
- package/dist/services/capability-audit-service/independent-checker.d.ts +15 -0
- package/dist/services/capability-audit-service/independent-checker.js +140 -0
- package/dist/services/capability-audit-service/index.d.ts +3 -1
- package/dist/services/capability-audit-service/index.js +1 -0
- package/dist/services/capability-audit-service/runner.d.ts +17 -13
- package/dist/services/capability-audit-service/runner.js +76 -15
- package/dist/services/capability-audit-service/types.d.ts +48 -0
- package/dist/services/capability-guard-runner/contracts/J01.js +21 -22
- package/dist/services/capability-guard-runner/contracts/J02.d.ts +1 -1
- package/dist/services/capability-guard-runner/contracts/J02.js +114 -28
- package/dist/services/capability-guard-runner/contracts/J03.d.ts +13 -0
- package/dist/services/capability-guard-runner/contracts/J03.js +72 -21
- package/dist/services/capability-guard-runner/contracts/J04.d.ts +6 -0
- package/dist/services/capability-guard-runner/contracts/J04.js +65 -32
- package/dist/services/capability-guard-runner/contracts/J05.js +118 -16
- package/dist/services/capability-guard-runner/contracts/J06.d.ts +14 -0
- package/dist/services/capability-guard-runner/contracts/J06.js +57 -39
- package/dist/services/capability-guard-runner/contracts/J07.d.ts +9 -0
- package/dist/services/capability-guard-runner/contracts/J07.js +76 -47
- package/dist/services/capability-guard-runner/contracts/J08.d.ts +11 -0
- package/dist/services/capability-guard-runner/contracts/J08.js +66 -39
- package/dist/services/capability-guard-runner/contracts/J09.d.ts +13 -0
- package/dist/services/capability-guard-runner/contracts/J09.js +95 -39
- package/dist/services/capability-guard-runner/contracts/J10.d.ts +12 -0
- package/dist/services/capability-guard-runner/contracts/J10.js +69 -35
- package/dist/services/capability-guard-runner/contracts/J11.d.ts +8 -0
- package/dist/services/capability-guard-runner/contracts/J11.js +73 -33
- package/dist/services/capability-guard-runner/contracts/J12.d.ts +12 -0
- package/dist/services/capability-guard-runner/contracts/J12.js +66 -30
- package/dist/services/capability-guard-runner/contracts/J13.d.ts +11 -0
- package/dist/services/capability-guard-runner/contracts/J13.js +62 -40
- package/dist/services/capability-guard-runner/contracts/J14.d.ts +11 -0
- package/dist/services/capability-guard-runner/contracts/J14.js +60 -31
- package/dist/services/capability-guard-runner/contracts/J15.d.ts +11 -0
- package/dist/services/capability-guard-runner/contracts/J15.js +70 -35
- package/dist/services/capability-guard-runner/contracts/_shared.d.ts +24 -0
- package/dist/services/capability-guard-runner/contracts/_shared.js +67 -0
- package/dist/services/capability-guard-runner/registry.d.ts +5 -0
- package/dist/services/capability-guard-runner/registry.js +140 -0
- package/dist/services/capability-guard-runner/runner.d.ts +26 -0
- package/dist/services/capability-guard-runner/runner.js +63 -6
- package/dist/services/code/auto-compact-lifecycle.d.ts +75 -0
- package/dist/services/code/auto-compact-lifecycle.js +65 -16
- package/dist/services/code/auto-compact-modes.d.ts +13 -2
- package/dist/services/code/auto-compact-modes.js +20 -4
- package/dist/services/code/auto-compact-orchestrator.js +119 -19
- package/dist/services/code/compact-event-settle.d.ts +20 -8
- package/dist/services/code/compact-event-settle.js +21 -0
- package/dist/services/code/post-compact-detector.js +20 -11
- package/dist/services/code/step-08-gate.js +21 -6
- package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
- package/dist/services/config/config-safety.js +11 -9
- package/dist/services/context/auto-compact-types.d.ts +20 -2
- 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/pre-post-diff.js +10 -2
- package/dist/services/job/job-progress-store.js +18 -3
- 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 +11 -4
- package/dist/services/observability/observability-service.js +16 -3
- package/dist/services/prd/handoff-service.js +43 -0
- package/dist/services/qa/qa-business-review-state.js +19 -5
- package/dist/services/sc/sc-service.d.ts +8 -0
- package/dist/services/sc/sc-service.js +8 -1
- package/dist/services/scan/api-diff-types.js +20 -2
- package/dist/services/security/safe-settings-path.js +19 -1
- package/dist/services/session/getSessionDir.d.ts +33 -0
- package/dist/services/session/getSessionDir.js +60 -0
- package/dist/services/skill/skill-search-service.d.ts +3 -3
- package/dist/services/slice/slice-review-state.js +19 -4
- package/dist/services/standards/loop-engineering-lint.d.ts +1 -1
- package/dist/services/standards/loop-engineering-lint.js +6 -0
- package/dist/services/web/daemon-registry.js +27 -2
- package/dist/services/workflow/pipeline-verify-gate-support.js +10 -11
- package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
- package/dist/services/workflow/pipeline-verify-service.js +23 -10
- package/dist/services/workflow/pipeline-verify-types.d.ts +5 -3
- package/dist/services/workspace/claude-settings-template.d.ts +53 -37
- package/dist/services/workspace/claude-settings-template.js +105 -83
- package/dist/services/workspace/generated-artifacts-stamp.d.ts +119 -0
- package/dist/services/workspace/generated-artifacts-stamp.js +167 -0
- package/dist/services/workspace/workspace-claude-settings-materializer.d.ts +8 -0
- package/dist/services/workspace/workspace-claude-settings-materializer.js +38 -3
- package/dist/services/workspace/workspace-service.js +11 -1
- package/dist/shared/fs-utils.d.ts +26 -0
- package/dist/shared/fs-utils.js +35 -0
- package/dist/shared/runtime-root.d.ts +73 -0
- package/dist/shared/runtime-root.js +77 -0
- package/package.json +9 -7
- package/scripts/copy-templates.mjs +0 -12
- package/scripts/install-skills.mjs +154 -53
- package/skills/bee/peaks-qa/SKILL.md +0 -1
- package/skills/bee/peaks-rd/SKILL.md +0 -1
- package/skills/peaks-code/SKILL.md +12 -10
- package/skills/peaks-code/references/periodic-checkpoint.md +2 -2
- package/skills/peaks-code/references/runbook.md +3 -0
- package/skills/peaks-code/references/session-overload-signal-index.md +4 -2
- package/skills/peaks-code/references/startup-sequence.md +2 -2
- package/skills/peaks-code/references/step-0-8-gate.md +1 -1
- package/skills/peaks-code/references/sub-agent-dispatch.md +19 -19
- package/dist/cli/commands/context-builder-commands.d.ts +0 -11
- package/dist/cli/commands/context-builder-commands.js +0 -85
- package/dist/services/hooks/write-gate.js +0 -111
- package/skills/bee/peaks-prd/references/command-migration.md +0 -3
- package/skills/bee/peaks-qa/references/command-migration.md +0 -3
- package/skills/bee/peaks-rd/references/command-migration.md +0 -3
- package/skills/bee/peaks-sc/references/command-migration.md +0 -3
- package/skills/bee/peaks-txt/references/command-migration.md +0 -3
- package/skills/bee/peaks-ui/references/command-migration.md +0 -3
- package/skills/peaks-code/references/command-migration.md +0 -3
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rid 2026-09-14-gate-h-promotion (R2; repaired by R8) — what a promotion's
|
|
3
|
+
* artifact has to PROVE.
|
|
4
|
+
*
|
|
5
|
+
* The three layers used to be checked with `text.includes(<rule name>)` over the
|
|
6
|
+
* whole file. A substring test cannot tell "the rule is registered" from "a
|
|
7
|
+
* comment saying the rule is absent", so a refusal tree passed every layer:
|
|
8
|
+
*
|
|
9
|
+
* - layer A: a `.peaks/sops/registry.json` that is not valid JSON, with the id
|
|
10
|
+
* still somewhere in its bytes;
|
|
11
|
+
* - layer B: a settings template whose only mention of the rule reads
|
|
12
|
+
* "do NOT add a matcher for <rule>";
|
|
13
|
+
* - layer C: a `mode-gate.ts` whose only mention reads
|
|
14
|
+
* "// TODO: <rule> is DELIBERATELY NOT a hard-floor category".
|
|
15
|
+
*
|
|
16
|
+
* All three are the same defect facing the other way: an unreadable artifact is
|
|
17
|
+
* treated as a permitted one. Each check below therefore parses its evidence and
|
|
18
|
+
* asserts the SHAPE, and every failure is a finding — never a warning that
|
|
19
|
+
* permits. Nothing here throws: an unparseable file is reported, not swallowed.
|
|
20
|
+
*
|
|
21
|
+
* R8 (same rid) — the same defect survived inside R2's repair, in two places:
|
|
22
|
+
*
|
|
23
|
+
* - layer C matched a member's `doc` by name MENTION, so an adverse line
|
|
24
|
+
* sitting between two vocabulary members became the following member's doc
|
|
25
|
+
* and the verdict flipped on where the comment sat. The predicate is now this
|
|
26
|
+
* repo's own citation form — the memory cited by PATH,
|
|
27
|
+
* `.peaks/memory/<id>.md`, exactly as `mode-gate.ts:41` does — rather than
|
|
28
|
+
* the memory merely named. That citation requirement is what closes the
|
|
29
|
+
* placement dependence; the doc is also read from the comment SPANS attached
|
|
30
|
+
* to the member rather than from the raw slice, which states "code is not a
|
|
31
|
+
* doc" as an invariant rather than relying on the raw slice to honour it;
|
|
32
|
+
* - layer B kept `matcher.includes(id)` / `command.includes(id)` over free
|
|
33
|
+
* text, so `command: "echo 'do NOT add a matcher for rule-x'"` registered a
|
|
34
|
+
* rule by writing a sentence about it. Both fields are now parsed: a matcher
|
|
35
|
+
* is a tool selector, a command is argv.
|
|
36
|
+
*
|
|
37
|
+
* R10 (same rid) — layer C read the `HardFloorCategory` union and the
|
|
38
|
+
* `HARD_FLOOR_CATEGORIES` array as ONE flattened member list, so a member of the
|
|
39
|
+
* union alone satisfied the check. Measured on the R8 bytes: a union-only literal
|
|
40
|
+
* — and a union-only member whose doc cited `.peaks/memory/<id>.md` — were both
|
|
41
|
+
* reported BACKED, while `isHardFloorCategory` (which reads the array) returned
|
|
42
|
+
* false and `shouldPauseAtGate` returned `shouldPause: false`. The gate certified
|
|
43
|
+
* a hard floor that paused nothing, contradicting its own message. Only the array
|
|
44
|
+
* enforces, so only the array is now evidence — for the name and for the citation
|
|
45
|
+
* alike. The union is still READ, for one reason only: a category's doc belongs
|
|
46
|
+
* to the category, and this repo documents `commit-boundary-side-effect` beside
|
|
47
|
+
* the union member while the array enforces it.
|
|
48
|
+
*/
|
|
49
|
+
export type PromotionEvidence =
|
|
50
|
+
/** A SOP manifest: a JSON object whose `id` is the SOP's and whose `gates` is an array. */
|
|
51
|
+
'sop-manifest'
|
|
52
|
+
/** An entry with the SOP's `id` inside `<registry>.sops[]` — what `readRegistry()` enumerates. */
|
|
53
|
+
| 'sop-registry-entry'
|
|
54
|
+
/** A hook registration (hook command) inside `hooks` that runs something named after the rule. */
|
|
55
|
+
| 'hook-registration'
|
|
56
|
+
/** A member of `HARD_FLOOR_CATEGORIES` — the array `isHardFloorCategory` reads — that names the rule. */
|
|
57
|
+
| 'hard-floor-category';
|
|
58
|
+
export type PromotionArtifactCheck = {
|
|
59
|
+
/** Project-relative POSIX path whose CONTENT must carry the evidence. */
|
|
60
|
+
path: string;
|
|
61
|
+
evidence: PromotionEvidence;
|
|
62
|
+
/** The rule id the evidence must name (a SOP id for layer A, a memory name for B and C). */
|
|
63
|
+
id: string;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* The reason `check` is unsatisfied by `text`, or `null` when it is satisfied.
|
|
67
|
+
* Never throws: a file that cannot be parsed yields the reason it could not be.
|
|
68
|
+
*/
|
|
69
|
+
export declare function artifactEvidenceFailure(check: PromotionArtifactCheck, text: string): string | null;
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rid 2026-09-14-gate-h-promotion (R2; repaired by R8) — what a promotion's
|
|
3
|
+
* artifact has to PROVE.
|
|
4
|
+
*
|
|
5
|
+
* The three layers used to be checked with `text.includes(<rule name>)` over the
|
|
6
|
+
* whole file. A substring test cannot tell "the rule is registered" from "a
|
|
7
|
+
* comment saying the rule is absent", so a refusal tree passed every layer:
|
|
8
|
+
*
|
|
9
|
+
* - layer A: a `.peaks/sops/registry.json` that is not valid JSON, with the id
|
|
10
|
+
* still somewhere in its bytes;
|
|
11
|
+
* - layer B: a settings template whose only mention of the rule reads
|
|
12
|
+
* "do NOT add a matcher for <rule>";
|
|
13
|
+
* - layer C: a `mode-gate.ts` whose only mention reads
|
|
14
|
+
* "// TODO: <rule> is DELIBERATELY NOT a hard-floor category".
|
|
15
|
+
*
|
|
16
|
+
* All three are the same defect facing the other way: an unreadable artifact is
|
|
17
|
+
* treated as a permitted one. Each check below therefore parses its evidence and
|
|
18
|
+
* asserts the SHAPE, and every failure is a finding — never a warning that
|
|
19
|
+
* permits. Nothing here throws: an unparseable file is reported, not swallowed.
|
|
20
|
+
*
|
|
21
|
+
* R8 (same rid) — the same defect survived inside R2's repair, in two places:
|
|
22
|
+
*
|
|
23
|
+
* - layer C matched a member's `doc` by name MENTION, so an adverse line
|
|
24
|
+
* sitting between two vocabulary members became the following member's doc
|
|
25
|
+
* and the verdict flipped on where the comment sat. The predicate is now this
|
|
26
|
+
* repo's own citation form — the memory cited by PATH,
|
|
27
|
+
* `.peaks/memory/<id>.md`, exactly as `mode-gate.ts:41` does — rather than
|
|
28
|
+
* the memory merely named. That citation requirement is what closes the
|
|
29
|
+
* placement dependence; the doc is also read from the comment SPANS attached
|
|
30
|
+
* to the member rather than from the raw slice, which states "code is not a
|
|
31
|
+
* doc" as an invariant rather than relying on the raw slice to honour it;
|
|
32
|
+
* - layer B kept `matcher.includes(id)` / `command.includes(id)` over free
|
|
33
|
+
* text, so `command: "echo 'do NOT add a matcher for rule-x'"` registered a
|
|
34
|
+
* rule by writing a sentence about it. Both fields are now parsed: a matcher
|
|
35
|
+
* is a tool selector, a command is argv.
|
|
36
|
+
*
|
|
37
|
+
* R10 (same rid) — layer C read the `HardFloorCategory` union and the
|
|
38
|
+
* `HARD_FLOOR_CATEGORIES` array as ONE flattened member list, so a member of the
|
|
39
|
+
* union alone satisfied the check. Measured on the R8 bytes: a union-only literal
|
|
40
|
+
* — and a union-only member whose doc cited `.peaks/memory/<id>.md` — were both
|
|
41
|
+
* reported BACKED, while `isHardFloorCategory` (which reads the array) returned
|
|
42
|
+
* false and `shouldPauseAtGate` returned `shouldPause: false`. The gate certified
|
|
43
|
+
* a hard floor that paused nothing, contradicting its own message. Only the array
|
|
44
|
+
* enforces, so only the array is now evidence — for the name and for the citation
|
|
45
|
+
* alike. The union is still READ, for one reason only: a category's doc belongs
|
|
46
|
+
* to the category, and this repo documents `commit-boundary-side-effect` beside
|
|
47
|
+
* the union member while the array enforces it.
|
|
48
|
+
*/
|
|
49
|
+
/** Is `value` a JSON object (not null, not an array)? */
|
|
50
|
+
function isRecord(value) {
|
|
51
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
52
|
+
}
|
|
53
|
+
function manifestFailure(parsed, id) {
|
|
54
|
+
if (!isRecord(parsed))
|
|
55
|
+
return 'not a JSON object';
|
|
56
|
+
if (parsed.id !== id)
|
|
57
|
+
return `does not declare id "${id}"`;
|
|
58
|
+
if (!Array.isArray(parsed.gates))
|
|
59
|
+
return 'has no "gates" array';
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
function registryFailure(parsed, id) {
|
|
63
|
+
if (!isRecord(parsed))
|
|
64
|
+
return 'not a JSON object';
|
|
65
|
+
if (!Array.isArray(parsed.sops))
|
|
66
|
+
return 'has no "sops" array';
|
|
67
|
+
const registered = parsed.sops.some((entry) => isRecord(entry) && entry.id === id);
|
|
68
|
+
return registered ? null : `registry has no SOP entry with id "${id}"`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A tool selector: `Name` or `Name(pattern)`, one or more separated by `|` —
|
|
72
|
+
* `Bash`, `Write|Edit|MultiEdit`, `Bash(git push:*)`. A segment that is not a
|
|
73
|
+
* tool-name token (a hyphenated rule name, say) makes the selector malformed.
|
|
74
|
+
*/
|
|
75
|
+
const MATCHER_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*\s*(\(.*\))?$/;
|
|
76
|
+
function isToolMatcher(matcher) {
|
|
77
|
+
const segments = matcher.split('|');
|
|
78
|
+
return segments.every((segment) => MATCHER_SEGMENT_RE.test(segment.trim()));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Split a hook command the way a shell does: whitespace separates words, and
|
|
82
|
+
* quotes group whitespace INTO a word rather than ending it. So a phrase inside
|
|
83
|
+
* a string literal arrives as one long word, while a path arrives short.
|
|
84
|
+
*/
|
|
85
|
+
function commandWords(command) {
|
|
86
|
+
const words = [];
|
|
87
|
+
let current = '';
|
|
88
|
+
let started = false;
|
|
89
|
+
let quote = null;
|
|
90
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
91
|
+
const char = command[i];
|
|
92
|
+
if (char === '\\' && quote !== "'" && i + 1 < command.length) {
|
|
93
|
+
current += command[i + 1];
|
|
94
|
+
started = true;
|
|
95
|
+
i += 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (quote !== null) {
|
|
99
|
+
if (char === quote)
|
|
100
|
+
quote = null;
|
|
101
|
+
else
|
|
102
|
+
current += char;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (char === '"' || char === "'") {
|
|
106
|
+
quote = char;
|
|
107
|
+
started = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (/\s/.test(char)) {
|
|
111
|
+
if (started)
|
|
112
|
+
words.push(current);
|
|
113
|
+
current = '';
|
|
114
|
+
started = false;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
current += char;
|
|
118
|
+
started = true;
|
|
119
|
+
}
|
|
120
|
+
if (started)
|
|
121
|
+
words.push(current);
|
|
122
|
+
return words;
|
|
123
|
+
}
|
|
124
|
+
const SCRIPT_EXTENSION_RE = /\.(?:c|m)?[jt]s$|\.(?:sh|bash|ps1|py|rb)$/i;
|
|
125
|
+
/**
|
|
126
|
+
* Does this hook `command` RUN something named after the rule?
|
|
127
|
+
*
|
|
128
|
+
* A command is argv, not prose. The rule counts only when its name sits inside a
|
|
129
|
+
* single word that (a) is not a phrase — no embedded whitespace, so a sentence
|
|
130
|
+
* inside a string literal is out — and (b) designates a file the hook executes:
|
|
131
|
+
* a path, or a script by extension.
|
|
132
|
+
*
|
|
133
|
+
* `echo 'do NOT add a matcher for rule-x'` parses to two words, the second of
|
|
134
|
+
* which holds the rule's name and four spaces: a sentence ABOUT the rule, not an
|
|
135
|
+
* invocation of it. `node scripts/enforce-rule-x.js` parses to two words, the
|
|
136
|
+
* second a path. That difference is the check.
|
|
137
|
+
*/
|
|
138
|
+
function commandRegistersRule(command, id) {
|
|
139
|
+
return commandWords(command).some((word) => {
|
|
140
|
+
if (/\s/.test(word))
|
|
141
|
+
return false;
|
|
142
|
+
if (!word.includes(id))
|
|
143
|
+
return false;
|
|
144
|
+
return word.includes('/') || word.includes('\\') || SCRIPT_EXTENSION_RE.test(word);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Layer B. A hook group is `{ matcher?, hooks: [...] }`. The matcher SELECTS
|
|
149
|
+
* TOOLS, so it can never name a rule: a group whose selector is not a tool
|
|
150
|
+
* matcher fires nothing and registers nothing. The registration itself is a hook
|
|
151
|
+
* command that runs something named after the rule — prose elsewhere in the file
|
|
152
|
+
* (an `env` note saying "do NOT add a matcher") is not a registration, and
|
|
153
|
+
* neither is a sentence in a command.
|
|
154
|
+
*/
|
|
155
|
+
function hookFailure(parsed, id) {
|
|
156
|
+
if (!isRecord(parsed))
|
|
157
|
+
return 'not a JSON object';
|
|
158
|
+
if (!isRecord(parsed.hooks))
|
|
159
|
+
return 'has no "hooks" object';
|
|
160
|
+
for (const groups of Object.values(parsed.hooks)) {
|
|
161
|
+
if (!Array.isArray(groups))
|
|
162
|
+
continue;
|
|
163
|
+
for (const group of groups) {
|
|
164
|
+
if (!isRecord(group))
|
|
165
|
+
continue;
|
|
166
|
+
if (typeof group.matcher === 'string' && !isToolMatcher(group.matcher))
|
|
167
|
+
continue;
|
|
168
|
+
const hooks = group.hooks;
|
|
169
|
+
if (!Array.isArray(hooks))
|
|
170
|
+
continue;
|
|
171
|
+
const registers = hooks.some((h) => isRecord(h) && typeof h.command === 'string' && commandRegistersRule(h.command, id));
|
|
172
|
+
if (registers)
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return `no hook registration runs anything named "${id}" (a hook command must invoke a file named after the rule)`;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* One pattern, used by BOTH the masker and the doc reader, so a comment can
|
|
180
|
+
* never be blanked by one and read as a doc by the other.
|
|
181
|
+
*/
|
|
182
|
+
const COMMENT_RE = /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g;
|
|
183
|
+
/** Comments are blanked to spaces, which keeps every offset in the file valid. */
|
|
184
|
+
function maskComments(text) {
|
|
185
|
+
return text.replace(COMMENT_RE, (comment) => ' '.repeat(comment.length));
|
|
186
|
+
}
|
|
187
|
+
function commentSpans(source) {
|
|
188
|
+
const spans = [];
|
|
189
|
+
COMMENT_RE.lastIndex = 0;
|
|
190
|
+
let match;
|
|
191
|
+
while ((match = COMMENT_RE.exec(source)) !== null) {
|
|
192
|
+
spans.push({ start: match.index, end: match.index + match[0].length, text: match[0] });
|
|
193
|
+
}
|
|
194
|
+
return spans;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Collect the members of the hard-floor vocabulary, each with the comment text
|
|
198
|
+
* that governs it. `masked` decides where a member STARTS (so a quoted string
|
|
199
|
+
* inside a comment is not a member, and no comment can inject the `;` / `]` that
|
|
200
|
+
* bounds a declaration); the doc is read from the comment SPANS inside the
|
|
201
|
+
* member's slot. Taking it from the raw slice instead — as R2 did — let CODE
|
|
202
|
+
* between two members become a doc, which is what made the verdict depend on
|
|
203
|
+
* where an adverse line sat.
|
|
204
|
+
*/
|
|
205
|
+
function collectMembers(masked, comments, start, end, enforcing, out) {
|
|
206
|
+
const body = masked.slice(start, end);
|
|
207
|
+
const literalRe = /'([^']*)'/g;
|
|
208
|
+
let match;
|
|
209
|
+
let previousEnd = 0;
|
|
210
|
+
while ((match = literalRe.exec(body)) !== null) {
|
|
211
|
+
// A member literal is preceded by nothing but whitespace, a `|` (union) or a
|
|
212
|
+
// `,` (array). Anything else — a `'` inside prose, say — is not a member.
|
|
213
|
+
if (/^[\s|,]*$/.test(body.slice(previousEnd, match.index))) {
|
|
214
|
+
const from = start + previousEnd;
|
|
215
|
+
const to = start + match.index;
|
|
216
|
+
out.push({
|
|
217
|
+
literal: match[1],
|
|
218
|
+
doc: comments
|
|
219
|
+
.filter((comment) => comment.start >= from && comment.end <= to)
|
|
220
|
+
.map((comment) => comment.text)
|
|
221
|
+
.join('\n'),
|
|
222
|
+
enforcing
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
previousEnd = match.index + match[0].length;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The two declarations that spell the vocabulary: the `HardFloorCategory` union
|
|
230
|
+
* (a type) and `HARD_FLOOR_CATEGORIES` (the array `isHardFloorCategory` reads).
|
|
231
|
+
* They are the same vocabulary, but only the ARRAY enforces — see
|
|
232
|
+
* `mergeByLiteral` for why the union is still read.
|
|
233
|
+
*/
|
|
234
|
+
function vocabularyMembers(source) {
|
|
235
|
+
const masked = maskComments(source);
|
|
236
|
+
const comments = commentSpans(source);
|
|
237
|
+
const raw = [];
|
|
238
|
+
const union = /export\s+type\s+HardFloorCategory\s*=/.exec(masked);
|
|
239
|
+
if (union) {
|
|
240
|
+
const start = union.index + union[0].length;
|
|
241
|
+
const end = masked.indexOf(';', start);
|
|
242
|
+
if (end > start)
|
|
243
|
+
collectMembers(masked, comments, start, end, false, raw);
|
|
244
|
+
}
|
|
245
|
+
const array = /HARD_FLOOR_CATEGORIES\s*:/.exec(masked);
|
|
246
|
+
if (array) {
|
|
247
|
+
const equals = masked.indexOf('=', array.index);
|
|
248
|
+
const open = masked.indexOf('[', equals);
|
|
249
|
+
const close = masked.indexOf(']', open + 1);
|
|
250
|
+
if (equals > array.index && open > equals && close > open) {
|
|
251
|
+
collectMembers(masked, comments, open + 1, close, true, raw);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return mergeByLiteral(raw);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* R10 — one entry per literal, and `enforcing` is only ever set by the array.
|
|
258
|
+
*
|
|
259
|
+
* Reading the two declarations as one flat member list let a member of the
|
|
260
|
+
* `HardFloorCategory` union ALONE satisfy this check while
|
|
261
|
+
* `isHardFloorCategory` — and therefore `shouldPauseAtGate` — did not recognise
|
|
262
|
+
* it. The gate reported BACKED for a hard floor that paused nothing. Splitting
|
|
263
|
+
* the provenance of `enforcing` out of the member list is what makes the two
|
|
264
|
+
* agree: what the predicate accepts is exactly what the array contains.
|
|
265
|
+
*
|
|
266
|
+
* The docs are joined across occurrences rather than kept per-declaration,
|
|
267
|
+
* because a category's doc belongs to the CATEGORY. This repo's own layer-C
|
|
268
|
+
* promotion cites `.peaks/memory/2026-06-28-full-auto-boundary.md` from the
|
|
269
|
+
* comment beside the UNION member of `commit-boundary-side-effect`, while the
|
|
270
|
+
* ARRAY is what enforces it; reading the doc from one declaration only would
|
|
271
|
+
* make that citation unreadable — a false negative on a file already known good.
|
|
272
|
+
*/
|
|
273
|
+
function mergeByLiteral(members) {
|
|
274
|
+
const merged = new Map();
|
|
275
|
+
for (const member of members) {
|
|
276
|
+
const previous = merged.get(member.literal);
|
|
277
|
+
merged.set(member.literal, {
|
|
278
|
+
literal: member.literal,
|
|
279
|
+
doc: [previous?.doc, member.doc].filter((doc) => doc !== undefined && doc !== '').join('\n'),
|
|
280
|
+
enforcing: (previous?.enforcing ?? false) || member.enforcing
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return [...merged.values()];
|
|
284
|
+
}
|
|
285
|
+
/** A memory cited by path, the way this repo cites one (`mode-gate.ts:41`). */
|
|
286
|
+
const MEMORY_CITATION_RE = /\.peaks\/memory\/([A-Za-z0-9._-]+)\.md/g;
|
|
287
|
+
function citedMemories(doc) {
|
|
288
|
+
return Array.from(doc.matchAll(MEMORY_CITATION_RE), (match) => match[1]);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Layer C. The rule counts when it IS a member of `HARD_FLOOR_CATEGORIES`, or
|
|
292
|
+
* when one of THAT ARRAY's members has a doc block that CITES it the way this
|
|
293
|
+
* repo cites a memory — by path: `commit-boundary-side-effect` cites
|
|
294
|
+
* `.peaks/memory/2026-06-28-full-auto-boundary.md`.
|
|
295
|
+
*
|
|
296
|
+
* R10 — both clauses are gated on `enforcing`. Certifying membership in the
|
|
297
|
+
* `HardFloorCategory` union alone is what let the check report BACKED for a
|
|
298
|
+
* category that `isHardFloorCategory` rejects and `shouldPauseAtGate` ignores.
|
|
299
|
+
*
|
|
300
|
+
* A citation is a deliberate act with a shape; a name is a word that any
|
|
301
|
+
* sentence can carry. `// TODO: <rule> is DELIBERATELY NOT a hard-floor
|
|
302
|
+
* category` names the rule while saying the opposite, and is rejected in every
|
|
303
|
+
* placement — the predicate never reads the sentence the citation sits in, so
|
|
304
|
+
* prose that negates a citation is a residual this cannot see. What it does see
|
|
305
|
+
* is the difference between being cited and being mentioned.
|
|
306
|
+
*/
|
|
307
|
+
function hardFloorFailure(source, id) {
|
|
308
|
+
const registered = vocabularyMembers(source).some((member) => member.enforcing && (member.literal === id || citedMemories(member.doc).includes(id)));
|
|
309
|
+
return registered
|
|
310
|
+
? null
|
|
311
|
+
: `no hard-floor category names "${id}" (it must be a member of HARD_FLOOR_CATEGORIES, or cited by one as ".peaks/memory/${id}.md")`;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The reason `check` is unsatisfied by `text`, or `null` when it is satisfied.
|
|
315
|
+
* Never throws: a file that cannot be parsed yields the reason it could not be.
|
|
316
|
+
*/
|
|
317
|
+
export function artifactEvidenceFailure(check, text) {
|
|
318
|
+
if (check.evidence === 'hard-floor-category')
|
|
319
|
+
return hardFloorFailure(text, check.id);
|
|
320
|
+
let parsed;
|
|
321
|
+
try {
|
|
322
|
+
parsed = JSON.parse(text);
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
return `not valid JSON: ${err.message}`;
|
|
326
|
+
}
|
|
327
|
+
if (check.evidence === 'sop-manifest')
|
|
328
|
+
return manifestFailure(parsed, check.id);
|
|
329
|
+
if (check.evidence === 'sop-registry-entry')
|
|
330
|
+
return registryFailure(parsed, check.id);
|
|
331
|
+
return hookFailure(parsed, check.id);
|
|
332
|
+
}
|
|
@@ -123,13 +123,21 @@ function isSourcePath(path) {
|
|
|
123
123
|
* read as two deleted cases; a class that is half-counted is worse than a class
|
|
124
124
|
* that is not counted at all.
|
|
125
125
|
*
|
|
126
|
+
* Word-boundary simulation, not `\b`. The JS regex could spell this as `\b`
|
|
127
|
+
* directly, but the POSIX ERE side of the count has to run on macOS too —
|
|
128
|
+
* where `git grep -E` is BSD grep, and BSD grep's POSIX ERE has no `\b`
|
|
129
|
+
* (it is a literal `b`). The two expressions therefore both spell the boundary
|
|
130
|
+
* as `(^|[^A-Za-z0-9_])`: start of line OR a non-word character. The two
|
|
131
|
+
* sides MUST agree, and `git grep -c` counts matched lines (not occurrences)
|
|
132
|
+
* so the extra prefix group does not shift the count.
|
|
133
|
+
*
|
|
126
134
|
* What is deliberately NOT modelled: an occurrence inside a comment or a string
|
|
127
135
|
* literal still matches. That is symmetric — the same expression is evaluated on
|
|
128
136
|
* both sides — so it cancels out of the DELTA, which is the only thing a removal
|
|
129
137
|
* is ever derived from.
|
|
130
138
|
*/
|
|
131
|
-
const TEST_CASE_LINE_RE =
|
|
132
|
-
const TEST_CASE_ERE = '
|
|
139
|
+
const TEST_CASE_LINE_RE = /(?:^|[^A-Za-z0-9_])(it|test)(\.[A-Za-z]+)*\(/;
|
|
140
|
+
const TEST_CASE_ERE = '(^|[^A-Za-z0-9_])(it|test)(\\.[A-Za-z]+)*\\(';
|
|
133
141
|
/**
|
|
134
142
|
* Column-0 `export` — an export STATEMENT, not a type-checked symbol.
|
|
135
143
|
*
|
|
@@ -22,6 +22,21 @@
|
|
|
22
22
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { z } from 'zod';
|
|
25
|
+
import { guardRuntimeSegment, runtimeRoot } from '../../shared/runtime-root.js';
|
|
26
|
+
/**
|
|
27
|
+
* The single place a job-progress directory is built, and therefore the single
|
|
28
|
+
* place the two ids that reach it are guarded.
|
|
29
|
+
*
|
|
30
|
+
* Slice 2026-09-15 (runtime-path-unrepresentable): this join used to be written
|
|
31
|
+
* at three sites, none of them guarded, and the shipped text rule could not see
|
|
32
|
+
* them — they sit in the service layer, outside the command layer it scans.
|
|
33
|
+
* Both ids are caller-supplied (`peaks job checkpoint` takes them from flags).
|
|
34
|
+
* The seam now requires `GuardedSegment`, so dropping either guard is a compile
|
|
35
|
+
* error rather than a finding someone has to notice.
|
|
36
|
+
*/
|
|
37
|
+
function jobProgressDir(projectRoot, sessionId, jobId) {
|
|
38
|
+
return runtimeRoot(projectRoot).join(guardRuntimeSegment(sessionId, 'session id'), guardRuntimeSegment('job', 'role'), guardRuntimeSegment(jobId, 'job id'));
|
|
39
|
+
}
|
|
25
40
|
export const JOB_PROGRESS_SCHEMA_VERSION = 1;
|
|
26
41
|
export const JobProgressSchema = z.object({
|
|
27
42
|
schemaVersion: z.literal(JOB_PROGRESS_SCHEMA_VERSION),
|
|
@@ -33,7 +48,7 @@ export const JobProgressSchema = z.object({
|
|
|
33
48
|
updatedAt: z.string().datetime()
|
|
34
49
|
});
|
|
35
50
|
export function writeJobProgress(projectRoot, sessionId, input) {
|
|
36
|
-
const dir =
|
|
51
|
+
const dir = jobProgressDir(projectRoot, sessionId, input.jobId);
|
|
37
52
|
mkdirSync(dir, { recursive: true });
|
|
38
53
|
const record = {
|
|
39
54
|
schemaVersion: JOB_PROGRESS_SCHEMA_VERSION,
|
|
@@ -49,7 +64,7 @@ export function writeJobProgress(projectRoot, sessionId, input) {
|
|
|
49
64
|
return record;
|
|
50
65
|
}
|
|
51
66
|
export function readJobProgress(projectRoot, sessionId, jobId) {
|
|
52
|
-
const path = join(projectRoot,
|
|
67
|
+
const path = join(jobProgressDir(projectRoot, sessionId, jobId), 'progress.json');
|
|
53
68
|
if (!existsSync(path)) {
|
|
54
69
|
throw new Error(`JobProgressStore: no progress for ${jobId} at ${path}`);
|
|
55
70
|
}
|
|
@@ -57,7 +72,7 @@ export function readJobProgress(projectRoot, sessionId, jobId) {
|
|
|
57
72
|
return JobProgressSchema.parse(JSON.parse(raw));
|
|
58
73
|
}
|
|
59
74
|
export function tryReadJobProgress(projectRoot, sessionId, jobId) {
|
|
60
|
-
const path = join(projectRoot,
|
|
75
|
+
const path = join(jobProgressDir(projectRoot, sessionId, jobId), 'progress.json');
|
|
61
76
|
if (!existsSync(path))
|
|
62
77
|
return null;
|
|
63
78
|
try {
|
|
@@ -21,6 +21,19 @@ export declare const MAX_METRICS_FILES = 10;
|
|
|
21
21
|
export declare function metricsFilePath(projectRoot: string, sessionId: string): string;
|
|
22
22
|
/** Absolute path to a session's metrics directory. */
|
|
23
23
|
export declare function metricsDirPath(projectRoot: string, sessionId: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Absolute path to a session's metrics JSONL file, or `null` when the
|
|
26
|
+
* session id names no session directory.
|
|
27
|
+
*
|
|
28
|
+
* Total sibling of `metricsFilePath`, built on the axis's own total
|
|
29
|
+
* entry (`tryGetSessionDir`) so the two agree on the predicate. It
|
|
30
|
+
* exists because this module has frames whose contract is never-throws
|
|
31
|
+
* (`readMetricLines`, and through it `readObservabilityEvents`) and
|
|
32
|
+
* `metricsFilePath` was the one call in them that could still throw.
|
|
33
|
+
* `null` here means exactly one thing — the id could not be resolved —
|
|
34
|
+
* and every caller below handles it in the same statement that reads it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function tryMetricsFilePath(projectRoot: string, sessionId: string): string | null;
|
|
24
37
|
/**
|
|
25
38
|
* Append one line to the session's metrics JSONL file. Creates the
|
|
26
39
|
* directory tree on demand. Returns true on success, false on any
|
|
@@ -32,6 +45,12 @@ export declare function appendMetricLine(projectRoot: string, sessionId: string,
|
|
|
32
45
|
* Returns [] when the file does not exist. Does NOT parse — callers
|
|
33
46
|
* decide what counts as valid (see `observability-service.ts`
|
|
34
47
|
* `readObservabilityEvents` for the schema-aware reader).
|
|
48
|
+
*
|
|
49
|
+
* Returns [] for an unresolvable session id too: this is the read half
|
|
50
|
+
* of a fire-and-forget store, and `readObservabilityEvents` documents
|
|
51
|
+
* that a session with no readable metrics file has no events. Throwing
|
|
52
|
+
* here used to escape as a raw guard error out of a documented
|
|
53
|
+
* never-throws reader (repair R6).
|
|
35
54
|
*/
|
|
36
55
|
export declare function readMetricLines(projectRoot: string, sessionId: string): string[];
|
|
37
56
|
export type SessionMetricsEntry = {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
-
import { getSessionDir } from '../session/getSessionDir.js';
|
|
19
|
+
import { getSessionDir, tryGetSessionDir } from '../session/getSessionDir.js';
|
|
20
20
|
export const METRICS_DIR = 'metrics';
|
|
21
21
|
export const METRICS_FILENAME = 'slices.jsonl';
|
|
22
22
|
export const MAX_METRICS_FILES = 10;
|
|
@@ -28,6 +28,22 @@ export function metricsFilePath(projectRoot, sessionId) {
|
|
|
28
28
|
export function metricsDirPath(projectRoot, sessionId) {
|
|
29
29
|
return join(getSessionDir(projectRoot, sessionId), METRICS_DIR);
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Absolute path to a session's metrics JSONL file, or `null` when the
|
|
33
|
+
* session id names no session directory.
|
|
34
|
+
*
|
|
35
|
+
* Total sibling of `metricsFilePath`, built on the axis's own total
|
|
36
|
+
* entry (`tryGetSessionDir`) so the two agree on the predicate. It
|
|
37
|
+
* exists because this module has frames whose contract is never-throws
|
|
38
|
+
* (`readMetricLines`, and through it `readObservabilityEvents`) and
|
|
39
|
+
* `metricsFilePath` was the one call in them that could still throw.
|
|
40
|
+
* `null` here means exactly one thing — the id could not be resolved —
|
|
41
|
+
* and every caller below handles it in the same statement that reads it.
|
|
42
|
+
*/
|
|
43
|
+
export function tryMetricsFilePath(projectRoot, sessionId) {
|
|
44
|
+
const resolved = tryGetSessionDir(projectRoot, sessionId);
|
|
45
|
+
return resolved.ok ? join(resolved.dir, METRICS_DIR, METRICS_FILENAME) : null;
|
|
46
|
+
}
|
|
31
47
|
/**
|
|
32
48
|
* Append one line to the session's metrics JSONL file. Creates the
|
|
33
49
|
* directory tree on demand. Returns true on success, false on any
|
|
@@ -51,9 +67,18 @@ export function appendMetricLine(projectRoot, sessionId, line) {
|
|
|
51
67
|
* Returns [] when the file does not exist. Does NOT parse — callers
|
|
52
68
|
* decide what counts as valid (see `observability-service.ts`
|
|
53
69
|
* `readObservabilityEvents` for the schema-aware reader).
|
|
70
|
+
*
|
|
71
|
+
* Returns [] for an unresolvable session id too: this is the read half
|
|
72
|
+
* of a fire-and-forget store, and `readObservabilityEvents` documents
|
|
73
|
+
* that a session with no readable metrics file has no events. Throwing
|
|
74
|
+
* here used to escape as a raw guard error out of a documented
|
|
75
|
+
* never-throws reader (repair R6).
|
|
54
76
|
*/
|
|
55
77
|
export function readMetricLines(projectRoot, sessionId) {
|
|
56
|
-
const path =
|
|
78
|
+
const path = tryMetricsFilePath(projectRoot, sessionId);
|
|
79
|
+
if (path === null) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
57
82
|
if (!existsSync(path)) {
|
|
58
83
|
return [];
|
|
59
84
|
}
|
|
@@ -29,8 +29,8 @@ export declare const ObservabilityEventSchema: z.ZodObject<{
|
|
|
29
29
|
ts: z.ZodString;
|
|
30
30
|
sessionId: z.ZodString;
|
|
31
31
|
category: z.ZodEnum<{
|
|
32
|
-
dispatch: "dispatch";
|
|
33
32
|
"slice-transition": "slice-transition";
|
|
33
|
+
dispatch: "dispatch";
|
|
34
34
|
checkpoint: "checkpoint";
|
|
35
35
|
"mode-gate": "mode-gate";
|
|
36
36
|
"context-trigger": "context-trigger";
|
|
@@ -56,11 +56,15 @@ export type EmitOptions = {
|
|
|
56
56
|
/** Absolute path to the project root (where `.peaks/_runtime/` lives). */
|
|
57
57
|
projectRoot: string;
|
|
58
58
|
};
|
|
59
|
-
export type EmitFailureReason = 'invalid-schema' | 'write-failed';
|
|
59
|
+
export type EmitFailureReason = 'invalid-schema' | 'write-failed' | 'invalid-session-id';
|
|
60
60
|
export type EmitResult = {
|
|
61
61
|
/** True when the JSONL line was appended; false on any error path. */
|
|
62
62
|
written: boolean;
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Absolute path to the metrics file the event was written to (or would
|
|
65
|
+
* be). Empty string when the session id named no session directory —
|
|
66
|
+
* there is no path to report, and `reason` says so.
|
|
67
|
+
*/
|
|
64
68
|
path: string;
|
|
65
69
|
/** Set only when `written` is false. */
|
|
66
70
|
reason?: EmitFailureReason;
|
|
@@ -81,7 +85,10 @@ export declare function emitObservabilityEvent(event: ObservabilityEvent, option
|
|
|
81
85
|
* lines and any record whose `schemaVersion` does not match the
|
|
82
86
|
* current `OBSERVABILITY_SCHEMA_VERSION` (forward-compat per Q3).
|
|
83
87
|
*
|
|
84
|
-
* Returns [] when the session has no metrics file yet
|
|
88
|
+
* Returns [] when the session has no metrics file yet, and [] when the
|
|
89
|
+
* session id names no session directory — both are "no events are
|
|
90
|
+
* readable here", and this reader does not throw (repair R6: it used to,
|
|
91
|
+
* via `readMetricLines` → `metricsFilePath`).
|
|
85
92
|
*/
|
|
86
93
|
export declare function readObservabilityEvents(projectRoot: string, sessionId: string): ObservabilityEvent[];
|
|
87
94
|
/**
|
|
@@ -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);
|