peaks-loop 4.0.0-beta.5 → 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.
Files changed (39) hide show
  1. package/dist/src/cli/commands/adapter-commands-s2a.d.ts +34 -0
  2. package/dist/src/cli/commands/adapter-commands-s2a.js +92 -0
  3. package/dist/src/cli/commands/polyrepo-commands.d.ts +37 -0
  4. package/dist/src/cli/commands/polyrepo-commands.js +155 -0
  5. package/dist/src/cli/commands/runtime-commands.d.ts +52 -0
  6. package/dist/src/cli/commands/runtime-commands.js +150 -0
  7. package/dist/src/cli/program.js +16 -0
  8. package/dist/src/services/adapter/adapter-registry.d.ts +48 -0
  9. package/dist/src/services/adapter/adapter-registry.js +186 -0
  10. package/dist/src/services/polyrepo/polyrepo-dispatcher.d.ts +34 -0
  11. package/dist/src/services/polyrepo/polyrepo-dispatcher.js +119 -0
  12. package/dist/src/services/polyrepo/polyrepo-scanner.d.ts +9 -0
  13. package/dist/src/services/polyrepo/polyrepo-scanner.js +94 -0
  14. package/dist/src/services/polyrepo/polyrepo-service.d.ts +34 -0
  15. package/dist/src/services/polyrepo/polyrepo-service.js +49 -0
  16. package/dist/src/services/polyrepo/polyrepo-types.d.ts +55 -0
  17. package/dist/src/services/polyrepo/polyrepo-types.js +1 -0
  18. package/dist/src/services/runtime/runtime-detector.d.ts +15 -0
  19. package/dist/src/services/runtime/runtime-detector.js +62 -0
  20. package/dist/src/services/runtime/runtime-service.d.ts +59 -0
  21. package/dist/src/services/runtime/runtime-service.js +51 -0
  22. package/dist/src/services/runtime/vendor-adapter.d.ts +59 -0
  23. package/dist/src/services/runtime/vendor-adapter.js +1 -0
  24. package/dist/src/services/runtime/vendors/claude-code.d.ts +7 -0
  25. package/dist/src/services/runtime/vendors/claude-code.js +39 -0
  26. package/dist/src/services/runtime/vendors/codex.d.ts +7 -0
  27. package/dist/src/services/runtime/vendors/codex.js +43 -0
  28. package/dist/src/services/runtime/vendors/copilot.d.ts +7 -0
  29. package/dist/src/services/runtime/vendors/copilot.js +40 -0
  30. package/dist/src/shared/version.d.ts +1 -1
  31. package/dist/src/shared/version.js +1 -1
  32. package/package.json +1 -1
  33. package/skills/peaks-code/SKILL.md +1 -8
  34. package/skills/peaks-code/references/external-references.md +1 -3
  35. package/skills/peaks-code/references/external-skill-invocation.md +1 -1
  36. package/skills/peaks-code/references/runbook.md +1 -3
  37. package/skills/peaks-code/references/step-0-55-1x-detection.md +2 -1
  38. package/skills/peaks-code/references/workflow-gates-and-types.md +1 -1
  39. package/skills/peaks-code/references/openspec-workflow.md +0 -81
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,34 @@
1
+ import type { PolyrepoChild, PolyrepoManifest } from './polyrepo-types.js';
2
+ export interface PolyrepoInitOptions {
3
+ readonly root: string;
4
+ readonly children?: readonly string[];
5
+ }
6
+ export interface PolyrepoInitResult {
7
+ readonly manifest: PolyrepoManifest;
8
+ readonly created: boolean;
9
+ }
10
+ export interface PolyrepoStatusResult {
11
+ readonly root: string;
12
+ readonly manifestExists: boolean;
13
+ readonly children: PolyrepoChild[];
14
+ readonly detectedAt: string | null;
15
+ }
16
+ export declare class PolyrepoService {
17
+ /** Run a scan and persist the manifest. Returns the manifest + a
18
+ * `created` flag indicating whether a new manifest was written
19
+ * (false = existing manifest was overwritten). */
20
+ init(opts: PolyrepoInitOptions): PolyrepoInitResult;
21
+ /** Read the persisted manifest + report children. */
22
+ status(root: string): PolyrepoStatusResult;
23
+ /** Dispatch an artifact to the named child targets. Wrapper
24
+ * around dispatchArtifact that resolves the manifest from disk
25
+ * first. Throws when no manifest exists (caller should run init
26
+ * first). */
27
+ dispatch(root: string, params: {
28
+ sid: string;
29
+ rid: string;
30
+ targets: readonly string[];
31
+ role: 'prd' | 'rd' | 'qa';
32
+ artifactPath: string;
33
+ }): import("./polyrepo-dispatcher.js").DispatchResult;
34
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Polyrepo service — slice S2-b of RD-2.
3
+ *
4
+ * Thin orchestrator wrapping the scanner + dispatcher. CLI commands
5
+ * call into this service so they don't need to know about the
6
+ * individual helpers.
7
+ */
8
+ import { dispatchArtifact, readManifest, writeManifest, manifestPath } from './polyrepo-dispatcher.js';
9
+ import { scanPolyrepo } from './polyrepo-scanner.js';
10
+ export class PolyrepoService {
11
+ /** Run a scan and persist the manifest. Returns the manifest + a
12
+ * `created` flag indicating whether a new manifest was written
13
+ * (false = existing manifest was overwritten). */
14
+ init(opts) {
15
+ const existing = readManifest(opts.root);
16
+ const manifest = opts.children === undefined
17
+ ? scanPolyrepo(opts.root)
18
+ : scanPolyrepo(opts.root, { explicitChildren: opts.children });
19
+ writeManifest(opts.root, manifest);
20
+ return { manifest, created: existing === null };
21
+ }
22
+ /** Read the persisted manifest + report children. */
23
+ status(root) {
24
+ const m = readManifest(root);
25
+ return {
26
+ root,
27
+ manifestExists: m !== null,
28
+ children: m?.children ?? [],
29
+ detectedAt: m?.detectedAt ?? null
30
+ };
31
+ }
32
+ /** Dispatch an artifact to the named child targets. Wrapper
33
+ * around dispatchArtifact that resolves the manifest from disk
34
+ * first. Throws when no manifest exists (caller should run init
35
+ * first). */
36
+ dispatch(root, params) {
37
+ const manifest = readManifest(root);
38
+ if (manifest === null) {
39
+ throw new Error(`no polyrepo manifest at ${manifestPath(root)} — run \`peaks polyrepo init\` first`);
40
+ }
41
+ return dispatchArtifact({
42
+ manifest,
43
+ sid: params.sid,
44
+ rid: params.rid,
45
+ targets: params.targets,
46
+ artifact: { role: params.role, path: params.artifactPath }
47
+ });
48
+ }
49
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Polyrepo types — slice S2-b of RD-2.
3
+ *
4
+ * Models a parent directory that hosts one or more child git repos,
5
+ * each of which may carry its own `.peaks/` state. The root `.peaks/`
6
+ * is the source of truth for cross-child orchestration; child
7
+ * `.peaks/` are subsets that mirror only the artifacts the child
8
+ * needs (PRD §4.3).
9
+ */
10
+ export type PeaksScope = 'root+child' | 'child-only';
11
+ /** A single child repo discovered under the parent root. */
12
+ export interface PolyrepoChild {
13
+ /** Stable id derived from the basename (e.g. `frontend`, `backend`). */
14
+ readonly id: string;
15
+ /** Absolute path to the child directory. */
16
+ readonly path: string;
17
+ /** Whether `.git` exists at the child root (true git repo, not just a subdir). */
18
+ readonly gitRoot: boolean;
19
+ /** Whether the child carries its own `.peaks/` artifact subset. */
20
+ readonly peaksScope: PeaksScope;
21
+ /** Whether the child has peaks-loop installed (peaks binary on PATH
22
+ * is NOT checked here — that's done at dispatch time). */
23
+ readonly peaksInstalled: boolean;
24
+ }
25
+ /** The polyrepo manifest, written to `.peaks/polyrepo.json` on init. */
26
+ export interface PolyrepoManifest {
27
+ /** Schema version — increment on breaking changes. */
28
+ readonly version: 1;
29
+ /** Absolute path to the parent (root) directory. */
30
+ readonly root: string;
31
+ /** ISO-8601 timestamp of when this manifest was generated. */
32
+ readonly detectedAt: string;
33
+ /** Children discovered under the root. */
34
+ readonly children: PolyrepoChild[];
35
+ }
36
+ /** A dispatch record — written to `.peaks/_runtime/<sid>/sc/dispatch/<rid>.json`
37
+ * AND mirrored to each target child's
38
+ * `.peaks/_runtime/<sid>/sc/dispatch/<rid>.json`. The two-axis
39
+ * convention from .peaks/standards/ is preserved: reviewable
40
+ * artifacts live under `.peaks/_runtime/<sid>/<role>/`, never as
41
+ * top-level siblings of `.peaks/_runtime/`. */
42
+ export interface PolyrepoDispatch {
43
+ readonly rid: string;
44
+ readonly sid: string;
45
+ readonly root: string;
46
+ /** Child ids targeted by this dispatch. Empty array = root-only. */
47
+ readonly targets: string[];
48
+ /** ISO timestamp. */
49
+ readonly dispatchedAt: string;
50
+ /** Source-of-truth for the artifact body. */
51
+ readonly artifact: {
52
+ readonly role: 'prd' | 'rd' | 'qa';
53
+ readonly path: string;
54
+ };
55
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ export type DetectedVendor = 'claude-code' | 'codex' | 'copilot' | 'unknown';
2
+ export interface RuntimeDetectorOptions {
3
+ /** Override for env (used by tests). */
4
+ readonly env?: NodeJS.ProcessEnv;
5
+ /** Override for home directory (used by tests). */
6
+ readonly home?: string;
7
+ }
8
+ export interface DetectionResult {
9
+ readonly vendor: DetectedVendor;
10
+ /** Why this vendor was chosen — useful for diagnostics and tests. */
11
+ readonly reason: string;
12
+ }
13
+ /** Detect the active vendor. Order: claude-code → codex → copilot →
14
+ * unknown. The first match wins. */
15
+ export declare function detectRuntime(opts?: RuntimeDetectorOptions): DetectionResult;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Runtime detector — slice S2-a of RD-2.
3
+ *
4
+ * Given the current process environment, decide which vendor (if any)
5
+ * is the active AI runtime. Detection order matters: Claude Code is
6
+ * checked first because it is the default runtime for peaks-loop
7
+ * (the project literally runs inside Claude Code). Codex + Copilot
8
+ * are checked second; if none match we report `unknown`.
9
+ *
10
+ * Detectors MUST be cheap (no process spawn, no network). They look
11
+ * at env vars + filesystem sentinels + home-dir shapes only. This
12
+ * keeps `peaks runtime detect` sub-100ms even on cold paths.
13
+ */
14
+ import { existsSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ /** Heuristic: Claude Code sets CLAUDE_CODE_ENTRYPOINT or has a
17
+ * .claude/skills directory under HOME (peaks-loop installs there). */
18
+ function detectClaudeCode(opts) {
19
+ const env = opts.env ?? process.env;
20
+ if (env.CLAUDE_CODE === '1' || env.CLAUDE_CODE === 'true')
21
+ return true;
22
+ if (env.CLAUDE_CODE_ENTRYPOINT !== undefined && env.CLAUDE_CODE_ENTRYPOINT.length > 0)
23
+ return true;
24
+ const home = opts.home ?? process.env.HOME ?? process.env.USERPROFILE ?? '';
25
+ if (home.length > 0 && existsSync(join(home, '.claude')))
26
+ return true;
27
+ return false;
28
+ }
29
+ /** Heuristic: Codex sets CODEX_HOME or has a .codex dir under HOME. */
30
+ function detectCodex(opts) {
31
+ const env = opts.env ?? process.env;
32
+ if (env.CODEX_HOME !== undefined && env.CODEX_HOME.length > 0)
33
+ return true;
34
+ const home = opts.home ?? process.env.HOME ?? process.env.USERPROFILE ?? '';
35
+ if (home.length > 0 && existsSync(join(home, '.codex')))
36
+ return true;
37
+ return false;
38
+ }
39
+ /** Heuristic: Copilot sets GITHUB_COPILOT or has a .copilot dir. */
40
+ function detectCopilot(opts) {
41
+ const env = opts.env ?? process.env;
42
+ if (env.GITHUB_COPILOT === '1' || env.GITHUB_COPILOT === 'true')
43
+ return true;
44
+ const home = opts.home ?? process.env.HOME ?? process.env.USERPROFILE ?? '';
45
+ if (home.length > 0 && existsSync(join(home, '.copilot')))
46
+ return true;
47
+ return false;
48
+ }
49
+ /** Detect the active vendor. Order: claude-code → codex → copilot →
50
+ * unknown. The first match wins. */
51
+ export function detectRuntime(opts = {}) {
52
+ if (detectClaudeCode(opts)) {
53
+ return { vendor: 'claude-code', reason: 'CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE / ~/.claude matched' };
54
+ }
55
+ if (detectCodex(opts)) {
56
+ return { vendor: 'codex', reason: 'CODEX_HOME / ~/.codex matched' };
57
+ }
58
+ if (detectCopilot(opts)) {
59
+ return { vendor: 'copilot', reason: 'GITHUB_COPILOT / ~/.copilot matched' };
60
+ }
61
+ return { vendor: 'unknown', reason: 'no vendor sentinel detected' };
62
+ }