peaks-cli 1.3.7 → 1.3.9
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/dist/src/cli/commands/core-artifact-commands.js +119 -14
- package/dist/src/cli/commands/project-commands.js +58 -1
- package/dist/src/cli/commands/request-commands.js +124 -4
- package/dist/src/cli/commands/retrospective-commands.d.ts +3 -0
- package/dist/src/cli/commands/retrospective-commands.js +113 -0
- package/dist/src/cli/program.js +2 -0
- package/dist/src/services/artifacts/request-artifact-service.d.ts +16 -0
- package/dist/src/services/artifacts/request-artifact-service.js +18 -2
- package/dist/src/services/memory/project-memory-service.d.ts +19 -0
- package/dist/src/services/memory/project-memory-service.js +33 -0
- package/dist/src/services/retrospective/migrate-from-md.d.ts +37 -0
- package/dist/src/services/retrospective/migrate-from-md.js +528 -0
- package/dist/src/services/retrospective/retrospective-index.d.ts +37 -0
- package/dist/src/services/retrospective/retrospective-index.js +110 -0
- package/dist/src/services/retrospective/retrospective-show.d.ts +40 -0
- package/dist/src/services/retrospective/retrospective-show.js +109 -0
- package/dist/src/services/session/caller-binding-service.d.ts +70 -0
- package/dist/src/services/session/caller-binding-service.js +148 -0
- package/dist/src/services/session/caller-id-types.d.ts +77 -0
- package/dist/src/services/session/caller-id-types.js +46 -0
- package/dist/src/services/session/index.d.ts +4 -0
- package/dist/src/services/session/index.js +5 -0
- package/dist/src/services/session/platform-fallbacks.d.ts +31 -0
- package/dist/src/services/session/platform-fallbacks.js +35 -0
- package/dist/src/services/session/resolve-caller-id.d.ts +57 -0
- package/dist/src/services/session/resolve-caller-id.js +88 -0
- package/dist/src/services/skills/skill-presence-service.d.ts +11 -0
- package/dist/src/services/skills/skill-presence-service.js +59 -0
- package/dist/src/shared/format-md-compact.d.ts +32 -0
- package/dist/src/shared/format-md-compact.js +297 -0
- package/dist/src/shared/stale-policy.d.ts +67 -0
- package/dist/src/shared/stale-policy.js +85 -0
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/skills/peaks-qa/SKILL.md +86 -515
- package/skills/peaks-qa/references/artifact-per-request.md +7 -79
- package/skills/peaks-qa/references/browser-validation-contracts.md +51 -0
- package/skills/peaks-qa/references/codegraph-regression-focus.md +5 -0
- package/skills/peaks-qa/references/external-capability-guidance.md +9 -0
- package/skills/peaks-qa/references/qa-compact-handoff.md +3 -0
- package/skills/peaks-qa/references/qa-context-governance.md +24 -0
- package/skills/peaks-qa/references/qa-fanout-contract.md +8 -0
- package/skills/peaks-qa/references/qa-gstack-integration.md +7 -0
- package/skills/peaks-qa/references/qa-local-artifacts.md +3 -0
- package/skills/peaks-qa/references/qa-matt-pocock-integration.md +9 -0
- package/skills/peaks-qa/references/qa-refactor-role.md +3 -0
- package/skills/peaks-qa/references/qa-runbook.md +74 -0
- package/skills/peaks-qa/references/qa-skill-presence.md +22 -0
- package/skills/peaks-qa/references/qa-standards-preflight.md +8 -0
- package/skills/peaks-qa/references/qa-sub-agent-dispatch.md +38 -0
- package/skills/peaks-qa/references/qa-transition-gates.md +79 -0
- package/skills/peaks-qa/references/requirement-boundary-recheck.md +9 -0
- package/skills/peaks-qa/references/test-case-generation.md +27 -0
- package/skills/peaks-qa/references/test-report-output.md +14 -0
- package/skills/peaks-rd/SKILL.md +85 -732
- package/skills/peaks-rd/references/artifact-and-standards-output.md +9 -0
- package/skills/peaks-rd/references/artifact-per-request.md +20 -0
- package/skills/peaks-rd/references/browser-self-test-contracts.md +29 -0
- package/skills/peaks-rd/references/codegraph-project-analysis.md +5 -0
- package/skills/peaks-rd/references/compact-handoff.md +3 -0
- package/skills/peaks-rd/references/external-references.md +11 -0
- package/skills/peaks-rd/references/frontend-project-generation.md +11 -0
- package/skills/peaks-rd/references/library-version-awareness.md +30 -0
- package/skills/peaks-rd/references/mandatory-perf-baseline.md +40 -0
- package/skills/peaks-rd/references/mandatory-tech-doc.md +18 -0
- package/skills/peaks-rd/references/matt-pocock-integration.md +11 -0
- package/skills/peaks-rd/references/mock-data-placement.md +40 -0
- package/skills/peaks-rd/references/parallel-review-fanout.md +81 -0
- package/skills/peaks-rd/references/rd-context-governance.md +36 -0
- package/skills/peaks-rd/references/rd-gstack-integration.md +16 -0
- package/skills/peaks-rd/references/rd-runbook.md +125 -0
- package/skills/peaks-rd/references/rd-standards-preflight.md +8 -0
- package/skills/peaks-rd/references/rd-sub-agent-dispatch.md +39 -0
- package/skills/peaks-rd/references/rd-transition-gates.md +148 -0
- package/skills/peaks-rd/references/skill-presence-and-title.md +22 -0
- package/skills/peaks-solo/SKILL.md +87 -786
- package/skills/peaks-solo/references/anchoring-and-session-info.md +25 -0
- package/skills/peaks-solo/references/boundaries.md +21 -0
- package/skills/peaks-solo/references/codegraph-orchestration.md +5 -0
- package/skills/peaks-solo/references/completion-handoff.md +16 -0
- package/skills/peaks-solo/references/context-governance.md +51 -0
- package/skills/peaks-solo/references/external-references.md +17 -0
- package/skills/peaks-solo/references/frontend-only-mode.md +87 -0
- package/skills/peaks-solo/references/gstack-integration.md +7 -0
- package/skills/peaks-solo/references/local-artifact-workspace.md +79 -0
- package/skills/peaks-solo/references/micro-cycle.md +68 -0
- package/skills/peaks-solo/references/mode-selection.md +21 -0
- package/skills/peaks-solo/references/openspec-workflow.md +43 -0
- package/skills/peaks-solo/references/project-memory-loading.md +17 -0
- package/skills/peaks-solo/references/project-scan-checklist.md +136 -0
- package/skills/peaks-solo/references/quality-gate-cheatsheet.md +13 -0
- package/skills/peaks-solo/references/resume-detection.md +63 -0
- package/skills/peaks-solo/references/runbook.md +1 -1
- package/skills/peaks-solo/references/skill-presence-and-title.md +31 -0
- package/skills/peaks-solo/references/standards-preflight.md +23 -0
- package/skills/peaks-solo/references/sub-agent-dispatch.md +46 -0
- package/skills/peaks-solo/references/swarm-dispatch-contract.md +56 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retrospective-index — load `.peaks/retrospective/index.json`, parse to
|
|
3
|
+
* `RetrospectiveEntry[]`, return the index envelope.
|
|
4
|
+
*
|
|
5
|
+
* Slice 023 (R3). Pure read on the hot path: a single `fs.readFile` of
|
|
6
|
+
* the index, no MD-tree fallback. The migration script (G9) is the only
|
|
7
|
+
* writer; this loader is read-only.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { join, resolve } from 'node:path';
|
|
11
|
+
const VALID_TYPES = new Set(['refactor', 'feature', 'bugfix', 'config', 'docs', 'chore']);
|
|
12
|
+
const VALID_OUTCOMES = new Set(['shipped', 'blocked', 'in-flight', 'cancelled']);
|
|
13
|
+
export function loadRetrospectiveIndex(projectRoot) {
|
|
14
|
+
const resolvedRoot = resolve(projectRoot);
|
|
15
|
+
const indexPath = join(resolvedRoot, '.peaks', 'retrospective', 'index.json');
|
|
16
|
+
if (!existsSync(indexPath)) {
|
|
17
|
+
return {
|
|
18
|
+
projectRoot: resolvedRoot,
|
|
19
|
+
indexPath,
|
|
20
|
+
entries: [],
|
|
21
|
+
totalCount: 0,
|
|
22
|
+
source: null,
|
|
23
|
+
warning: 'no retrospective index; run `peaks retrospective migrate --apply` to build one from legacy MDs'
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = readFileSync(indexPath, 'utf8');
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return {
|
|
32
|
+
projectRoot: resolvedRoot,
|
|
33
|
+
indexPath,
|
|
34
|
+
entries: [],
|
|
35
|
+
totalCount: 0,
|
|
36
|
+
source: null,
|
|
37
|
+
warning: `failed to read retrospective index at ${indexPath}`
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return {
|
|
46
|
+
projectRoot: resolvedRoot,
|
|
47
|
+
indexPath,
|
|
48
|
+
entries: [],
|
|
49
|
+
totalCount: 0,
|
|
50
|
+
source: null,
|
|
51
|
+
warning: `retrospective index at ${indexPath} is not valid JSON`
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const entries = extractEntries(parsed);
|
|
55
|
+
const sorted = [...entries].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
56
|
+
return {
|
|
57
|
+
projectRoot: resolvedRoot,
|
|
58
|
+
indexPath,
|
|
59
|
+
entries: sorted,
|
|
60
|
+
totalCount: sorted.length,
|
|
61
|
+
source: 'index.json',
|
|
62
|
+
warning: null
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function extractEntries(parsed) {
|
|
66
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
67
|
+
return [];
|
|
68
|
+
const obj = parsed;
|
|
69
|
+
if (!Array.isArray(obj.entries))
|
|
70
|
+
return [];
|
|
71
|
+
const result = [];
|
|
72
|
+
for (const candidate of obj.entries) {
|
|
73
|
+
if (!isRetrospectiveEntry(candidate))
|
|
74
|
+
continue;
|
|
75
|
+
result.push(candidate);
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
function isRetrospectiveEntry(value) {
|
|
80
|
+
if (value === null || typeof value !== 'object')
|
|
81
|
+
return false;
|
|
82
|
+
const v = value;
|
|
83
|
+
if (typeof v.id !== 'string' || v.id.length === 0)
|
|
84
|
+
return false;
|
|
85
|
+
if (typeof v.sessionId !== 'string')
|
|
86
|
+
return false;
|
|
87
|
+
if (typeof v.type !== 'string' || !VALID_TYPES.has(v.type))
|
|
88
|
+
return false;
|
|
89
|
+
if (typeof v.title !== 'string')
|
|
90
|
+
return false;
|
|
91
|
+
if (typeof v.summary !== 'string')
|
|
92
|
+
return false;
|
|
93
|
+
if (typeof v.outcome !== 'string' || !VALID_OUTCOMES.has(v.outcome))
|
|
94
|
+
return false;
|
|
95
|
+
if (!Array.isArray(v.keyDecisions))
|
|
96
|
+
return false;
|
|
97
|
+
if (!v.keyDecisions.every((decision) => typeof decision === 'string'))
|
|
98
|
+
return false;
|
|
99
|
+
if (typeof v.lessonsLearned !== 'number' || !Number.isInteger(v.lessonsLearned) || v.lessonsLearned < 0)
|
|
100
|
+
return false;
|
|
101
|
+
if (!Array.isArray(v.artifactPaths))
|
|
102
|
+
return false;
|
|
103
|
+
if (!v.artifactPaths.every((p) => typeof p === 'string'))
|
|
104
|
+
return false;
|
|
105
|
+
if (typeof v.updatedAt !== 'string')
|
|
106
|
+
return false;
|
|
107
|
+
if (v.sliceId !== undefined && typeof v.sliceId !== 'string')
|
|
108
|
+
return false;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retrospective-show — load one retrospective entry by id, synthesize the
|
|
3
|
+
* body on-demand from `artifactPaths` (concatenate with `---` separator),
|
|
4
|
+
* apply `formatMdCompact` by default, return the JSON envelope.
|
|
5
|
+
*
|
|
6
|
+
* Slice 023 (R3). The on-disk MD form is gone after the G9 migration; the
|
|
7
|
+
* body is re-hydrated from the source PRD / RD / QA / TXT artifacts. If
|
|
8
|
+
* a referenced artifact is missing on disk, `show` returns a
|
|
9
|
+
* `ARTIFACT_MISSING` envelope (PRD R3) and does not crash.
|
|
10
|
+
*
|
|
11
|
+
* Stale policy is **not** applied to retrospective in this slice
|
|
12
|
+
* (per PRD G7 / R3 scope). The helper exists for a future slice.
|
|
13
|
+
*/
|
|
14
|
+
import { loadRetrospectiveIndex, type RetrospectiveEntry, type RetrospectiveIndexResult } from './retrospective-index.js';
|
|
15
|
+
export type RetrospectiveFormat = 'compact' | 'pretty';
|
|
16
|
+
export interface RetrospectiveShowOptions {
|
|
17
|
+
projectRoot: string;
|
|
18
|
+
id: string;
|
|
19
|
+
format?: RetrospectiveFormat;
|
|
20
|
+
}
|
|
21
|
+
export interface RetrospectiveShowSuccess {
|
|
22
|
+
ok: true;
|
|
23
|
+
projectRoot: string;
|
|
24
|
+
format: RetrospectiveFormat;
|
|
25
|
+
entry: RetrospectiveEntry;
|
|
26
|
+
body: string;
|
|
27
|
+
warnings: string[];
|
|
28
|
+
}
|
|
29
|
+
export interface RetrospectiveShowError {
|
|
30
|
+
ok: false;
|
|
31
|
+
code: 'NOT_FOUND' | 'INDEX_MISSING' | 'ARTIFACT_MISSING' | 'INVALID_REQUEST';
|
|
32
|
+
message: string;
|
|
33
|
+
hint?: string;
|
|
34
|
+
projectRoot: string;
|
|
35
|
+
missingArtifacts?: string[];
|
|
36
|
+
}
|
|
37
|
+
export type RetrospectiveShowResult = RetrospectiveShowSuccess | RetrospectiveShowError;
|
|
38
|
+
export declare function showRetrospective(options: RetrospectiveShowOptions): RetrospectiveShowResult;
|
|
39
|
+
export { loadRetrospectiveIndex };
|
|
40
|
+
export type { RetrospectiveEntry, RetrospectiveIndexResult };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retrospective-show — load one retrospective entry by id, synthesize the
|
|
3
|
+
* body on-demand from `artifactPaths` (concatenate with `---` separator),
|
|
4
|
+
* apply `formatMdCompact` by default, return the JSON envelope.
|
|
5
|
+
*
|
|
6
|
+
* Slice 023 (R3). The on-disk MD form is gone after the G9 migration; the
|
|
7
|
+
* body is re-hydrated from the source PRD / RD / QA / TXT artifacts. If
|
|
8
|
+
* a referenced artifact is missing on disk, `show` returns a
|
|
9
|
+
* `ARTIFACT_MISSING` envelope (PRD R3) and does not crash.
|
|
10
|
+
*
|
|
11
|
+
* Stale policy is **not** applied to retrospective in this slice
|
|
12
|
+
* (per PRD G7 / R3 scope). The helper exists for a future slice.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import { formatMdCompact } from '../../shared/format-md-compact.js';
|
|
17
|
+
import { loadRetrospectiveIndex } from './retrospective-index.js';
|
|
18
|
+
export function showRetrospective(options) {
|
|
19
|
+
if (typeof options.id !== 'string' || options.id.trim().length === 0) {
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
code: 'INVALID_REQUEST',
|
|
23
|
+
message: 'retrospective show requires a non-empty <id> argument',
|
|
24
|
+
projectRoot: resolve(options.projectRoot)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const resolvedRoot = resolve(options.projectRoot);
|
|
28
|
+
const index = loadRetrospectiveIndex(resolvedRoot);
|
|
29
|
+
if (index.source === null) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
code: 'INDEX_MISSING',
|
|
33
|
+
message: index.warning ?? `retrospective index not found at ${index.indexPath}`,
|
|
34
|
+
hint: 'run `peaks retrospective migrate --apply` to build the index',
|
|
35
|
+
projectRoot: resolvedRoot
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const entry = index.entries.find((e) => e.id === options.id);
|
|
39
|
+
if (entry === undefined) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
code: 'NOT_FOUND',
|
|
43
|
+
message: `retrospective entry not found: ${options.id}`,
|
|
44
|
+
hint: 'run `peaks retrospective index --json` to see available ids',
|
|
45
|
+
projectRoot: resolvedRoot
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const format = options.format ?? 'compact';
|
|
49
|
+
const synthesis = synthesizeBody(entry, resolvedRoot);
|
|
50
|
+
const body = format === 'pretty' ? synthesis.body : formatMdCompact(synthesis.body);
|
|
51
|
+
const warnings = synthesis.warnings;
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
projectRoot: resolvedRoot,
|
|
55
|
+
format,
|
|
56
|
+
entry,
|
|
57
|
+
body,
|
|
58
|
+
warnings
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function synthesizeBody(entry, projectRoot) {
|
|
62
|
+
if (entry.artifactPaths.length === 0) {
|
|
63
|
+
return { body: renderEntryHeader(entry), warnings: ['entry has no artifactPaths; body is the index summary only'] };
|
|
64
|
+
}
|
|
65
|
+
const sections = [];
|
|
66
|
+
const warnings = [];
|
|
67
|
+
for (const relativePath of entry.artifactPaths) {
|
|
68
|
+
const absolutePath = join(projectRoot, relativePath);
|
|
69
|
+
if (!existsSync(absolutePath)) {
|
|
70
|
+
warnings.push(`artifact missing on disk: ${relativePath}`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const content = readFileSync(absolutePath, 'utf8');
|
|
75
|
+
sections.push(`## ${relativePath}\n\n${content}`);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
79
|
+
warnings.push(`failed to read ${relativePath}: ${message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const header = renderEntryHeader(entry);
|
|
83
|
+
const body = sections.length === 0
|
|
84
|
+
? `${header}\n\n_No artifacts available; only the index summary is shown._`
|
|
85
|
+
: `${header}\n\n${sections.join('\n\n---\n\n')}`;
|
|
86
|
+
return { body, warnings };
|
|
87
|
+
}
|
|
88
|
+
function renderEntryHeader(entry) {
|
|
89
|
+
const lines = [
|
|
90
|
+
`# ${entry.title}`,
|
|
91
|
+
'',
|
|
92
|
+
`- id: ${entry.id}`,
|
|
93
|
+
`- session: ${entry.sessionId}`,
|
|
94
|
+
...(entry.sliceId !== undefined ? [`- slice: ${entry.sliceId}`] : []),
|
|
95
|
+
`- type: ${entry.type}`,
|
|
96
|
+
`- outcome: ${entry.outcome}`,
|
|
97
|
+
`- updatedAt: ${entry.updatedAt}`,
|
|
98
|
+
`- lessonsLearned: ${entry.lessonsLearned}`,
|
|
99
|
+
''
|
|
100
|
+
];
|
|
101
|
+
if (entry.keyDecisions.length > 0) {
|
|
102
|
+
lines.push('## Key Decisions', '', ...entry.keyDecisions.map((decision) => `- ${decision}`), '');
|
|
103
|
+
}
|
|
104
|
+
if (entry.summary.length > 0) {
|
|
105
|
+
lines.push('## Summary', '', entry.summary, '');
|
|
106
|
+
}
|
|
107
|
+
return lines.join('\n');
|
|
108
|
+
}
|
|
109
|
+
export { loadRetrospectiveIndex };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller-Binding Service (slice 020 — caller-keyed session binding).
|
|
3
|
+
*
|
|
4
|
+
* Each caller has its own on-disk binding file at
|
|
5
|
+
* `.peaks/_runtime/callers/<callerId>.json`. This service is the
|
|
6
|
+
* single read/write surface for that file. Legacy single-file bindings
|
|
7
|
+
* (`.peaks/_runtime/session.json` and `.peaks/.session.json`) remain
|
|
8
|
+
* readable for one minor release (M1 / M4); the read path falls back
|
|
9
|
+
* to them with a `legacy-fallback-used: true` flag.
|
|
10
|
+
*
|
|
11
|
+
* M2: legacy bindings resolve into a synthetic callerId of the form
|
|
12
|
+
* `legacy-<8hex-of-sha256(outerSessionId)>`, with `claudeSessionId`
|
|
13
|
+
* and `projectRoot` as fallback hash inputs. The synthetic id is
|
|
14
|
+
* permanent and recognisable by the `legacy-` prefix.
|
|
15
|
+
*
|
|
16
|
+
* See `.peaks/_runtime/2026-06-09-session-8bfe7d/prd/source/caller-id-contract.md`
|
|
17
|
+
* for the freeze-in contract.
|
|
18
|
+
*/
|
|
19
|
+
import { type CallerBinding } from './caller-id-types.js';
|
|
20
|
+
/**
|
|
21
|
+
* On-disk location of the per-caller binding file. P4 invariant:
|
|
22
|
+
* the `callers/<callerId>.json` lives at `.peaks/_runtime/callers/`,
|
|
23
|
+
* the same root as the per-peak session dirs.
|
|
24
|
+
*/
|
|
25
|
+
export declare function getCallerBindingFile(projectRoot: string, callerId: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* On-disk location of the per-(peak, caller) active-skill file. D6:
|
|
28
|
+
* one file per (peakSessionId, callerId) pair; two callers bound to
|
|
29
|
+
* the same peak session never clobber each other.
|
|
30
|
+
*/
|
|
31
|
+
export declare function getActiveSkillFileForCaller(projectRoot: string, peakSessionId: string, callerId: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a stable, deterministic callerId for a legacy single-file
|
|
34
|
+
* binding (M2). The hash input priority is:
|
|
35
|
+
*
|
|
36
|
+
* 1. `outerSessionId` (slice 018 stamped this on the per-peak
|
|
37
|
+
* session.json for sessions created after the slice shipped).
|
|
38
|
+
* 2. `claudeSessionId` (legacy field name on pre-018 presence
|
|
39
|
+
* files; honour the read side so v1.2.x data does not lose its
|
|
40
|
+
* binding).
|
|
41
|
+
* 3. `projectRoot` (truly anonymous case: pre-018 sessions that
|
|
42
|
+
* never recorded an outer / claude id).
|
|
43
|
+
*
|
|
44
|
+
* The synthetic id is `legacy-<first 8 hex chars of sha256(input)>`.
|
|
45
|
+
* 32 bits of entropy is enough for typical on-disk state
|
|
46
|
+
* (R1: <100 legacy peak sessions per project; the test asserts
|
|
47
|
+
* uniqueness across 1000 synthetic ids).
|
|
48
|
+
*/
|
|
49
|
+
export declare function synthesiseLegacyCallerId(input: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* Read a per-caller binding file. Returns `null` if the file does
|
|
52
|
+
* not exist, is malformed, or is for a different project (M1 back-compat
|
|
53
|
+
* read returns the legacy file but only after the per-caller file is
|
|
54
|
+
* absent).
|
|
55
|
+
*/
|
|
56
|
+
export declare function getCallerBinding(projectRoot: string, callerId: string): CallerBinding | null;
|
|
57
|
+
/**
|
|
58
|
+
* Write or update a per-caller binding file. The caller is responsible
|
|
59
|
+
* for the binding object (callerId must match the file stem, peakSessionId
|
|
60
|
+
* must be a valid session id, projectRoot is canonicalized). Idempotent:
|
|
61
|
+
* re-writing the same callerId overwrites the file.
|
|
62
|
+
*/
|
|
63
|
+
export declare function setCallerBinding(projectRoot: string, callerId: string, binding: CallerBinding): void;
|
|
64
|
+
/**
|
|
65
|
+
* Enumerate the per-caller binding files under
|
|
66
|
+
* `.peaks/_runtime/callers/`. Returns the parsed bindings plus the
|
|
67
|
+
* raw filenames (so callers can list orphan / legacy files without
|
|
68
|
+
* re-reading).
|
|
69
|
+
*/
|
|
70
|
+
export declare function listCallerBindings(projectRoot: string): CallerBinding[];
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller-Binding Service (slice 020 — caller-keyed session binding).
|
|
3
|
+
*
|
|
4
|
+
* Each caller has its own on-disk binding file at
|
|
5
|
+
* `.peaks/_runtime/callers/<callerId>.json`. This service is the
|
|
6
|
+
* single read/write surface for that file. Legacy single-file bindings
|
|
7
|
+
* (`.peaks/_runtime/session.json` and `.peaks/.session.json`) remain
|
|
8
|
+
* readable for one minor release (M1 / M4); the read path falls back
|
|
9
|
+
* to them with a `legacy-fallback-used: true` flag.
|
|
10
|
+
*
|
|
11
|
+
* M2: legacy bindings resolve into a synthetic callerId of the form
|
|
12
|
+
* `legacy-<8hex-of-sha256(outerSessionId)>`, with `claudeSessionId`
|
|
13
|
+
* and `projectRoot` as fallback hash inputs. The synthetic id is
|
|
14
|
+
* permanent and recognisable by the `legacy-` prefix.
|
|
15
|
+
*
|
|
16
|
+
* See `.peaks/_runtime/2026-06-09-session-8bfe7d/prd/source/caller-id-contract.md`
|
|
17
|
+
* for the freeze-in contract.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { dirname, join, resolve } from 'node:path';
|
|
22
|
+
import { CALLER_ID_REGEX } from './caller-id-types.js';
|
|
23
|
+
import { getSessionDir } from './getSessionDir.js';
|
|
24
|
+
/**
|
|
25
|
+
* On-disk location of the per-caller binding file. P4 invariant:
|
|
26
|
+
* the `callers/<callerId>.json` lives at `.peaks/_runtime/callers/`,
|
|
27
|
+
* the same root as the per-peak session dirs.
|
|
28
|
+
*/
|
|
29
|
+
export function getCallerBindingFile(projectRoot, callerId) {
|
|
30
|
+
if (!CALLER_ID_REGEX.test(callerId)) {
|
|
31
|
+
// Defensive: a malformed callerId should never make it to disk.
|
|
32
|
+
// The caller is expected to validate via `resolveCallerId` first.
|
|
33
|
+
throw new Error(`getCallerBindingFile: invalid callerId "${callerId}"`);
|
|
34
|
+
}
|
|
35
|
+
return join(projectRoot, '.peaks', '_runtime', 'callers', `${callerId}.json`);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* On-disk location of the per-(peak, caller) active-skill file. D6:
|
|
39
|
+
* one file per (peakSessionId, callerId) pair; two callers bound to
|
|
40
|
+
* the same peak session never clobber each other.
|
|
41
|
+
*/
|
|
42
|
+
export function getActiveSkillFileForCaller(projectRoot, peakSessionId, callerId) {
|
|
43
|
+
if (!CALLER_ID_REGEX.test(callerId)) {
|
|
44
|
+
throw new Error(`getActiveSkillFileForCaller: invalid callerId "${callerId}"`);
|
|
45
|
+
}
|
|
46
|
+
return join(getSessionDir(projectRoot, peakSessionId), `active-skill-${callerId}.json`);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Resolve a stable, deterministic callerId for a legacy single-file
|
|
50
|
+
* binding (M2). The hash input priority is:
|
|
51
|
+
*
|
|
52
|
+
* 1. `outerSessionId` (slice 018 stamped this on the per-peak
|
|
53
|
+
* session.json for sessions created after the slice shipped).
|
|
54
|
+
* 2. `claudeSessionId` (legacy field name on pre-018 presence
|
|
55
|
+
* files; honour the read side so v1.2.x data does not lose its
|
|
56
|
+
* binding).
|
|
57
|
+
* 3. `projectRoot` (truly anonymous case: pre-018 sessions that
|
|
58
|
+
* never recorded an outer / claude id).
|
|
59
|
+
*
|
|
60
|
+
* The synthetic id is `legacy-<first 8 hex chars of sha256(input)>`.
|
|
61
|
+
* 32 bits of entropy is enough for typical on-disk state
|
|
62
|
+
* (R1: <100 legacy peak sessions per project; the test asserts
|
|
63
|
+
* uniqueness across 1000 synthetic ids).
|
|
64
|
+
*/
|
|
65
|
+
export function synthesiseLegacyCallerId(input) {
|
|
66
|
+
const hash = createHash('sha256').update(input, 'utf8').digest('hex').slice(0, 8);
|
|
67
|
+
return `legacy-${hash}`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Read a per-caller binding file. Returns `null` if the file does
|
|
71
|
+
* not exist, is malformed, or is for a different project (M1 back-compat
|
|
72
|
+
* read returns the legacy file but only after the per-caller file is
|
|
73
|
+
* absent).
|
|
74
|
+
*/
|
|
75
|
+
export function getCallerBinding(projectRoot, callerId) {
|
|
76
|
+
const bindingPath = getCallerBindingFile(projectRoot, callerId);
|
|
77
|
+
if (!existsSync(bindingPath)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const raw = readFileSync(bindingPath, 'utf8');
|
|
82
|
+
const parsed = JSON.parse(raw);
|
|
83
|
+
if (typeof parsed.callerId !== 'string' || parsed.callerId !== callerId) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (typeof parsed.peakSessionId !== 'string' || parsed.peakSessionId.length === 0) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (typeof parsed.projectRoot !== 'string' || parsed.projectRoot.length === 0) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return parsed;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Write or update a per-caller binding file. The caller is responsible
|
|
100
|
+
* for the binding object (callerId must match the file stem, peakSessionId
|
|
101
|
+
* must be a valid session id, projectRoot is canonicalized). Idempotent:
|
|
102
|
+
* re-writing the same callerId overwrites the file.
|
|
103
|
+
*/
|
|
104
|
+
export function setCallerBinding(projectRoot, callerId, binding) {
|
|
105
|
+
if (binding.callerId !== callerId) {
|
|
106
|
+
throw new Error(`setCallerBinding: binding.callerId "${binding.callerId}" does not match callerId "${callerId}"`);
|
|
107
|
+
}
|
|
108
|
+
const bindingPath = getCallerBindingFile(projectRoot, callerId);
|
|
109
|
+
const dir = dirname(bindingPath);
|
|
110
|
+
if (!existsSync(dir)) {
|
|
111
|
+
mkdirSync(dir, { recursive: true });
|
|
112
|
+
}
|
|
113
|
+
const payload = {
|
|
114
|
+
...binding,
|
|
115
|
+
projectRoot: resolve(binding.projectRoot)
|
|
116
|
+
};
|
|
117
|
+
writeFileSync(bindingPath, JSON.stringify(payload, null, 2), 'utf8');
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Enumerate the per-caller binding files under
|
|
121
|
+
* `.peaks/_runtime/callers/`. Returns the parsed bindings plus the
|
|
122
|
+
* raw filenames (so callers can list orphan / legacy files without
|
|
123
|
+
* re-reading).
|
|
124
|
+
*/
|
|
125
|
+
export function listCallerBindings(projectRoot) {
|
|
126
|
+
const dir = join(projectRoot, '.peaks', '_runtime', 'callers');
|
|
127
|
+
if (!existsSync(dir))
|
|
128
|
+
return [];
|
|
129
|
+
let names;
|
|
130
|
+
try {
|
|
131
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
132
|
+
names = entries.filter((e) => e.isFile() && e.name.endsWith('.json')).map((e) => e.name);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const name of names) {
|
|
139
|
+
const callerId = name.replace(/\.json$/, '');
|
|
140
|
+
if (!CALLER_ID_REGEX.test(callerId))
|
|
141
|
+
continue;
|
|
142
|
+
const binding = getCallerBinding(projectRoot, callerId);
|
|
143
|
+
if (binding !== null) {
|
|
144
|
+
out.push(binding);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller-Id Resolution types (slice 020 — caller-keyed session binding).
|
|
3
|
+
*
|
|
4
|
+
* The single shared `.peaks/_runtime/session.json` and
|
|
5
|
+
* `.peaks/_runtime/active-skill.json` files are replaced with per-caller
|
|
6
|
+
* layouts: `.peaks/_runtime/callers/<callerId>.json` and
|
|
7
|
+
* `.peaks/_runtime/<peakSid>/active-skill-<callerId>.json`. The
|
|
8
|
+
* `callerId` is a generic identifier the calling platform declares
|
|
9
|
+
* itself (Claude Code via `CLAUDE_CODE_SESSION_ID`, future platforms
|
|
10
|
+
* via `PLATFORM_FALLBACKS`).
|
|
11
|
+
*
|
|
12
|
+
* See `.peaks/_runtime/2026-06-09-session-8bfe7d/prd/source/caller-id-contract.md`
|
|
13
|
+
* for the freeze-in contract (D1-D7 + M1-M5).
|
|
14
|
+
*/
|
|
15
|
+
export type CallerIdSource = 'flag' | 'env' | 'fallback' | 'none';
|
|
16
|
+
/**
|
|
17
|
+
* On-disk shape of `.peaks/_runtime/callers/<callerId>.json`. One file
|
|
18
|
+
* per caller; two callers may point to the same `peakSessionId` (D6).
|
|
19
|
+
*/
|
|
20
|
+
export interface CallerBinding {
|
|
21
|
+
/** Echo of the filename stem; matches D1 regex. */
|
|
22
|
+
callerId: string;
|
|
23
|
+
/** The peak session this caller is bound to. */
|
|
24
|
+
peakSessionId: string;
|
|
25
|
+
/** Absolute path to the project root, canonicalized. */
|
|
26
|
+
projectRoot: string;
|
|
27
|
+
/** ISO 8601 timestamp; stamped at first write. */
|
|
28
|
+
createdAt: string;
|
|
29
|
+
/** ISO 8601 timestamp; bumped on every `peaks <cmd>` that touches the binding. */
|
|
30
|
+
lastActivityAt: string;
|
|
31
|
+
/** Last skill that touched this binding, e.g. "peaks-solo". */
|
|
32
|
+
skill: string;
|
|
33
|
+
/** Last mode, e.g. "full-auto". */
|
|
34
|
+
mode: string;
|
|
35
|
+
/** Last gate, e.g. "startup". */
|
|
36
|
+
gate: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Per-(peakSessionId, callerId) presence record at
|
|
40
|
+
* `.peaks/_runtime/<peakSid>/active-skill-<callerId>.json`. Each caller
|
|
41
|
+
* has its own file (D6); two callers bound to the same peak session
|
|
42
|
+
* never clobber each other's presence.
|
|
43
|
+
*/
|
|
44
|
+
export interface CallerSkillPresence {
|
|
45
|
+
callerId: string;
|
|
46
|
+
skill: string;
|
|
47
|
+
mode?: string;
|
|
48
|
+
gate?: string;
|
|
49
|
+
setAt: string;
|
|
50
|
+
lastHeartbeat?: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* D1 callerId regex: ASCII letters, digits, dot, underscore, hyphen;
|
|
54
|
+
* 1-200 chars. Excludes path separators (Windows: `\`, Unix: `/`),
|
|
55
|
+
* NUL, control chars, whitespace, all other Unicode — callerId is
|
|
56
|
+
* embedded in a file path and must be portable across Windows / macOS
|
|
57
|
+
* / Linux.
|
|
58
|
+
*/
|
|
59
|
+
export declare const CALLER_ID_REGEX: RegExp;
|
|
60
|
+
/**
|
|
61
|
+
* Thrown by `resolveCallerId` for two cases:
|
|
62
|
+
*
|
|
63
|
+
* - `code: 'EX_USAGE'` (exit 64, D2): no callerId available
|
|
64
|
+
* anywhere (flag/env/fallback all empty).
|
|
65
|
+
* - `code: 'EX_DATAERR'` (exit 65, D5): resolved callerId does not
|
|
66
|
+
* match D1's regex.
|
|
67
|
+
*
|
|
68
|
+
* The `source` field tells the user where the bad id came from
|
|
69
|
+
* (`flag` / `env` / `fallback` / `none`) so the error message points
|
|
70
|
+
* at the right thing to fix.
|
|
71
|
+
*/
|
|
72
|
+
export declare class CallerIdError extends Error {
|
|
73
|
+
readonly code: 'EX_USAGE' | 'EX_DATAERR';
|
|
74
|
+
readonly source: CallerIdSource;
|
|
75
|
+
readonly value: string | undefined;
|
|
76
|
+
constructor(code: 'EX_USAGE' | 'EX_DATAERR', source: CallerIdSource, message: string, value?: string);
|
|
77
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller-Id Resolution types (slice 020 — caller-keyed session binding).
|
|
3
|
+
*
|
|
4
|
+
* The single shared `.peaks/_runtime/session.json` and
|
|
5
|
+
* `.peaks/_runtime/active-skill.json` files are replaced with per-caller
|
|
6
|
+
* layouts: `.peaks/_runtime/callers/<callerId>.json` and
|
|
7
|
+
* `.peaks/_runtime/<peakSid>/active-skill-<callerId>.json`. The
|
|
8
|
+
* `callerId` is a generic identifier the calling platform declares
|
|
9
|
+
* itself (Claude Code via `CLAUDE_CODE_SESSION_ID`, future platforms
|
|
10
|
+
* via `PLATFORM_FALLBACKS`).
|
|
11
|
+
*
|
|
12
|
+
* See `.peaks/_runtime/2026-06-09-session-8bfe7d/prd/source/caller-id-contract.md`
|
|
13
|
+
* for the freeze-in contract (D1-D7 + M1-M5).
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* D1 callerId regex: ASCII letters, digits, dot, underscore, hyphen;
|
|
17
|
+
* 1-200 chars. Excludes path separators (Windows: `\`, Unix: `/`),
|
|
18
|
+
* NUL, control chars, whitespace, all other Unicode — callerId is
|
|
19
|
+
* embedded in a file path and must be portable across Windows / macOS
|
|
20
|
+
* / Linux.
|
|
21
|
+
*/
|
|
22
|
+
export const CALLER_ID_REGEX = /^[a-zA-Z0-9._-]{1,200}$/;
|
|
23
|
+
/**
|
|
24
|
+
* Thrown by `resolveCallerId` for two cases:
|
|
25
|
+
*
|
|
26
|
+
* - `code: 'EX_USAGE'` (exit 64, D2): no callerId available
|
|
27
|
+
* anywhere (flag/env/fallback all empty).
|
|
28
|
+
* - `code: 'EX_DATAERR'` (exit 65, D5): resolved callerId does not
|
|
29
|
+
* match D1's regex.
|
|
30
|
+
*
|
|
31
|
+
* The `source` field tells the user where the bad id came from
|
|
32
|
+
* (`flag` / `env` / `fallback` / `none`) so the error message points
|
|
33
|
+
* at the right thing to fix.
|
|
34
|
+
*/
|
|
35
|
+
export class CallerIdError extends Error {
|
|
36
|
+
code;
|
|
37
|
+
source;
|
|
38
|
+
value;
|
|
39
|
+
constructor(code, source, message, value) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'CallerIdError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.source = source;
|
|
44
|
+
this.value = value;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, rotateSessionBinding, type SessionInfo, type SessionMeta } from './session-manager.js';
|
|
2
2
|
export { getSessionDir } from './getSessionDir.js';
|
|
3
|
+
export { resolveCallerId, type ResolveCallerIdOptions } from './resolve-caller-id.js';
|
|
4
|
+
export { getCallerBindingFile, getActiveSkillFileForCaller, synthesiseLegacyCallerId, getCallerBinding, setCallerBinding, listCallerBindings } from './caller-binding-service.js';
|
|
5
|
+
export { PLATFORM_FALLBACKS, type PlatformFallback } from './platform-fallbacks.js';
|
|
6
|
+
export { CALLER_ID_REGEX, CallerIdError, type CallerBinding, type CallerSkillPresence, type CallerIdSource } from './caller-id-types.js';
|
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, rotateSessionBinding } from './session-manager.js';
|
|
2
2
|
export { getSessionDir } from './getSessionDir.js';
|
|
3
|
+
// Slice 020 — caller-keyed session binding. The new canonical path.
|
|
4
|
+
export { resolveCallerId } from './resolve-caller-id.js';
|
|
5
|
+
export { getCallerBindingFile, getActiveSkillFileForCaller, synthesiseLegacyCallerId, getCallerBinding, setCallerBinding, listCallerBindings } from './caller-binding-service.js';
|
|
6
|
+
export { PLATFORM_FALLBACKS } from './platform-fallbacks.js';
|
|
7
|
+
export { CALLER_ID_REGEX, CallerIdError } from './caller-id-types.js';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PLATFORM_FALLBACKS — the Level 3 fallback table for caller-id resolution.
|
|
3
|
+
*
|
|
4
|
+
* Slice 020 (D3): when neither `--caller-id` nor `PEAKS_CALLER_ID` is
|
|
5
|
+
* set, the resolver walks this table top-to-bottom and takes the
|
|
6
|
+
* first non-empty entry. Today there is exactly one entry: Claude
|
|
7
|
+
* Code (`CLAUDE_CODE_SESSION_ID`).
|
|
8
|
+
*
|
|
9
|
+
* To add a new platform (Cursor, Windsurf, peaks-ide, etc.):
|
|
10
|
+
*
|
|
11
|
+
* 1. Add a new entry below.
|
|
12
|
+
* 2. Bump the contract doc's A5 acceptance criterion
|
|
13
|
+
* (`.peaks/_runtime/2026-06-09-session-8bfe7d/prd/source/caller-id-contract.md`).
|
|
14
|
+
* 3. Add a regression test that asserts the new entry resolves
|
|
15
|
+
* correctly under D4 priority.
|
|
16
|
+
*
|
|
17
|
+
* The contract's A5 test (`tests/unit/services/session/caller-id-resolution.test.ts`)
|
|
18
|
+
* asserts `PLATFORM_FALLBACKS.length === 1`; adding a new entry will
|
|
19
|
+
* fail that test, forcing the contract bump.
|
|
20
|
+
*
|
|
21
|
+
* Adding an entry does NOT require code changes to read points
|
|
22
|
+
* (statusline, doctor, sc, session-info) — they all call the same
|
|
23
|
+
* resolver. Each entry is a one-line additive change.
|
|
24
|
+
*/
|
|
25
|
+
export interface PlatformFallback {
|
|
26
|
+
readonly envVar: string;
|
|
27
|
+
readonly description: string;
|
|
28
|
+
/** Semver this entry was added in (e.g. "1.3.7"). */
|
|
29
|
+
readonly addedIn: string;
|
|
30
|
+
}
|
|
31
|
+
export declare const PLATFORM_FALLBACKS: ReadonlyArray<PlatformFallback>;
|