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
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* returns a structured `PipelineVerification` envelope. Type
|
|
6
6
|
* declarations live in `pipeline-verify-types.ts`; private gate
|
|
7
7
|
* helpers (`rdGatesForType`, `qaGatesForType`, `extractState`,
|
|
8
|
-
* `findRequestFile`, the
|
|
8
|
+
* `findRequestFile`, the RD/QA handoff
|
|
9
9
|
* state sets, and the RD/QA evidence path probes) live in
|
|
10
10
|
* `pipeline-verify-gate-support.ts`. The re-export shim at the
|
|
11
11
|
* bottom preserves the original public surface so existing import
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { isRequestType } from '../artifacts/artifact-prerequisites.js';
|
|
17
17
|
import { readSkipState } from './workflow-state-store.js';
|
|
18
18
|
import { getSessionIdCanonical } from '../session/session-manager.js';
|
|
19
|
-
import { listUnpromotedFeedback } from '../feedback/feedback-promotion-service.js';
|
|
19
|
+
import { listPromotionExempt, listUnpromotedFeedback } from '../feedback/feedback-promotion-service.js';
|
|
20
20
|
import { QA_COMPLETE_STATES, RD_QA_HANDOFF_STATES, extractState, findRequestFile, qaGatesForType, rdGatesForType, resolveQaEvidencePaths, resolveRdEvidencePaths } from './pipeline-verify-gate-support.js';
|
|
21
21
|
export async function verifyPipeline(options) {
|
|
22
22
|
const requestType = isRequestType(options.requestType ?? '') ? options.requestType : 'feature';
|
|
@@ -96,7 +96,7 @@ export async function verifyPipeline(options) {
|
|
|
96
96
|
// to `options.rid` would make every missing-evidence path look like
|
|
97
97
|
// a per-rid scope dir.
|
|
98
98
|
const rdEvidenceDir = resolvedChangeId || options.sessionId || getSessionIdCanonical(options.projectRoot) || options.rid;
|
|
99
|
-
const rdTracker = resolveRdEvidencePaths(rdGates, rdEvidenceDir, options.projectRoot, violations, nextActions, { anyEvidenceResolved: false, allResolvedPathsCanonical: true });
|
|
99
|
+
const rdTracker = resolveRdEvidencePaths(rdGates, rdEvidenceDir, options.projectRoot, options.rid, requestType, violations, nextActions, { anyEvidenceResolved: false, allResolvedPathsCanonical: true });
|
|
100
100
|
// Check if RD reached qa-handoff
|
|
101
101
|
if (rdInvoked && !RD_QA_HANDOFF_STATES.has(rdState)) {
|
|
102
102
|
violations.push(`RD not ready for QA: state is "${rdState}" — must reach "qa-handoff" (unit tests, karpathy-guidelines §1 Think / §2 Simplicity / §3 Surgical / §4 Goal-Driven, code review, security review complete)`);
|
|
@@ -118,16 +118,11 @@ export async function verifyPipeline(options) {
|
|
|
118
118
|
nextActions.push('Invoke Skill(skill="peaks-qa") with the request-id for functional/performance/security testing');
|
|
119
119
|
qaGates[0].detail = 'not found';
|
|
120
120
|
}
|
|
121
|
-
// Check QA evidence files.
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
// v2.17.0 home; the legacy `_runtime/change/<sessionId>/qa/...` probe
|
|
127
|
-
// should only fire for pre-v2.17.0 workspaces, not as a default for
|
|
128
|
-
// new requests.
|
|
129
|
-
const changeIdForResolver = resolvedChangeId || getSessionIdCanonical(options.projectRoot) || rdEvidenceDir;
|
|
130
|
-
const qaTracker = resolveQaEvidencePaths(qaGates, options.projectRoot, rdEvidenceDir, changeIdForResolver, options.rid, violations, nextActions, rdTracker);
|
|
121
|
+
// Check QA evidence files. (The v2.18.1 bug #5 `changeIdForResolver`
|
|
122
|
+
// fallback — current session id when no RD/QA artifact is on disk yet —
|
|
123
|
+
// was dropped by rid 2026-09-14-verify-pipeline-contract-drift along with
|
|
124
|
+
// the security/perf findings branch that was its only consumer.)
|
|
125
|
+
const qaTracker = resolveQaEvidencePaths(qaGates, options.projectRoot, rdEvidenceDir, requestType, options.rid, violations, nextActions, rdTracker);
|
|
131
126
|
const anyEvidenceResolved = qaTracker.anyEvidenceResolved;
|
|
132
127
|
const allResolvedPathsCanonical = qaTracker.allResolvedPathsCanonical;
|
|
133
128
|
// Check if QA reached verdict-issued
|
|
@@ -183,10 +178,12 @@ export async function verifyPipeline(options) {
|
|
|
183
178
|
}
|
|
184
179
|
// Slice 002 (v2.15.0) AC-3 — Gate H "feedback-promotion". Scans
|
|
185
180
|
// `.peaks/memory/*.md` for `metadata.type === 'feedback'` entries
|
|
186
|
-
//
|
|
187
|
-
// sidecar)
|
|
188
|
-
//
|
|
189
|
-
//
|
|
181
|
+
// that lack a promotion marker (HTML comment or `.promotion.json`
|
|
182
|
+
// sidecar) OR whose marker is not backed by the layer's artifact
|
|
183
|
+
// (rid 2026-09-14-gate-h-promotion). When any such feedback is
|
|
184
|
+
// found, the gate fails and the pipeline does not complete until the
|
|
185
|
+
// user promotes via `peaks feedback promote <memory-file> --layer
|
|
186
|
+
// <A|B|C>` — which now produces the artifact, not just the marker.
|
|
190
187
|
//
|
|
191
188
|
// The scan is intentionally non-throwing — a missing or unreadable
|
|
192
189
|
// memory dir is treated as "no feedback found, gate passes" so
|
|
@@ -201,14 +198,25 @@ export async function verifyPipeline(options) {
|
|
|
201
198
|
];
|
|
202
199
|
try {
|
|
203
200
|
const unpromoted = listUnpromotedFeedback({ projectRoot: options.projectRoot });
|
|
201
|
+
// rid 2026-09-14-gate-h-promotion (classify slice): memories that declare
|
|
202
|
+
// themselves out of the gate are reported, never dropped. An exemption the
|
|
203
|
+
// gate does not show would be indistinguishable from a fixed violation.
|
|
204
|
+
const exempt = listPromotionExempt({ projectRoot: options.projectRoot });
|
|
205
|
+
const exemptNote = exempt.length === 0
|
|
206
|
+
? ''
|
|
207
|
+
: `; ${exempt.length} declared not-to-be-promoted: ${exempt.map((e) => `${e.name} (${e.code})`).join('; ')}`;
|
|
204
208
|
if (unpromoted.length === 0) {
|
|
205
209
|
feedbackGates[0].passed = true;
|
|
206
|
-
feedbackGates[0].detail = `0 unpromoted feedback memories in .peaks/memory
|
|
210
|
+
feedbackGates[0].detail = `0 unpromoted feedback memories in .peaks/memory/${exemptNote}`;
|
|
207
211
|
}
|
|
208
212
|
else {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
213
|
+
// rid 2026-09-14-gate-h-promotion: the gate no longer passes on a marker
|
|
214
|
+
// alone. `listUnpromotedFeedback` now also reports markers whose layer
|
|
215
|
+
// artifact is absent, so `unpromoted` mixes "never promoted" with
|
|
216
|
+
// "promoted on paper only" — the reason on each entry says which.
|
|
217
|
+
feedbackGates[0].detail = `${unpromoted.length} feedback memor${unpromoted.length === 1 ? 'y' : 'ies'} without a backed promotion: ${unpromoted.map((u) => `${u.name} (${u.reason})`).join('; ')}${exemptNote}`;
|
|
218
|
+
violations.push(`Gate H feedback-promotion FAILED: ${unpromoted.length} feedback memor${unpromoted.length === 1 ? 'y is' : 'ies are'} not yet promoted to an enforcement layer with a real artifact (${unpromoted.map((u) => u.name).join(', ')})${exemptNote}. A marker alone does not count: layer A needs a registered SOP manifest, layer B a hook command that runs something named after the rule in .peaks/.claude-settings-template.json, layer C a hard-floor category in src/services/code/mode-gate.ts. Run \`peaks feedback promote <memory-file> --layer <A|B|C>\` for each and read what it reports. See sops/feedback-promotion-sop.md.`);
|
|
219
|
+
nextActions.push(`Run \`peaks feedback promote <memory-file> --layer <A|B|C>\` for each feedback memory without a backed promotion to satisfy Gate H.`);
|
|
212
220
|
}
|
|
213
221
|
}
|
|
214
222
|
catch {
|
|
@@ -225,20 +233,26 @@ export async function verifyPipeline(options) {
|
|
|
225
233
|
const complete = rdInvoked && qaInvoked && allRdGatesPassed && allQaGatesPassed && allFeedbackGatesPassed
|
|
226
234
|
&& RD_QA_HANDOFF_STATES.has(rdState) && QA_COMPLETE_STATES.has(qaState);
|
|
227
235
|
// Slice 025 — derive the `acceptedForm` and `gateC` verdict. The form is
|
|
228
|
-
// 'suffixed'
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
236
|
+
// 'suffixed' when the contract's current path served the file and 'legacy'
|
|
237
|
+
// when the deprecated fallback did; 'none' if neither gate passed.
|
|
238
|
+
//
|
|
239
|
+
// rid 2026-09-14-verify-pipeline-contract-drift: the two gates moved from the
|
|
240
|
+
// QA phase to the RD phase, because that is where the current contract puts
|
|
241
|
+
// the evidence (`AUDIT_SECURITY` / `AUDIT_PERF` at `rd:qa-handoff`). The
|
|
242
|
+
// `-<rid>.md` suffix that used to distinguish the forms is a real one again
|
|
243
|
+
// after slice `2026-09-14-audit-artifact-rid-scoping` rid-scoped the audit
|
|
244
|
+
// paths; the resolver marks which form served the file with
|
|
245
|
+
// `[LEGACY_EVIDENCE_PATH]`.
|
|
246
|
+
const secGate = rdGates.find((g) => g.name === 'security-review');
|
|
247
|
+
const perfGate = rdGates.find((g) => g.name === 'perf-baseline');
|
|
248
|
+
const formOf = (gate) => gate?.passed === true && !gate.detail.includes('LEGACY_EVIDENCE_PATH') ? 'suffixed' : 'legacy';
|
|
249
|
+
const secForm = formOf(secGate);
|
|
250
|
+
const perfForm = formOf(perfGate);
|
|
235
251
|
const acceptedForm = !secGate?.passed && !perfGate?.passed
|
|
236
252
|
? 'none'
|
|
237
|
-
: (secForm === '
|
|
238
|
-
? '
|
|
239
|
-
:
|
|
240
|
-
? 'legacy'
|
|
241
|
-
: 'suffixed';
|
|
253
|
+
: (secForm === 'legacy' || perfForm === 'legacy')
|
|
254
|
+
? 'legacy'
|
|
255
|
+
: 'suffixed';
|
|
242
256
|
const gateC = allQaGatesPassed ? 'pass' : 'fail';
|
|
243
257
|
const gateH = allFeedbackGatesPassed ? 'pass' : 'fail';
|
|
244
258
|
// Slice 2026-06-28-code-mode-bypass-fix (defect #3): `true` when
|
|
@@ -50,18 +50,27 @@ export type PipelineVerification = {
|
|
|
50
50
|
* Slice 002 (v2.15.0) AC-3: Gate H "feedback-promotion". Always
|
|
51
51
|
* present (single-element array). Evaluates whether every
|
|
52
52
|
* `metadata.type === 'feedback'` memory in `.peaks/memory/`
|
|
53
|
-
* carries a promotion marker (comment OR sidecar)
|
|
54
|
-
*
|
|
55
|
-
*
|
|
53
|
+
* carries a promotion marker (comment OR sidecar) AND whether the
|
|
54
|
+
* layer that marker claims is actually backed by its artifact
|
|
55
|
+
* (rid 2026-09-14-gate-h-promotion). Failures block the `complete`
|
|
56
|
+
* verdict via the `gateH` field below; the pipeline only completes
|
|
57
|
+
* when every gate in this array passes.
|
|
56
58
|
*/
|
|
57
59
|
feedbackPhase?: {
|
|
58
60
|
gates: PipelineGate[];
|
|
59
61
|
};
|
|
60
62
|
violations: string[];
|
|
61
63
|
nextActions: string[];
|
|
62
|
-
/** Form of the security/performance
|
|
63
|
-
* (slice 025). `'suffixed'`
|
|
64
|
-
*
|
|
64
|
+
/** Form of the security/performance evidence the RD gates accepted
|
|
65
|
+
* (slice 025). `'suffixed'` when the current contract's path served the
|
|
66
|
+
* file, `'legacy'` when a deprecated fallback did, `'none'` when neither
|
|
67
|
+
* gate passed. The per-rid `<rid>.md` suffix the union was named after was
|
|
68
|
+
* retired with `qa/security-findings-<rid>.md` (v2.11.0 D1/D4); the evidence
|
|
69
|
+
* now lives at `audit/security-<rid>.md` / `audit/perf-<rid>.md`
|
|
70
|
+
* (rid-scoped since slice `2026-09-14-audit-artifact-rid-scoping`) with
|
|
71
|
+
* `audit/security.md` / `audit/perf.md` and
|
|
72
|
+
* `rd/security-review.md` / `rd/perf-baseline.md` as the declared legacy
|
|
73
|
+
* fallbacks (rid 2026-09-14-verify-pipeline-contract-drift). */
|
|
65
74
|
acceptedForm?: 'suffixed' | 'legacy' | 'none';
|
|
66
75
|
/** `gateC` is the pre-computed verdict string (AC7 dogfood shape). */
|
|
67
76
|
gateC?: 'pass' | 'fail';
|
|
@@ -83,15 +83,35 @@ export declare const CLAUDE_SETTINGS_LOCAL_FILENAME = ".claude/settings.local.js
|
|
|
83
83
|
*/
|
|
84
84
|
export declare const TEMPLATE_VERSION = "1.7.0";
|
|
85
85
|
/**
|
|
86
|
-
* Compare two serialized template strings
|
|
87
|
-
*
|
|
86
|
+
* Compare two serialized template strings: does the on-disk file already
|
|
87
|
+
* declare every entry the generated tree declares?
|
|
88
88
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
89
|
+
* OWNERSHIP IS PER ENTRY, NOT PER KEY (rid 2026-09-13-two-decisions item ②).
|
|
90
|
+
* This comparator answers "is each entry the GENERATED tree declares present
|
|
91
|
+
* on disk?", NOT "are the two `hooks` trees identical". Extra on-disk entries
|
|
92
|
+
* are IGNORED, so an entry another writer put in this file never makes it look
|
|
93
|
+
* drifted.
|
|
94
|
+
*
|
|
95
|
+
* That is the deliberate other half of the entry-level merge in
|
|
96
|
+
* `mergeTemplateOwnedHooks` / `workspace-claude-settings-materializer.ts`.
|
|
97
|
+
* `.claude/settings.local.json` has a SECOND writer of `hooks.PreToolUse`:
|
|
98
|
+
* `installAutoCompactHook` appends a `Bash|Task` entry. Under the previous
|
|
99
|
+
* exact-tree rule the merged file carried 4 entries against a 3-entry
|
|
100
|
+
* generated tree, so every `peaks workspace init` answered "drifted",
|
|
101
|
+
* rewrote, and reported `refreshed` forever — precisely the state whole-key
|
|
102
|
+
* ownership existed to prevent, and the reason the merge could not ship alone.
|
|
103
|
+
*
|
|
104
|
+
* Matching is order-insensitive AND multiset-aware: the template declares TWO
|
|
105
|
+
* `Bash` entries, and each must have its own counterpart on disk, so a file
|
|
106
|
+
* carrying only one of them is still reported as drifted (the previous
|
|
107
|
+
* index-by-index loop had the same property; it is load-bearing, not a
|
|
108
|
+
* detail).
|
|
109
|
+
*
|
|
110
|
+
* Returns `true` iff both strings parse to objects whose `hooks.PreToolUse`
|
|
111
|
+
* arrays satisfy that containment AND the on-disk `env` already carries every
|
|
112
|
+
* exemption the template declares (extra on-disk keys and extra globs are
|
|
113
|
+
* allowed — a user may exempt other trees, and a requirement the file already
|
|
114
|
+
* exceeds must not re-trigger a write).
|
|
95
115
|
*
|
|
96
116
|
* Returns `false` on any `JSON.parse` error, shape mismatch, or
|
|
97
117
|
* missing `hooks.PreToolUse`. Whitespace and key order do NOT affect
|
|
@@ -101,6 +121,34 @@ export declare const TEMPLATE_VERSION = "1.7.0";
|
|
|
101
121
|
* refresh a stale `.peaks/.claude-settings-template.json` on disk.
|
|
102
122
|
*/
|
|
103
123
|
export declare function templateContentMatches(generated: string, onDisk: string): boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Merge the on-disk `hooks.PreToolUse` list with the template's.
|
|
126
|
+
*
|
|
127
|
+
* THE OWNERSHIP RULE (rid 2026-09-13-two-decisions item ②): this template owns
|
|
128
|
+
* the entries IT DECLARES — and nothing else. Every other on-disk entry is
|
|
129
|
+
* carried across verbatim, whatever its matcher, because the template has no
|
|
130
|
+
* opinion about it:
|
|
131
|
+
*
|
|
132
|
+
* - a `matcher` the template does not declare (`Bash|Task`, the auto-compact
|
|
133
|
+
* hook `installAutoCompactHook` appends) is never touched;
|
|
134
|
+
* - surplus entries BEYOND the template's count for a declared matcher (a
|
|
135
|
+
* user's own `Bash` hook) are surplus too, and survive;
|
|
136
|
+
* - an on-disk entry that fills a declared slot is REPLACED by the template's
|
|
137
|
+
* entry for it. That is what makes a hand-edited (or older-release) entry
|
|
138
|
+
* self-heal instead of lingering next to a correct copy of itself.
|
|
139
|
+
*
|
|
140
|
+
* Slot counting is per `matcher` and positional within it: the template
|
|
141
|
+
* declares TWO `Bash` entries, so the first two on-disk `Bash` entries are
|
|
142
|
+
* theirs and a third is the user's. The template's entries are emitted first,
|
|
143
|
+
* in template order, then the preserved ones in their on-disk order — which is
|
|
144
|
+
* a fixed point: re-merging the result yields the result (the template's own
|
|
145
|
+
* entries are encountered first and refill their own slots).
|
|
146
|
+
*
|
|
147
|
+
* Non-conforming entries (no string `matcher`, no `hooks` array) are preserved
|
|
148
|
+
* rather than dropped: guessing at their shape is how a user's entry gets
|
|
149
|
+
* deleted.
|
|
150
|
+
*/
|
|
151
|
+
export declare function mergeTemplateOwnedHooks(onDisk: ReadonlyArray<unknown>, template: ReadonlyArray<unknown>): unknown[];
|
|
104
152
|
/**
|
|
105
153
|
* Absolute path of the shipped Write|Edit|MultiEdit gate script.
|
|
106
154
|
*
|
|
@@ -86,15 +86,35 @@ export const CLAUDE_SETTINGS_LOCAL_FILENAME = '.claude/settings.local.json';
|
|
|
86
86
|
*/
|
|
87
87
|
export const TEMPLATE_VERSION = '1.7.0';
|
|
88
88
|
/**
|
|
89
|
-
* Compare two serialized template strings
|
|
90
|
-
*
|
|
89
|
+
* Compare two serialized template strings: does the on-disk file already
|
|
90
|
+
* declare every entry the generated tree declares?
|
|
91
91
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
92
|
+
* OWNERSHIP IS PER ENTRY, NOT PER KEY (rid 2026-09-13-two-decisions item ②).
|
|
93
|
+
* This comparator answers "is each entry the GENERATED tree declares present
|
|
94
|
+
* on disk?", NOT "are the two `hooks` trees identical". Extra on-disk entries
|
|
95
|
+
* are IGNORED, so an entry another writer put in this file never makes it look
|
|
96
|
+
* drifted.
|
|
97
|
+
*
|
|
98
|
+
* That is the deliberate other half of the entry-level merge in
|
|
99
|
+
* `mergeTemplateOwnedHooks` / `workspace-claude-settings-materializer.ts`.
|
|
100
|
+
* `.claude/settings.local.json` has a SECOND writer of `hooks.PreToolUse`:
|
|
101
|
+
* `installAutoCompactHook` appends a `Bash|Task` entry. Under the previous
|
|
102
|
+
* exact-tree rule the merged file carried 4 entries against a 3-entry
|
|
103
|
+
* generated tree, so every `peaks workspace init` answered "drifted",
|
|
104
|
+
* rewrote, and reported `refreshed` forever — precisely the state whole-key
|
|
105
|
+
* ownership existed to prevent, and the reason the merge could not ship alone.
|
|
106
|
+
*
|
|
107
|
+
* Matching is order-insensitive AND multiset-aware: the template declares TWO
|
|
108
|
+
* `Bash` entries, and each must have its own counterpart on disk, so a file
|
|
109
|
+
* carrying only one of them is still reported as drifted (the previous
|
|
110
|
+
* index-by-index loop had the same property; it is load-bearing, not a
|
|
111
|
+
* detail).
|
|
112
|
+
*
|
|
113
|
+
* Returns `true` iff both strings parse to objects whose `hooks.PreToolUse`
|
|
114
|
+
* arrays satisfy that containment AND the on-disk `env` already carries every
|
|
115
|
+
* exemption the template declares (extra on-disk keys and extra globs are
|
|
116
|
+
* allowed — a user may exempt other trees, and a requirement the file already
|
|
117
|
+
* exceeds must not re-trigger a write).
|
|
98
118
|
*
|
|
99
119
|
* Returns `false` on any `JSON.parse` error, shape mismatch, or
|
|
100
120
|
* missing `hooks.PreToolUse`. Whitespace and key order do NOT affect
|
|
@@ -121,20 +141,15 @@ export function templateContentMatches(generated, onDisk) {
|
|
|
121
141
|
if (!isTemplateShape(parsedGenerated) || !isTemplateShape(parsedOnDisk)) {
|
|
122
142
|
return false;
|
|
123
143
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const a = generatedEntries[i];
|
|
131
|
-
const b = onDiskEntries[i];
|
|
132
|
-
if (a.matcher !== b.matcher) {
|
|
133
|
-
return false;
|
|
134
|
-
}
|
|
135
|
-
if (!sameHooksArray(a.hooks, b.hooks)) {
|
|
144
|
+
// Multiset containment: consume one on-disk entry per generated entry so a
|
|
145
|
+
// file holding a single copy of a doubly-declared entry still fails.
|
|
146
|
+
const unmatched = [...parsedOnDisk.hooks.PreToolUse];
|
|
147
|
+
for (const required of parsedGenerated.hooks.PreToolUse) {
|
|
148
|
+
const at = unmatched.findIndex((candidate) => sameEntry(required, candidate));
|
|
149
|
+
if (at === -1) {
|
|
136
150
|
return false;
|
|
137
151
|
}
|
|
152
|
+
unmatched.splice(at, 1);
|
|
138
153
|
}
|
|
139
154
|
// A project installed by a release that predates a template-declared
|
|
140
155
|
// exemption still needs the refresh this comparator gates — otherwise the
|
|
@@ -143,6 +158,69 @@ export function templateContentMatches(generated, onDisk) {
|
|
|
143
158
|
// uses, so the two writers cannot drift apart.
|
|
144
159
|
return hasExternalGateExemptions({ env: parsedOnDisk.env });
|
|
145
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Merge the on-disk `hooks.PreToolUse` list with the template's.
|
|
163
|
+
*
|
|
164
|
+
* THE OWNERSHIP RULE (rid 2026-09-13-two-decisions item ②): this template owns
|
|
165
|
+
* the entries IT DECLARES — and nothing else. Every other on-disk entry is
|
|
166
|
+
* carried across verbatim, whatever its matcher, because the template has no
|
|
167
|
+
* opinion about it:
|
|
168
|
+
*
|
|
169
|
+
* - a `matcher` the template does not declare (`Bash|Task`, the auto-compact
|
|
170
|
+
* hook `installAutoCompactHook` appends) is never touched;
|
|
171
|
+
* - surplus entries BEYOND the template's count for a declared matcher (a
|
|
172
|
+
* user's own `Bash` hook) are surplus too, and survive;
|
|
173
|
+
* - an on-disk entry that fills a declared slot is REPLACED by the template's
|
|
174
|
+
* entry for it. That is what makes a hand-edited (or older-release) entry
|
|
175
|
+
* self-heal instead of lingering next to a correct copy of itself.
|
|
176
|
+
*
|
|
177
|
+
* Slot counting is per `matcher` and positional within it: the template
|
|
178
|
+
* declares TWO `Bash` entries, so the first two on-disk `Bash` entries are
|
|
179
|
+
* theirs and a third is the user's. The template's entries are emitted first,
|
|
180
|
+
* in template order, then the preserved ones in their on-disk order — which is
|
|
181
|
+
* a fixed point: re-merging the result yields the result (the template's own
|
|
182
|
+
* entries are encountered first and refill their own slots).
|
|
183
|
+
*
|
|
184
|
+
* Non-conforming entries (no string `matcher`, no `hooks` array) are preserved
|
|
185
|
+
* rather than dropped: guessing at their shape is how a user's entry gets
|
|
186
|
+
* deleted.
|
|
187
|
+
*/
|
|
188
|
+
export function mergeTemplateOwnedHooks(onDisk, template) {
|
|
189
|
+
const slots = new Map();
|
|
190
|
+
for (const entry of template) {
|
|
191
|
+
if (!isPreToolUseEntry(entry))
|
|
192
|
+
continue;
|
|
193
|
+
slots.set(entry.matcher, (slots.get(entry.matcher) ?? 0) + 1);
|
|
194
|
+
}
|
|
195
|
+
const taken = new Map();
|
|
196
|
+
const preserved = [];
|
|
197
|
+
for (const entry of onDisk) {
|
|
198
|
+
// Unowned by construction: not a shape the template could have declared.
|
|
199
|
+
if (!isPreToolUseEntry(entry)) {
|
|
200
|
+
preserved.push(entry);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const declared = slots.get(entry.matcher) ?? 0;
|
|
204
|
+
const used = taken.get(entry.matcher) ?? 0;
|
|
205
|
+
if (used >= declared) {
|
|
206
|
+
preserved.push(entry);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
taken.set(entry.matcher, used + 1);
|
|
210
|
+
}
|
|
211
|
+
return [...template, ...preserved];
|
|
212
|
+
}
|
|
213
|
+
/** Structural equality of two `PreToolUse` entries. */
|
|
214
|
+
function sameEntry(a, b) {
|
|
215
|
+
return a.matcher === b.matcher && sameHooksArray(a.hooks, b.hooks);
|
|
216
|
+
}
|
|
217
|
+
function isPreToolUseEntry(value) {
|
|
218
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
const candidate = value;
|
|
222
|
+
return typeof candidate.matcher === 'string' && Array.isArray(candidate.hooks);
|
|
223
|
+
}
|
|
146
224
|
function isTemplateShape(value) {
|
|
147
225
|
if (typeof value !== 'object' || value === null) {
|
|
148
226
|
return false;
|
|
@@ -13,7 +13,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
13
13
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { withExternalGateExemptions } from '../skills/hooks-codegate-superpowers.js';
|
|
16
|
-
import { buildClaudeSettingsLocalJson, CLAUDE_SETTINGS_LOCAL_FILENAME, templateContentMatches } from './claude-settings-template.js';
|
|
16
|
+
import { buildClaudeSettingsLocalJson, CLAUDE_SETTINGS_LOCAL_FILENAME, mergeTemplateOwnedHooks, templateContentMatches } from './claude-settings-template.js';
|
|
17
17
|
/** Read a file as text, or `undefined` when it cannot be read. */
|
|
18
18
|
function readTextIfPresent(filePath) {
|
|
19
19
|
try {
|
|
@@ -48,12 +48,16 @@ function readEnvObject(serialized) {
|
|
|
48
48
|
* The top-level keys this function's template is allowed to DECIDE. Every other
|
|
49
49
|
* key on disk belongs to whoever put it there and is carried across verbatim.
|
|
50
50
|
*
|
|
51
|
-
* - `hooks` — the tree this function exists to keep in sync
|
|
52
|
-
*
|
|
51
|
+
* - `hooks` — the tree this function exists to keep in sync, ENTRY BY ENTRY.
|
|
52
|
+
* `peaks workspace init` converges a consumer's file on the current
|
|
53
53
|
* release's handler set, and `templateContentMatches` (the drift detector
|
|
54
|
-
* that decides whether to rewrite at all)
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* that decides whether to rewrite at all) asks whether every entry the
|
|
55
|
+
* generated tree declares is present — not whether the trees are equal.
|
|
56
|
+
* Letting the disk win outright would make the rewrite a no-op that reports
|
|
57
|
+
* `refreshed` forever AND would never deliver a changed handler; letting the
|
|
58
|
+
* template win outright is what deleted the auto-compact hook. See
|
|
59
|
+
* `mergeHooksTree` / `mergeTemplateOwnedHooks` for the rule that does
|
|
60
|
+
* neither.
|
|
57
61
|
* - `env` — jointly owned with `peaks hooks install`, which unions the user's
|
|
58
62
|
* exemption globs into it. Handled as a union below, not by either side
|
|
59
63
|
* winning outright.
|
|
@@ -62,6 +66,35 @@ function readEnvObject(serialized) {
|
|
|
62
66
|
* direction matters. A whitelist drops every key it was not told about — which
|
|
63
67
|
* is how `permissions` was lost — whereas anything absent from this list is
|
|
64
68
|
* preserved by default, including keys no release of peaks-loop knows about.
|
|
69
|
+
*
|
|
70
|
+
* ⚠️ `hooks` USED TO BE OWNED WHOLE — every entry in it was deleted by the next
|
|
71
|
+
* `peaks workspace init` unless the template declared it, silently:
|
|
72
|
+
* `templateContentMatches` saw the extra entry, answered "drifted", and the
|
|
73
|
+
* rewrite emitted `{...template}`. That was not hypothetical.
|
|
74
|
+
* `.claude/settings.local.json` has a second writer of peaks' OWN hooks:
|
|
75
|
+
* `installAutoCompactHook` (`src/services/hooks/auto-compact-hook-install.ts`),
|
|
76
|
+
* reached from `peaks code auto-compact` on an adapter declaring
|
|
77
|
+
* `compactPathway: 'ide-native'` — which `claude-code` does. Measured on a
|
|
78
|
+
* throwaway project root (rid 2026-09-13-two-decisions item ②):
|
|
79
|
+
*
|
|
80
|
+
* init (written, 3 PreToolUse entries: Write|Edit|MultiEdit, Bash, Bash)
|
|
81
|
+
* → installAutoCompactHook (installed, 4: … | Bash|Task)
|
|
82
|
+
* → init again (REFRESHED, 3: … ) ← the Bash|Task entry is gone
|
|
83
|
+
*
|
|
84
|
+
* and nothing re-installs it: the hook's whole job was to fire on the next
|
|
85
|
+
* Bash/Task call, so once it is deleted the auto-compact contract stops
|
|
86
|
+
* silently.
|
|
87
|
+
*
|
|
88
|
+
* FIXED 2026-09-13 (user-decided): this template now owns only the entries IT
|
|
89
|
+
* DECLARES. `mergeHooksTree` below unions the rest of the on-disk `hooks` tree
|
|
90
|
+
* across verbatim, and `templateContentMatches` — the drift detector — was
|
|
91
|
+
* changed in the same slice from "the trees are identical" to "every entry the
|
|
92
|
+
* generated tree declares is present". The two halves are one change: the merge
|
|
93
|
+
* alone would leave the comparator comparing a 3-entry generated tree against a
|
|
94
|
+
* 4-entry file on EVERY init and reporting `refreshed` forever, which is the
|
|
95
|
+
* state whole-key ownership existed to prevent. See
|
|
96
|
+
* `mergeTemplateOwnedHooks` in `claude-settings-template.ts` for the ownership
|
|
97
|
+
* rule and `templateContentMatches` for the containment rule it implies.
|
|
65
98
|
*/
|
|
66
99
|
const TEMPLATE_OWNED_KEYS = new Set(['hooks', 'env']);
|
|
67
100
|
/** `template`'s own keys, then every on-disk key the template does not own. */
|
|
@@ -72,8 +105,34 @@ function carryUserOwnedKeys(onDisk, template) {
|
|
|
72
105
|
continue;
|
|
73
106
|
merged[key] = value;
|
|
74
107
|
}
|
|
108
|
+
merged.hooks = mergeHooksTree(onDisk.hooks, template.hooks);
|
|
75
109
|
return merged;
|
|
76
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Merge the on-disk `hooks` tree with the template's, one event at a time.
|
|
113
|
+
*
|
|
114
|
+
* Only the events the template DECLARES are merged (and only their declared
|
|
115
|
+
* entries — see `mergeTemplateOwnedHooks`); every other event, and every other
|
|
116
|
+
* key under `hooks`, is carried across from the disk untouched. The template
|
|
117
|
+
* currently declares `PreToolUse` alone, so this is what keeps a hand-added or
|
|
118
|
+
* future-installer `SessionStart` entry in this file instead of deleting it —
|
|
119
|
+
* the latent half of the hazard the header describes.
|
|
120
|
+
*/
|
|
121
|
+
function mergeHooksTree(onDiskHooks, templateHooks) {
|
|
122
|
+
const onDisk = isPlainRecord(onDiskHooks) ? { ...onDiskHooks } : {};
|
|
123
|
+
const template = isPlainRecord(templateHooks) ? templateHooks : {};
|
|
124
|
+
for (const [event, declared] of Object.entries(template)) {
|
|
125
|
+
const current = onDisk[event];
|
|
126
|
+
onDisk[event] = Array.isArray(declared)
|
|
127
|
+
? mergeTemplateOwnedHooks(Array.isArray(current) ? current : [], declared)
|
|
128
|
+
: declared;
|
|
129
|
+
}
|
|
130
|
+
return onDisk;
|
|
131
|
+
}
|
|
132
|
+
/** A JSON object, as opposed to a null / array / primitive. */
|
|
133
|
+
function isPlainRecord(value) {
|
|
134
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
135
|
+
}
|
|
77
136
|
/**
|
|
78
137
|
* The peaks-managed snippet appended to the consumer project's
|
|
79
138
|
* `.peaks/.gitignore` so the local-only settings file never lands
|
|
@@ -213,6 +272,17 @@ export async function materializeClaudeSettingsLocal(projectRoot, noClaudeHooks)
|
|
|
213
272
|
* Returns the action taken so the caller can surface it in the
|
|
214
273
|
* envelope. Read failures are treated as drift so a malformed
|
|
215
274
|
* on-disk file always self-heals on the next init.
|
|
275
|
+
*
|
|
276
|
+
* WHAT IS COMPARED (rid 2026-09-13-two-decisions item ②): the copy is checked
|
|
277
|
+
* against the TEMPLATE'S OWN entries — `buildClaudeSettingsLocalJson()` — not
|
|
278
|
+
* against `serialized`, the merged LOCAL file content it is written from. This
|
|
279
|
+
* file is a copy of the template (its name and this doc both say so), so
|
|
280
|
+
* "is it current?" is a question about the template's entries only; asking it
|
|
281
|
+
* against the merged local file made the copy report `refreshed` once for every
|
|
282
|
+
* entry another writer had added to `.claude/settings.local.json` — drift noise
|
|
283
|
+
* about a file the copy does not own, on the very init that is supposed to be a
|
|
284
|
+
* no-op. With entry-containment semantics the copy is current as soon as it
|
|
285
|
+
* declares every template entry, whatever else it carries.
|
|
216
286
|
*/
|
|
217
287
|
async function writeOfflineTemplateCopy(projectRoot, serialized) {
|
|
218
288
|
const copyPath = join(projectRoot, '.peaks', '.claude-settings-template.json');
|
|
@@ -222,7 +292,8 @@ async function writeOfflineTemplateCopy(projectRoot, serialized) {
|
|
|
222
292
|
try {
|
|
223
293
|
const { readFile } = await import('node:fs/promises');
|
|
224
294
|
const existing = await readFile(copyPath, 'utf8');
|
|
225
|
-
|
|
295
|
+
const declared = JSON.stringify(buildClaudeSettingsLocalJson());
|
|
296
|
+
if (templateContentMatches(declared, existing)) {
|
|
226
297
|
action = 'already-current';
|
|
227
298
|
}
|
|
228
299
|
else {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one seam through which a `.peaks/_runtime` path is built.
|
|
3
|
+
*
|
|
4
|
+
* Slice 2026-09-15 (runtime-path-unrepresentable). Three attempts to *detect*
|
|
5
|
+
* a caller-supplied id reaching a runtime join all failed the same way: the
|
|
6
|
+
* shipped text rule caught 4 of 12 fixture shapes where the name-based
|
|
7
|
+
* predicate it replaced caught 8, and the version that reached 10 of 12 gave
|
|
8
|
+
* back the change that reached 12 because it cost seven findings on live code —
|
|
9
|
+
* two of them structural (a guard helper, and readers that take the guarded
|
|
10
|
+
* value as a parameter). Its measured verdict: reassignment between guard and
|
|
11
|
+
* join, and guard-after-join, are **domination failures inside a single
|
|
12
|
+
* function, invisible to any text key**.
|
|
13
|
+
*
|
|
14
|
+
* So the instrument is retired in favour of a property. The id cannot reach a
|
|
15
|
+
* runtime join unguarded because there is no longer a join that accepts an
|
|
16
|
+
* unguarded string: `RuntimeRoot.join` takes `GuardedSegment`, and a
|
|
17
|
+
* `GuardedSegment` can only be produced by `guardRuntimeSegment`, which throws
|
|
18
|
+
* on the shapes the escapes used. A newly written unguarded join is a type
|
|
19
|
+
* error at authoring time — not a removed guard that some later scan notices.
|
|
20
|
+
*
|
|
21
|
+
* The raw root is not obtainable as a string except through `dir()`, which is
|
|
22
|
+
* named so that a join written from it (`join(root.dir(), id)`) reads at review
|
|
23
|
+
* time as the bypass it is. `dir()` exists because callers legitimately
|
|
24
|
+
* enumerate the root itself; it is not a join.
|
|
25
|
+
*/
|
|
26
|
+
declare const RUNTIME_SEGMENT: unique symbol;
|
|
27
|
+
/**
|
|
28
|
+
* A path segment that has been checked as a single safe segment.
|
|
29
|
+
*
|
|
30
|
+
* Unforgeable by construction: the brand is a `unique symbol` that is never
|
|
31
|
+
* exported, so `value as GuardedSegment` outside this module is a type error
|
|
32
|
+
* and a plain `string` is not assignable. `guardRuntimeSegment` is the only
|
|
33
|
+
* producer.
|
|
34
|
+
*/
|
|
35
|
+
export type GuardedSegment = string & {
|
|
36
|
+
readonly [RUNTIME_SEGMENT]: true;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Check a caller-supplied string and brand it for use as a runtime path
|
|
40
|
+
* segment. `label` names the id in the error the way the caller knows it
|
|
41
|
+
* ("session id", "project id"), because the throw site is one function away
|
|
42
|
+
* from the caller that supplied it.
|
|
43
|
+
*
|
|
44
|
+
* Rejects exactly the shapes `isUnsafePathInput` rejects: separators, `..`,
|
|
45
|
+
* absolute and drive-prefixed paths, UNC and URL shapes, and empty segments.
|
|
46
|
+
*/
|
|
47
|
+
export declare function guardRuntimeSegment(value: string, label: string): GuardedSegment;
|
|
48
|
+
/**
|
|
49
|
+
* The `.peaks/_runtime` root of one project, as a capability rather than a
|
|
50
|
+
* string. `#path` is a private field, so the raw root cannot be read off the
|
|
51
|
+
* object and joined by an unguarded `join()`.
|
|
52
|
+
*/
|
|
53
|
+
export declare class RuntimeRoot {
|
|
54
|
+
#private;
|
|
55
|
+
private constructor();
|
|
56
|
+
/** The runtime root of `projectRoot`. */
|
|
57
|
+
static at(projectRoot: string): RuntimeRoot;
|
|
58
|
+
/**
|
|
59
|
+
* Join guarded segments onto the root. At least one segment is required: a
|
|
60
|
+
* zero-argument `join()` would hand back the bare root as a `string`, which
|
|
61
|
+
* is the capability this class exists to withhold.
|
|
62
|
+
*/
|
|
63
|
+
join(first: GuardedSegment, ...rest: readonly GuardedSegment[]): string;
|
|
64
|
+
/**
|
|
65
|
+
* The root itself, for READ-only enumeration (`readdir`, `existsSync`) — not
|
|
66
|
+
* for joining. Deliberately a method rather than a property so a bypass is
|
|
67
|
+
* legible at the call site.
|
|
68
|
+
*/
|
|
69
|
+
dir(): string;
|
|
70
|
+
}
|
|
71
|
+
/** The runtime root of `projectRoot`. */
|
|
72
|
+
export declare function runtimeRoot(projectRoot: string): RuntimeRoot;
|
|
73
|
+
export {};
|