peaks-loop 4.0.0-beta.4 → 4.0.0-beta.6
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/.claude-plugin/marketplace.json +5 -0
- package/CHANGELOG.md +16 -0
- package/README-en.md +4 -0
- package/README.md +4 -0
- package/dist/src/cli/commands/adapter-commands-s2a.d.ts +34 -0
- package/dist/src/cli/commands/adapter-commands-s2a.js +92 -0
- package/dist/src/cli/commands/core/skill-command.js +22 -0
- package/dist/src/cli/commands/polyrepo-commands.d.ts +37 -0
- package/dist/src/cli/commands/polyrepo-commands.js +155 -0
- package/dist/src/cli/commands/runtime-commands.d.ts +52 -0
- package/dist/src/cli/commands/runtime-commands.js +150 -0
- package/dist/src/cli/commands/skill-search-commands.d.ts +25 -0
- package/dist/src/cli/commands/skill-search-commands.js +68 -0
- package/dist/src/cli/program.js +16 -0
- package/dist/src/services/adapter/adapter-registry.d.ts +48 -0
- package/dist/src/services/adapter/adapter-registry.js +186 -0
- package/dist/src/services/context/context-schema.d.ts +6 -6
- package/dist/src/services/polyrepo/polyrepo-dispatcher.d.ts +34 -0
- package/dist/src/services/polyrepo/polyrepo-dispatcher.js +119 -0
- package/dist/src/services/polyrepo/polyrepo-scanner.d.ts +9 -0
- package/dist/src/services/polyrepo/polyrepo-scanner.js +94 -0
- package/dist/src/services/polyrepo/polyrepo-service.d.ts +34 -0
- package/dist/src/services/polyrepo/polyrepo-service.js +49 -0
- package/dist/src/services/polyrepo/polyrepo-types.d.ts +55 -0
- package/dist/src/services/polyrepo/polyrepo-types.js +1 -0
- package/dist/src/services/runtime/runtime-detector.d.ts +15 -0
- package/dist/src/services/runtime/runtime-detector.js +62 -0
- package/dist/src/services/runtime/runtime-service.d.ts +59 -0
- package/dist/src/services/runtime/runtime-service.js +51 -0
- package/dist/src/services/runtime/vendor-adapter.d.ts +59 -0
- package/dist/src/services/runtime/vendor-adapter.js +1 -0
- package/dist/src/services/runtime/vendors/claude-code.d.ts +7 -0
- package/dist/src/services/runtime/vendors/claude-code.js +39 -0
- package/dist/src/services/runtime/vendors/codex.d.ts +7 -0
- package/dist/src/services/runtime/vendors/codex.js +43 -0
- package/dist/src/services/runtime/vendors/copilot.d.ts +7 -0
- package/dist/src/services/runtime/vendors/copilot.js +40 -0
- package/dist/src/services/skill/skill-search-service.d.ts +88 -0
- package/dist/src/services/skill/skill-search-service.js +228 -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-code/SKILL.md +1 -8
- package/skills/peaks-code/references/external-references.md +1 -3
- package/skills/peaks-code/references/external-skill-invocation.md +1 -1
- package/skills/peaks-code/references/runbook.md +1 -3
- package/skills/peaks-code/references/step-0-55-1x-detection.md +2 -1
- package/skills/peaks-code/references/workflow-gates-and-types.md +1 -1
- package/skills/peaks-solo/SKILL.md +197 -0
- package/skills/peaks-solo/references/fallback-tool-inventory.md +46 -0
- package/skills/peaks-solo/references/sediment-prompt-template.md +62 -0
- package/skills/peaks-solo/references/triage-decision-table.md +31 -0
- package/skills/peaks-code/references/openspec-workflow.md +0 -81
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { searchSkills, SkillSearchInputSchema } from '../../services/skill/skill-search-service.js';
|
|
2
|
+
import { getErrorMessage } from '../../shared/result.js';
|
|
3
|
+
/**
|
|
4
|
+
* Validate the raw CLI flags, call the service, and emit a JSON
|
|
5
|
+
* envelope on stdout. Errors flow through stderr + non-zero exit
|
|
6
|
+
* codes per the plan §"API Contract" table.
|
|
7
|
+
*/
|
|
8
|
+
export function registerSkillSearchCommand(program, io) {
|
|
9
|
+
// The parent `skill` command is created by registerSkillCommand in
|
|
10
|
+
// core/skill-command.ts. To keep the wiring self-contained we
|
|
11
|
+
// resolve the parent by name; this also lets tests register the
|
|
12
|
+
// subcommand in isolation.
|
|
13
|
+
const skill = program.commands.find((c) => c.name() === 'skill');
|
|
14
|
+
if (skill === undefined) {
|
|
15
|
+
// Defensive: the parent must be registered before us. Surface the
|
|
16
|
+
// error visibly — the dispatcher cannot triage without `peaks skill`
|
|
17
|
+
// existing, so the LLM-side caller needs to know.
|
|
18
|
+
throw new Error('peaks skill search: parent `skill` command not found. ' +
|
|
19
|
+
'Ensure registerSkillCommand is called before registerSkillSearchCommand.');
|
|
20
|
+
}
|
|
21
|
+
skill
|
|
22
|
+
.command('search')
|
|
23
|
+
.description('Search the in-tree skill pool by --query (substring on description + triggers) ' +
|
|
24
|
+
'and/or --tag (exact match on metadata.tags) and/or --domain (exact match). ' +
|
|
25
|
+
'Outputs a JSON array (always, even on no-match) to stdout. ' +
|
|
26
|
+
'See docs/superpowers/specs/2026-07-08-peaks-solo-dispatcher-design.md §3.2.')
|
|
27
|
+
.option('-q, --query <text>', 'case-insensitive substring matched against skill description + triggers')
|
|
28
|
+
.option('-t, --tag <tag>', 'exact match on metadata.tags (e.g. "orchestrator", "loop-engineering")')
|
|
29
|
+
.option('-d, --domain <domain>', 'exact match on metadata.domain (locked enum: code | content | doctor | research | triage | sop | audit | final-review | resume | status | test | ide | slice-decompose | issue-fix-orchestrator | perf-audit | security-audit | reviewer)')
|
|
30
|
+
.option('-l, --limit <n>', 'max results returned (1..100, default 20)', (v) => Number.parseInt(v, 10))
|
|
31
|
+
.action(async (options) => {
|
|
32
|
+
// 1) Coerce + validate input via the service's Zod schema.
|
|
33
|
+
// The schema's refine rejects empty input — that is the
|
|
34
|
+
// "all-empty → error" contract the spec mandates.
|
|
35
|
+
const raw = {};
|
|
36
|
+
if (options.query !== undefined)
|
|
37
|
+
raw['query'] = options.query;
|
|
38
|
+
if (options.tag !== undefined)
|
|
39
|
+
raw['tag'] = options.tag;
|
|
40
|
+
if (options.domain !== undefined)
|
|
41
|
+
raw['domain'] = options.domain;
|
|
42
|
+
if (options.limit !== undefined)
|
|
43
|
+
raw['limit'] = options.limit;
|
|
44
|
+
const parsed = SkillSearchInputSchema.safeParse(raw);
|
|
45
|
+
if (!parsed.success) {
|
|
46
|
+
// Exit code 1 = invalid args. Emit a human-readable message
|
|
47
|
+
// on stderr so the LLM-side caller can act on it; do NOT
|
|
48
|
+
// pollute stdout (which downstream tools parse as JSON).
|
|
49
|
+
for (const issue of parsed.error.issues) {
|
|
50
|
+
io.stderr(`${issue.path.join('.') || '<root>'}: ${issue.message}`);
|
|
51
|
+
}
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// 2) Call the service. Any unexpected error (skill pool read
|
|
56
|
+
// fail, etc.) maps to exit code 2.
|
|
57
|
+
try {
|
|
58
|
+
const results = await searchSkills(parsed.data);
|
|
59
|
+
// Always emit a JSON array on stdout. The plan locks this
|
|
60
|
+
// contract: even no-match returns `[]`, never `null`.
|
|
61
|
+
io.stdout(JSON.stringify(results));
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
io.stderr(`skill.search failed: ${getErrorMessage(error)}`);
|
|
65
|
+
process.exitCode = 2;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
package/dist/src/cli/program.js
CHANGED
|
@@ -85,6 +85,15 @@ import { registerJobCommands } from './commands/job-commands.js';
|
|
|
85
85
|
// 15b / 15c / 15d will extend sediment-commands.ts with the remaining 14 verbs.
|
|
86
86
|
import { registerSedimentCommands } from './commands/sediment-commands.js';
|
|
87
87
|
import { registerAdapterCommands } from './commands/adapter-commands.js';
|
|
88
|
+
// Slice RD-2 S2-a: `peaks runtime *` (detect / list / compact) +
|
|
89
|
+
// `peaks adapter *` (list / register). See src/services/runtime/ and
|
|
90
|
+
// src/services/adapter/adapter-registry.ts. Coexists with the older
|
|
91
|
+
// `peaks skill adapter` surface (slice 2026-07-04-cli-15a) which is
|
|
92
|
+
// for skill materialization, not vendor-adapter registry.
|
|
93
|
+
import { registerRuntimeCommands } from './commands/runtime-commands.js';
|
|
94
|
+
import { registerAdapterS2ACommands } from './commands/adapter-commands-s2a.js';
|
|
95
|
+
// Slice RD-2 S2-b: `peaks polyrepo *` (init / status / dispatch).
|
|
96
|
+
import { registerPolyrepoCommands } from './commands/polyrepo-commands.js';
|
|
88
97
|
import { registerSkillVisibilityCommand } from './commands/skill-visibility.js';
|
|
89
98
|
import { applyRetention } from '../services/log/retention.js';
|
|
90
99
|
import { writeLogEntry, maybeWriteStderr } from '../services/log/logger.js';
|
|
@@ -375,6 +384,13 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
|
|
|
375
384
|
registerSedimentCommands(program, io);
|
|
376
385
|
// Slice 2026-07-04-cli-15a: `peaks skill adapter <verb>` — adapter list / set-active.
|
|
377
386
|
registerAdapterCommands(program, io);
|
|
387
|
+
// Slice RD-2 S2-a: `peaks runtime *` (detect / list / compact) +
|
|
388
|
+
// `peaks adapter *` (list / register). Vendor verbs live ONLY in
|
|
389
|
+
// src/services/runtime/vendors/<vendor>.ts (AC-1 enforcement).
|
|
390
|
+
registerRuntimeCommands(program, io);
|
|
391
|
+
registerAdapterS2ACommands(program, io);
|
|
392
|
+
// Slice RD-2 S2-b: `peaks polyrepo *` (init / status / dispatch).
|
|
393
|
+
registerPolyrepoCommands(program, io);
|
|
378
394
|
// Slice 2026-07-05-peaks-code-to-peaks-code (Task 1): `peaks skill:visibility --list --json`
|
|
379
395
|
// — read .claude-plugin/marketplace.json and report each skill's
|
|
380
396
|
// visibility (public vs internal). Used to surface which skills are
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { VendorAdapter } from '../runtime/vendor-adapter.js';
|
|
2
|
+
/** Serializable adapter record (the on-disk shape). */
|
|
3
|
+
export interface AdapterRecord {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly displayName: string;
|
|
6
|
+
/** Binary name or absolute path to invoke for compact. */
|
|
7
|
+
readonly binary: string;
|
|
8
|
+
/** Extra args appended after the compact verb. */
|
|
9
|
+
readonly args?: string[];
|
|
10
|
+
}
|
|
11
|
+
export interface PersistedAdapterRegistry {
|
|
12
|
+
readonly version: 1;
|
|
13
|
+
readonly adapters: AdapterRecord[];
|
|
14
|
+
}
|
|
15
|
+
export interface RegisterOptions {
|
|
16
|
+
/** Overwrite an existing record with the same id. Defaults to false
|
|
17
|
+
* to prevent silent overwrites of user customizations. */
|
|
18
|
+
readonly force?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** In-memory + on-disk adapter registry. The registry holds USER
|
|
21
|
+
* adapters (those registered via `peaks adapter register`); built-in
|
|
22
|
+
* adapters live in RuntimeService and are NOT registered here. */
|
|
23
|
+
export declare class AdapterRegistry {
|
|
24
|
+
private readonly records;
|
|
25
|
+
/** Register a new adapter. When an adapter with the same id already
|
|
26
|
+
* exists, returns the existing record unless `force: true`. */
|
|
27
|
+
register(record: AdapterRecord, opts?: RegisterOptions): {
|
|
28
|
+
record: AdapterRecord;
|
|
29
|
+
created: boolean;
|
|
30
|
+
};
|
|
31
|
+
/** Resolve a registered adapter by id, materializing it as a
|
|
32
|
+
* runnable VendorAdapter. Returns undefined when not registered. */
|
|
33
|
+
resolve(id: string): VendorAdapter | undefined;
|
|
34
|
+
/** List registered adapter records (serializable form). */
|
|
35
|
+
list(): AdapterRecord[];
|
|
36
|
+
/** Remove a registered adapter. Returns true if it existed. */
|
|
37
|
+
unregister(id: string): boolean;
|
|
38
|
+
/** Persist the current registry to disk as JSON. Writes atomically
|
|
39
|
+
* (write to `<file>.tmp` then rename) so a crash mid-write does
|
|
40
|
+
* not corrupt the registry file. */
|
|
41
|
+
persist(file: string): void;
|
|
42
|
+
/** Load a registry from disk. Missing file = empty registry.
|
|
43
|
+
* Corrupt file = throw (caller decides whether to bail or
|
|
44
|
+
* fall back to empty). */
|
|
45
|
+
load(file: string): void;
|
|
46
|
+
/** Default location for the on-disk registry file. */
|
|
47
|
+
static defaultFile(projectRoot: string): string;
|
|
48
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter registry — slice S2-a of RD-2.
|
|
3
|
+
*
|
|
4
|
+
* Persists user-registered vendor adapters to
|
|
5
|
+
* `.peaks/runtime/adapters.json` and resolves them by id. The
|
|
6
|
+
* registry is layered on top of `RuntimeService` (built-in adapters
|
|
7
|
+
* live there); a caller asks "do you have an adapter for id=X?" by
|
|
8
|
+
* first asking the registry, then falling back to built-ins. The
|
|
9
|
+
* runtime service itself does NOT consult the registry (kept
|
|
10
|
+
* decoupled so the registry can be swapped for a different
|
|
11
|
+
* persistence backend without touching runtime-service).
|
|
12
|
+
*
|
|
13
|
+
* Design notes:
|
|
14
|
+
* - Persistence uses atomic write (write to tmp + rename) so a
|
|
15
|
+
* crash mid-write does not corrupt the registry file.
|
|
16
|
+
* - `register()` defaults to fail-if-exists to prevent silent
|
|
17
|
+
* overwrites of user customizations; pass `force: true` to
|
|
18
|
+
* overwrite (matches the CLI surface from PRD §4.4).
|
|
19
|
+
* - VendorAdapter implementations are NOT directly serializable
|
|
20
|
+
* (they hold closures over `home`, child_process handles, etc.).
|
|
21
|
+
* Instead the registry persists a minimal JSON shape:
|
|
22
|
+
* `{id, displayName, binary, args}` and reconstructs a
|
|
23
|
+
* ProcessVendorAdapter wrapper on load. This keeps the JSON
|
|
24
|
+
* file human-readable + diff-friendly.
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
28
|
+
import { spawn } from 'node:child_process';
|
|
29
|
+
/** A registry-built adapter: wraps a persisted record + invokes the
|
|
30
|
+
* recorded binary with the recorded args. Used so `peaks runtime
|
|
31
|
+
* compact --via <id>` works against user-registered adapters without
|
|
32
|
+
* peaks-loop needing to ship a vendor-specific implementation. */
|
|
33
|
+
class ProcessVendorAdapter {
|
|
34
|
+
id;
|
|
35
|
+
displayName;
|
|
36
|
+
binary;
|
|
37
|
+
extraArgs;
|
|
38
|
+
constructor(record) {
|
|
39
|
+
this.id = record.id;
|
|
40
|
+
this.displayName = record.displayName;
|
|
41
|
+
this.binary = record.binary;
|
|
42
|
+
this.extraArgs = record.args ?? [];
|
|
43
|
+
}
|
|
44
|
+
async detect() {
|
|
45
|
+
// Heuristic: process-spawned adapters are always "detectable" once
|
|
46
|
+
// registered; their actual availability is checked at compact()
|
|
47
|
+
// time. This avoids probing every registered binary on every
|
|
48
|
+
// detect() call.
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
async compact(args = {}) {
|
|
52
|
+
const argv = [...this.extraArgs];
|
|
53
|
+
if (args.force === true)
|
|
54
|
+
argv.push('--force');
|
|
55
|
+
return new Promise((resolveRun) => {
|
|
56
|
+
const proc = spawn(this.binary, argv, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
57
|
+
let stdout = '';
|
|
58
|
+
let stderr = '';
|
|
59
|
+
proc.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
|
|
60
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
|
|
61
|
+
proc.on('error', (err) => {
|
|
62
|
+
resolveRun({ exitCode: 127, stdout, stderr: stderr + (stderr.length > 0 ? '\n' : '') + err.message });
|
|
63
|
+
});
|
|
64
|
+
proc.on('close', (code) => {
|
|
65
|
+
resolveRun({ exitCode: code ?? 0, stdout, stderr });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Validate an adapter record. Used by register() to fail fast on
|
|
71
|
+
* malformed CLI input. Returns an error message or undefined. */
|
|
72
|
+
function validateRecord(record) {
|
|
73
|
+
if (record.id.length === 0)
|
|
74
|
+
return 'adapter id must be non-empty';
|
|
75
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(record.id)) {
|
|
76
|
+
return `adapter id "${record.id}" must match /^[a-z0-9][a-z0-9._-]*$/`;
|
|
77
|
+
}
|
|
78
|
+
if (record.displayName.length === 0)
|
|
79
|
+
return 'adapter displayName must be non-empty';
|
|
80
|
+
if (record.binary.length === 0)
|
|
81
|
+
return 'adapter binary must be non-empty';
|
|
82
|
+
if (record.binary.includes('/') || record.binary.includes('\\')) {
|
|
83
|
+
return `adapter binary "${record.binary}" must be a name, not a path`;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
/** In-memory + on-disk adapter registry. The registry holds USER
|
|
88
|
+
* adapters (those registered via `peaks adapter register`); built-in
|
|
89
|
+
* adapters live in RuntimeService and are NOT registered here. */
|
|
90
|
+
export class AdapterRegistry {
|
|
91
|
+
records = new Map();
|
|
92
|
+
/** Register a new adapter. When an adapter with the same id already
|
|
93
|
+
* exists, returns the existing record unless `force: true`. */
|
|
94
|
+
register(record, opts = {}) {
|
|
95
|
+
const validationError = validateRecord(record);
|
|
96
|
+
if (validationError !== undefined) {
|
|
97
|
+
throw new Error(`invalid adapter record: ${validationError}`);
|
|
98
|
+
}
|
|
99
|
+
const existed = this.records.has(record.id);
|
|
100
|
+
if (existed && opts.force !== true) {
|
|
101
|
+
return { record: this.records.get(record.id), created: false };
|
|
102
|
+
}
|
|
103
|
+
this.records.set(record.id, record);
|
|
104
|
+
return { record, created: !existed };
|
|
105
|
+
}
|
|
106
|
+
/** Resolve a registered adapter by id, materializing it as a
|
|
107
|
+
* runnable VendorAdapter. Returns undefined when not registered. */
|
|
108
|
+
resolve(id) {
|
|
109
|
+
const rec = this.records.get(id);
|
|
110
|
+
if (rec === undefined)
|
|
111
|
+
return undefined;
|
|
112
|
+
return new ProcessVendorAdapter(rec);
|
|
113
|
+
}
|
|
114
|
+
/** List registered adapter records (serializable form). */
|
|
115
|
+
list() {
|
|
116
|
+
return Array.from(this.records.values());
|
|
117
|
+
}
|
|
118
|
+
/** Remove a registered adapter. Returns true if it existed. */
|
|
119
|
+
unregister(id) {
|
|
120
|
+
return this.records.delete(id);
|
|
121
|
+
}
|
|
122
|
+
/** Persist the current registry to disk as JSON. Writes atomically
|
|
123
|
+
* (write to `<file>.tmp` then rename) so a crash mid-write does
|
|
124
|
+
* not corrupt the registry file. */
|
|
125
|
+
persist(file) {
|
|
126
|
+
const payload = {
|
|
127
|
+
version: 1,
|
|
128
|
+
adapters: this.list()
|
|
129
|
+
};
|
|
130
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
131
|
+
const tmp = `${file}.tmp`;
|
|
132
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
133
|
+
renameSync(tmp, file);
|
|
134
|
+
}
|
|
135
|
+
/** Load a registry from disk. Missing file = empty registry.
|
|
136
|
+
* Corrupt file = throw (caller decides whether to bail or
|
|
137
|
+
* fall back to empty). */
|
|
138
|
+
load(file) {
|
|
139
|
+
if (!existsSync(file))
|
|
140
|
+
return;
|
|
141
|
+
const raw = readFileSync(file, 'utf8');
|
|
142
|
+
let parsed;
|
|
143
|
+
try {
|
|
144
|
+
parsed = JSON.parse(raw);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
throw new Error(`adapter registry at ${file} is not valid JSON: ${err.message}`);
|
|
148
|
+
}
|
|
149
|
+
if (!isPersistedRegistry(parsed)) {
|
|
150
|
+
throw new Error(`adapter registry at ${file} has unexpected shape (expected version=1 + adapters[])`);
|
|
151
|
+
}
|
|
152
|
+
this.records.clear();
|
|
153
|
+
for (const rec of parsed.adapters) {
|
|
154
|
+
const validationError = validateRecord(rec);
|
|
155
|
+
if (validationError !== undefined) {
|
|
156
|
+
// Skip + continue — partial recovery is better than throwing on
|
|
157
|
+
// a single bad row. The CLI surfaces the count of skipped
|
|
158
|
+
// records via the load() return value below.
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
this.records.set(rec.id, rec);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Default location for the on-disk registry file. */
|
|
165
|
+
static defaultFile(projectRoot) {
|
|
166
|
+
return join(projectRoot, '.peaks', 'runtime', 'adapters.json');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function isPersistedRegistry(value) {
|
|
170
|
+
if (typeof value !== 'object' || value === null)
|
|
171
|
+
return false;
|
|
172
|
+
const v = value;
|
|
173
|
+
if (v.version !== 1)
|
|
174
|
+
return false;
|
|
175
|
+
if (!Array.isArray(v.adapters))
|
|
176
|
+
return false;
|
|
177
|
+
return v.adapters.every((a) => {
|
|
178
|
+
if (typeof a !== 'object' || a === null)
|
|
179
|
+
return false;
|
|
180
|
+
const r = a;
|
|
181
|
+
return typeof r.id === 'string'
|
|
182
|
+
&& typeof r.displayName === 'string'
|
|
183
|
+
&& typeof r.binary === 'string'
|
|
184
|
+
&& (r.args === undefined || (Array.isArray(r.args) && r.args.every((x) => typeof x === 'string')));
|
|
185
|
+
});
|
|
186
|
+
}
|
|
@@ -223,39 +223,39 @@ export declare const ContextJsonSchema: z.ZodObject<{
|
|
|
223
223
|
tags: z.ZodArray<z.ZodString, "many">;
|
|
224
224
|
}, "strip", z.ZodTypeAny, {
|
|
225
225
|
kind: "git" | "code" | "doc" | "memory";
|
|
226
|
+
tags: string[];
|
|
226
227
|
id: string;
|
|
227
228
|
blastRadius: string[];
|
|
228
229
|
conflictScore: number;
|
|
229
230
|
timeDecayScore: number;
|
|
230
|
-
tags: string[];
|
|
231
231
|
version?: string | undefined;
|
|
232
232
|
}, {
|
|
233
233
|
kind: "git" | "code" | "doc" | "memory";
|
|
234
|
+
tags: string[];
|
|
234
235
|
id: string;
|
|
235
236
|
blastRadius: string[];
|
|
236
237
|
conflictScore: number;
|
|
237
238
|
timeDecayScore: number;
|
|
238
|
-
tags: string[];
|
|
239
239
|
version?: string | undefined;
|
|
240
240
|
}>, "many">;
|
|
241
241
|
}, "strip", z.ZodTypeAny, {
|
|
242
242
|
metadata: {
|
|
243
243
|
kind: "git" | "code" | "doc" | "memory";
|
|
244
|
+
tags: string[];
|
|
244
245
|
id: string;
|
|
245
246
|
blastRadius: string[];
|
|
246
247
|
conflictScore: number;
|
|
247
248
|
timeDecayScore: number;
|
|
248
|
-
tags: string[];
|
|
249
249
|
version?: string | undefined;
|
|
250
250
|
}[];
|
|
251
251
|
}, {
|
|
252
252
|
metadata: {
|
|
253
253
|
kind: "git" | "code" | "doc" | "memory";
|
|
254
|
+
tags: string[];
|
|
254
255
|
id: string;
|
|
255
256
|
blastRadius: string[];
|
|
256
257
|
conflictScore: number;
|
|
257
258
|
timeDecayScore: number;
|
|
258
|
-
tags: string[];
|
|
259
259
|
version?: string | undefined;
|
|
260
260
|
}[];
|
|
261
261
|
}>;
|
|
@@ -330,11 +330,11 @@ export declare const ContextJsonSchema: z.ZodObject<{
|
|
|
330
330
|
tokenizer: {
|
|
331
331
|
metadata: {
|
|
332
332
|
kind: "git" | "code" | "doc" | "memory";
|
|
333
|
+
tags: string[];
|
|
333
334
|
id: string;
|
|
334
335
|
blastRadius: string[];
|
|
335
336
|
conflictScore: number;
|
|
336
337
|
timeDecayScore: number;
|
|
337
|
-
tags: string[];
|
|
338
338
|
version?: string | undefined;
|
|
339
339
|
}[];
|
|
340
340
|
};
|
|
@@ -397,11 +397,11 @@ export declare const ContextJsonSchema: z.ZodObject<{
|
|
|
397
397
|
tokenizer: {
|
|
398
398
|
metadata: {
|
|
399
399
|
kind: "git" | "code" | "doc" | "memory";
|
|
400
|
+
tags: string[];
|
|
400
401
|
id: string;
|
|
401
402
|
blastRadius: string[];
|
|
402
403
|
conflictScore: number;
|
|
403
404
|
timeDecayScore: number;
|
|
404
|
-
tags: string[];
|
|
405
405
|
version?: string | undefined;
|
|
406
406
|
}[];
|
|
407
407
|
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PolyrepoDispatch, PolyrepoManifest } from './polyrepo-types.js';
|
|
2
|
+
export interface DispatchOptions {
|
|
3
|
+
/** Manifest returned by scanPolyrepo (root + children list). */
|
|
4
|
+
readonly manifest: PolyrepoManifest;
|
|
5
|
+
/** Session id this dispatch belongs to. */
|
|
6
|
+
readonly sid: string;
|
|
7
|
+
/** Request id (used in the on-disk filename). */
|
|
8
|
+
readonly rid: string;
|
|
9
|
+
/** Target child ids. Empty = root-only dispatch. Unknown ids are
|
|
10
|
+
* recorded as warnings in the result, NOT raised as errors. */
|
|
11
|
+
readonly targets: readonly string[];
|
|
12
|
+
/** Artifact body — the role + source path on the parent side. */
|
|
13
|
+
readonly artifact: PolyrepoDispatch['artifact'];
|
|
14
|
+
}
|
|
15
|
+
export interface ChildDispatchResult {
|
|
16
|
+
readonly childId: string;
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly ok: boolean;
|
|
19
|
+
readonly error?: string;
|
|
20
|
+
/** Where the artifact was mirrored to (child-side path). */
|
|
21
|
+
readonly mirroredTo?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface DispatchResult {
|
|
24
|
+
readonly dispatch: PolyrepoDispatch;
|
|
25
|
+
readonly perChild: ChildDispatchResult[];
|
|
26
|
+
readonly warnings: string[];
|
|
27
|
+
}
|
|
28
|
+
export declare function dispatchArtifact(opts: DispatchOptions): DispatchResult;
|
|
29
|
+
/** Default location of the polyrepo manifest on disk. */
|
|
30
|
+
export declare function manifestPath(root: string): string;
|
|
31
|
+
/** Read a persisted manifest from disk. Returns null when missing. */
|
|
32
|
+
export declare function readManifest(root: string): PolyrepoManifest | null;
|
|
33
|
+
/** Write a manifest atomically. */
|
|
34
|
+
export declare function writeManifest(root: string, manifest: PolyrepoManifest): void;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polyrepo dispatcher — slice S2-b of RD-2.
|
|
3
|
+
*
|
|
4
|
+
* Takes a parent root + a list of target child ids + a PRD/RD/QA
|
|
5
|
+
* artifact, and mirrors the artifact into each target child's
|
|
6
|
+
* `.peaks/_runtime/<sid>/<role>/` directory. Mirroring is
|
|
7
|
+
* best-effort + atomic per child: a failure in one child does NOT
|
|
8
|
+
* block the others (the dispatcher collects per-child results and
|
|
9
|
+
* returns them all so the caller can surface partial successes).
|
|
10
|
+
*
|
|
11
|
+
* Design notes:
|
|
12
|
+
* - Per the project's two-axis convention, the parent's reviewable
|
|
13
|
+
* artifact lives under `.peaks/_runtime/<sid>/<role>/`, not as a
|
|
14
|
+
* sibling of `.peaks/_runtime/`. We mirror the same shape into
|
|
15
|
+
* each child (the child does NOT inherit the parent's session
|
|
16
|
+
* id verbatim — it gets the same one, which is fine because the
|
|
17
|
+
* sid is the project's binding, not the parent's transient
|
|
18
|
+
* pid).
|
|
19
|
+
* - The dispatcher does NOT spawn any vendor verb — vendor
|
|
20
|
+
* neutrality is preserved. If a child has no peaks-loop binary
|
|
21
|
+
* on PATH, the dispatcher writes the artifact to disk and notes
|
|
22
|
+
* a `peaksInstalled: false` warning; the user / a future slice
|
|
23
|
+
* can pick it up. Per PRD §5 mitigation for "child has no
|
|
24
|
+
* peaks-loop installed".
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
28
|
+
/** Compute the child-side path where the artifact should be mirrored.
|
|
29
|
+
* Mirrors the parent shape exactly: `.peaks/_runtime/<sid>/<role>/`. */
|
|
30
|
+
function childArtifactPath(childRoot, sid, role, sourcePath) {
|
|
31
|
+
const filename = sourcePath.split(/[\\/]/).pop() ?? 'artifact.md';
|
|
32
|
+
return join(childRoot, '.peaks', '_runtime', sid, role, filename);
|
|
33
|
+
}
|
|
34
|
+
function ensureDirFor(file) {
|
|
35
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
export function dispatchArtifact(opts) {
|
|
38
|
+
const warnings = [];
|
|
39
|
+
const perChild = [];
|
|
40
|
+
// Validate targets against the manifest.
|
|
41
|
+
const knownIds = new Set(opts.manifest.children.map((c) => c.id));
|
|
42
|
+
const validTargets = [];
|
|
43
|
+
for (const t of opts.targets) {
|
|
44
|
+
if (knownIds.has(t)) {
|
|
45
|
+
validTargets.push(t);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
warnings.push(`unknown target id "${t}" — ignored`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Read the source artifact body once (so we can mirror to N children).
|
|
52
|
+
let sourceBody;
|
|
53
|
+
try {
|
|
54
|
+
sourceBody = readFileSync(opts.artifact.path, 'utf8');
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
throw new Error(`source artifact unreadable at ${opts.artifact.path}: ${err.message}`);
|
|
58
|
+
}
|
|
59
|
+
// Per-child write.
|
|
60
|
+
for (const targetId of validTargets) {
|
|
61
|
+
const child = opts.manifest.children.find((c) => c.id === targetId);
|
|
62
|
+
if (child === undefined)
|
|
63
|
+
continue; // already filtered above
|
|
64
|
+
const dest = childArtifactPath(child.path, opts.sid, opts.artifact.role, opts.artifact.path);
|
|
65
|
+
try {
|
|
66
|
+
ensureDirFor(dest);
|
|
67
|
+
writeFileSync(dest, sourceBody, 'utf8');
|
|
68
|
+
perChild.push({
|
|
69
|
+
childId: targetId,
|
|
70
|
+
path: child.path,
|
|
71
|
+
ok: true,
|
|
72
|
+
mirroredTo: dest
|
|
73
|
+
});
|
|
74
|
+
if (!child.peaksInstalled) {
|
|
75
|
+
warnings.push(`child "${targetId}" has no peaks-loop install — artifact mirrored but not picked up by a running peaks process`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
perChild.push({
|
|
80
|
+
childId: targetId,
|
|
81
|
+
path: child.path,
|
|
82
|
+
ok: false,
|
|
83
|
+
error: err.message
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const dispatch = {
|
|
88
|
+
rid: opts.rid,
|
|
89
|
+
sid: opts.sid,
|
|
90
|
+
root: opts.manifest.root,
|
|
91
|
+
targets: validTargets,
|
|
92
|
+
dispatchedAt: new Date().toISOString(),
|
|
93
|
+
artifact: opts.artifact
|
|
94
|
+
};
|
|
95
|
+
return { dispatch, perChild, warnings };
|
|
96
|
+
}
|
|
97
|
+
/** Default location of the polyrepo manifest on disk. */
|
|
98
|
+
export function manifestPath(root) {
|
|
99
|
+
return join(root, '.peaks', 'polyrepo.json');
|
|
100
|
+
}
|
|
101
|
+
/** Read a persisted manifest from disk. Returns null when missing. */
|
|
102
|
+
export function readManifest(root) {
|
|
103
|
+
const p = manifestPath(root);
|
|
104
|
+
if (!existsSync(p))
|
|
105
|
+
return null;
|
|
106
|
+
try {
|
|
107
|
+
const raw = readFileSync(p, 'utf8');
|
|
108
|
+
return JSON.parse(raw);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Write a manifest atomically. */
|
|
115
|
+
export function writeManifest(root, manifest) {
|
|
116
|
+
const p = manifestPath(root);
|
|
117
|
+
ensureDirFor(p);
|
|
118
|
+
writeFileSync(p, JSON.stringify(manifest, null, 2), 'utf8');
|
|
119
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PolyrepoManifest } from './polyrepo-types.js';
|
|
2
|
+
export interface ScanOptions {
|
|
3
|
+
/** Explicit list of child directory names (relative to root). When
|
|
4
|
+
* provided, the scanner uses these instead of auto-discovery.
|
|
5
|
+
* Matches the `peaks polyrepo init --children <dir1,dir2>` shape. */
|
|
6
|
+
readonly explicitChildren?: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
/** Walk the parent root at depth 1 and return discovered children. */
|
|
9
|
+
export declare function scanPolyrepo(root: string, opts?: ScanOptions): PolyrepoManifest;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polyrepo scanner — slice S2-b of RD-2.
|
|
3
|
+
*
|
|
4
|
+
* Walks a parent directory looking for child git repos. A "child
|
|
5
|
+
* git repo" is defined as: a directory that contains a `.git`
|
|
6
|
+
* entry (file or directory — worktrees + submodules both qualify),
|
|
7
|
+
* is NOT the root itself, and sits at depth 1 from the root.
|
|
8
|
+
*
|
|
9
|
+
* Depth is intentionally bounded to 1 (per the PRD §4.3 model):
|
|
10
|
+
* parent/
|
|
11
|
+
* .peaks/
|
|
12
|
+
* frontend/.git
|
|
13
|
+
* backend/.git
|
|
14
|
+
* We do not recurse into nested monorepos in this slice — that is
|
|
15
|
+
* a future-slice concern.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
18
|
+
import { join, basename, resolve } from 'node:path';
|
|
19
|
+
function isGitRoot(dir) {
|
|
20
|
+
// A `.git` directory (normal repo) OR a `.git` file (worktree +
|
|
21
|
+
// submodule pointer) both count. statSync follows the pointer for
|
|
22
|
+
// a `.git` file, so the existence check is the same.
|
|
23
|
+
return existsSync(join(dir, '.git'));
|
|
24
|
+
}
|
|
25
|
+
function childHasPeaks(dir) {
|
|
26
|
+
return existsSync(join(dir, '.peaks'));
|
|
27
|
+
}
|
|
28
|
+
/** Sanitize a candidate child id: lowercase, dash-joined, no
|
|
29
|
+
* leading dots or dashes. Used when the dir name itself is the id
|
|
30
|
+
* (the common case). */
|
|
31
|
+
function sanitizeId(name) {
|
|
32
|
+
return name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '');
|
|
33
|
+
}
|
|
34
|
+
/** Walk the parent root at depth 1 and return discovered children. */
|
|
35
|
+
export function scanPolyrepo(root, opts = {}) {
|
|
36
|
+
const absRoot = resolve(root);
|
|
37
|
+
if (!existsSync(absRoot)) {
|
|
38
|
+
throw new Error(`polyrepo root does not exist: ${absRoot}`);
|
|
39
|
+
}
|
|
40
|
+
const rootStat = statSync(absRoot);
|
|
41
|
+
if (!rootStat.isDirectory()) {
|
|
42
|
+
throw new Error(`polyrepo root is not a directory: ${absRoot}`);
|
|
43
|
+
}
|
|
44
|
+
const children = [];
|
|
45
|
+
if (opts.explicitChildren !== undefined && opts.explicitChildren.length > 0) {
|
|
46
|
+
// Explicit mode: only consider the named subdirs. This matches
|
|
47
|
+
// `peaks polyrepo init --children ./frontend,./backend`.
|
|
48
|
+
for (const name of opts.explicitChildren) {
|
|
49
|
+
const childPath = resolve(absRoot, name);
|
|
50
|
+
if (!existsSync(childPath)) {
|
|
51
|
+
throw new Error(`polyrepo child directory does not exist: ${childPath}`);
|
|
52
|
+
}
|
|
53
|
+
const s = statSync(childPath);
|
|
54
|
+
if (!s.isDirectory()) {
|
|
55
|
+
throw new Error(`polyrepo child path is not a directory: ${childPath}`);
|
|
56
|
+
}
|
|
57
|
+
children.push({
|
|
58
|
+
id: sanitizeId(basename(childPath)),
|
|
59
|
+
path: childPath,
|
|
60
|
+
gitRoot: isGitRoot(childPath),
|
|
61
|
+
peaksScope: childHasPeaks(childPath) ? 'root+child' : 'child-only',
|
|
62
|
+
peaksInstalled: childHasPeaks(childPath)
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
// Auto-discovery mode: list immediate entries, filter to dirs with
|
|
68
|
+
// `.git`. This is intentionally narrow — see the file-level
|
|
69
|
+
// rationale at the top of this file.
|
|
70
|
+
const entries = readdirSync(absRoot, { withFileTypes: true });
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (!entry.isDirectory())
|
|
73
|
+
continue;
|
|
74
|
+
if (entry.name.startsWith('.'))
|
|
75
|
+
continue; // skip .git, .peaks, .vscode, etc.
|
|
76
|
+
const childPath = join(absRoot, entry.name);
|
|
77
|
+
if (!isGitRoot(childPath))
|
|
78
|
+
continue;
|
|
79
|
+
children.push({
|
|
80
|
+
id: sanitizeId(entry.name),
|
|
81
|
+
path: childPath,
|
|
82
|
+
gitRoot: true,
|
|
83
|
+
peaksScope: childHasPeaks(childPath) ? 'root+child' : 'child-only',
|
|
84
|
+
peaksInstalled: childHasPeaks(childPath)
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
version: 1,
|
|
90
|
+
root: absRoot,
|
|
91
|
+
detectedAt: new Date().toISOString(),
|
|
92
|
+
children
|
|
93
|
+
};
|
|
94
|
+
}
|