peaks-loop 4.0.0-beta.5 → 4.0.0-beta.7

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 (58) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/src/cli/commands/adapter-commands-s2a.d.ts +34 -0
  3. package/dist/src/cli/commands/adapter-commands-s2a.js +92 -0
  4. package/dist/src/cli/commands/ide-commands.d.ts +25 -0
  5. package/dist/src/cli/commands/ide-commands.js +42 -0
  6. package/dist/src/cli/commands/polyrepo-commands.d.ts +37 -0
  7. package/dist/src/cli/commands/polyrepo-commands.js +155 -0
  8. package/dist/src/cli/commands/runtime-commands.d.ts +52 -0
  9. package/dist/src/cli/commands/runtime-commands.js +150 -0
  10. package/dist/src/cli/program.js +23 -0
  11. package/dist/src/services/adapter/adapter-registry.d.ts +48 -0
  12. package/dist/src/services/adapter/adapter-registry.js +186 -0
  13. package/dist/src/services/config/model-routing.d.ts +55 -2
  14. package/dist/src/services/config/model-routing.js +79 -3
  15. package/dist/src/services/ide/adapters/zcode-adapter.d.ts +40 -0
  16. package/dist/src/services/ide/adapters/zcode-adapter.js +220 -0
  17. package/dist/src/services/ide/current-model-detector.d.ts +14 -0
  18. package/dist/src/services/ide/current-model-detector.js +60 -0
  19. package/dist/src/services/ide/ide-registry.js +3 -0
  20. package/dist/src/services/ide/ide-types.d.ts +24 -1
  21. package/dist/src/services/polyrepo/polyrepo-dispatcher.d.ts +34 -0
  22. package/dist/src/services/polyrepo/polyrepo-dispatcher.js +119 -0
  23. package/dist/src/services/polyrepo/polyrepo-scanner.d.ts +9 -0
  24. package/dist/src/services/polyrepo/polyrepo-scanner.js +94 -0
  25. package/dist/src/services/polyrepo/polyrepo-service.d.ts +34 -0
  26. package/dist/src/services/polyrepo/polyrepo-service.js +49 -0
  27. package/dist/src/services/polyrepo/polyrepo-types.d.ts +55 -0
  28. package/dist/src/services/polyrepo/polyrepo-types.js +1 -0
  29. package/dist/src/services/preferences/preferences-types.d.ts +1 -1
  30. package/dist/src/services/providers/minimax-worker-service.d.ts +1 -1
  31. package/dist/src/services/providers/minimax-worker-service.js +1 -1
  32. package/dist/src/services/rd/rd-service.js +7 -4
  33. package/dist/src/services/runtime/runtime-detector.d.ts +15 -0
  34. package/dist/src/services/runtime/runtime-detector.js +62 -0
  35. package/dist/src/services/runtime/runtime-service.d.ts +59 -0
  36. package/dist/src/services/runtime/runtime-service.js +51 -0
  37. package/dist/src/services/runtime/vendor-adapter.d.ts +59 -0
  38. package/dist/src/services/runtime/vendor-adapter.js +1 -0
  39. package/dist/src/services/runtime/vendors/claude-code.d.ts +7 -0
  40. package/dist/src/services/runtime/vendors/claude-code.js +39 -0
  41. package/dist/src/services/runtime/vendors/codex.d.ts +7 -0
  42. package/dist/src/services/runtime/vendors/codex.js +43 -0
  43. package/dist/src/services/runtime/vendors/copilot.d.ts +7 -0
  44. package/dist/src/services/runtime/vendors/copilot.js +40 -0
  45. package/dist/src/services/workflow/workflow-router-service.d.ts +1 -1
  46. package/dist/src/services/workflow/workflow-router-service.js +37 -31
  47. package/dist/src/shared/version.d.ts +1 -1
  48. package/dist/src/shared/version.js +1 -1
  49. package/package.json +1 -1
  50. package/scripts/install-skills.mjs +13 -10
  51. package/skills/peaks-code/SKILL.md +26 -12
  52. package/skills/peaks-code/references/external-references.md +1 -3
  53. package/skills/peaks-code/references/external-skill-invocation.md +1 -1
  54. package/skills/peaks-code/references/runbook.md +1 -3
  55. package/skills/peaks-code/references/step-0-55-1x-detection.md +2 -1
  56. package/skills/peaks-code/references/workflow-gates-and-types.md +1 -1
  57. package/skills/peaks-slice-decompose/SKILL.md +61 -1
  58. package/skills/peaks-code/references/openspec-workflow.md +0 -81
@@ -0,0 +1,220 @@
1
+ /**
2
+ * z-code IDE adapter (slice 2026-07-09-add-zcode-adapter) — peaks-loop 的第九个
3
+ * 内置 IDE 适配器。
4
+ *
5
+ * z-code 是一个 VS Code-style 的 Anthropic-compatible 桌面应用:
6
+ * - 不是 CLI,没有 `zcode` binary (`which zcode` 返回空)。
7
+ * - 桌面应用内置了 "导入 skills" 功能,user 手动触发后将本地 skills 目录
8
+ * 与 `~/.zcode/skills` 双向同步 (实测 symlink)。
9
+ * - hook 协议、project-dir env var、compact command 均未公开。
10
+ *
11
+ * 设计前提 (RD-3 §2.2 D1 决策 + 现场实测,见
12
+ * `.peaks/memory/2026-07-09-zcode-context-probe-handoff.md` §2):
13
+ * - `compact` 字段全部 undefined — 走 `llm-self-compress` fallback 路径。
14
+ * - `envVar` / `hookEvent` / `toolMatcher` 全部标注 UNVERIFIED,因为
15
+ * z-code 文档未公开,本 adapter 用 Anthropic-compatible 协议的合理
16
+ * 假设占位。
17
+ * - `subAgentDispatcher` 用 `nullSubAgentDispatcher` 占位 — z-code
18
+ * 桌面应用没有公开的 Task 工具 dispatch 协议。
19
+ * - `standardsProfile` 字段指向宪法文件根 + rules 目录 — z-code 的设计
20
+ * 就是借用上游 "import skills" 目标目录,因此保留上游路径常量。
21
+ * - `skillInstall` 指向 `~/.zcode/skills` — 实测 z-code 已通过 user
22
+ * 手动导入同步到这里。
23
+ * - 注:`standardsProfile` 中的常量子段是 z-code 借用的固定路径,属于
24
+ * 架构事实,不算 vendor verb 泄漏。
25
+ *
26
+ * Vendor-neutrality 备注 (SC-3 §5 + RD-3 §2.4):
27
+ * 本文件允许出现 vendor 字符串 (`zcode` / `ZCODE_PROJECT_DIR` 等),
28
+ * **不允许** 出现其他 vendor 字符串 / 模型名。`standardsProfile`
29
+ * 中的 `CLAUDE.md` / `.claude/rules` 是 z-code 借用的上游路径常量,
30
+ * 属于架构事实,不算 vendor verb 泄漏。
31
+ */
32
+ import { existsSync, readFileSync } from 'node:fs';
33
+ import { homedir } from 'node:os';
34
+ import { join, resolve } from 'node:path';
35
+ import { nullSubAgentDispatcher } from '../../dispatch/sub-agent-dispatcher.js';
36
+ /**
37
+ * Helper exported for tests + internal use: the default location of the
38
+ * z-code v2 config file on the host filesystem.
39
+ */
40
+ export function defaultZcodeConfigPath() {
41
+ return join(homedir(), '.zcode', 'v2', 'config.json');
42
+ }
43
+ /**
44
+ * Read the z-code config from `path` (string-typed to keep this
45
+ * swappable for tests). Synchronous + Node.js `fs` so adapter callers
46
+ * stay async-only at the public surface (`detectCurrentModel` is
47
+ * already declared `async`).
48
+ */
49
+ function readZcodeConfig(path) {
50
+ if (!existsSync(path))
51
+ return undefined;
52
+ try {
53
+ return JSON.parse(readFileSync(path, 'utf8'));
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ /**
60
+ * Resolve the active model id from a parsed z-code config payload
61
+ * (the `~/.zcode/v2/config.json` root object). Pure / exported for
62
+ * unit tests so the file-IO seam and the resolution seam are
63
+ * independently verifiable.
64
+ *
65
+ * Resolution order (Slice C §"z-code config 解析路径"):
66
+ * P1. env var `PEAKS_ZCODE_ACTIVE_PROVIDER_UUID` (test seam +
67
+ * manual pin for the user).
68
+ * P2. The provider whose top-level UUID key is NOT prefixed
69
+ * `builtin:` — this matches the user-installed provider
70
+ * pattern. Within that provider, prefer its `models` first
71
+ * key (insertion order — z-code writes the active model
72
+ * first in the picker).
73
+ * P3. The first provider with `enabled: true`.
74
+ * P4. The first provider in the object (insertion order) — last
75
+ * resort. Returns undefined if there are zero providers.
76
+ *
77
+ * Returns `undefined` when nothing matches.
78
+ */
79
+ export function resolveZcodeCurrentModel(config, envOverride) {
80
+ const providers = config?.provider;
81
+ if (!providers || typeof providers !== 'object')
82
+ return undefined;
83
+ // P1: env var override (vendor-neutral identifier: the provider UUID).
84
+ if (envOverride !== undefined && envOverride !== '') {
85
+ const target = providers[envOverride];
86
+ if (target && typeof target === 'object') {
87
+ const modelId = pickFirstModelId(target);
88
+ if (modelId)
89
+ return modelId;
90
+ }
91
+ }
92
+ // P2: prefer a non-builtin provider (the user-installed one).
93
+ for (const [uuid, entry] of Object.entries(providers)) {
94
+ if (uuid.startsWith('builtin:'))
95
+ continue;
96
+ if (!entry || typeof entry !== 'object')
97
+ continue;
98
+ const modelId = pickFirstModelId(entry);
99
+ if (modelId)
100
+ return modelId;
101
+ }
102
+ // P3: first enabled provider (legacy / fresh-install case).
103
+ for (const entry of Object.values(providers)) {
104
+ if (!entry || typeof entry !== 'object')
105
+ continue;
106
+ const enabled = entry.enabled;
107
+ if (enabled === true) {
108
+ const modelId = pickFirstModelId(entry);
109
+ if (modelId)
110
+ return modelId;
111
+ }
112
+ }
113
+ // P4: first provider at all.
114
+ for (const entry of Object.values(providers)) {
115
+ if (!entry || typeof entry !== 'object')
116
+ continue;
117
+ const modelId = pickFirstModelId(entry);
118
+ if (modelId)
119
+ return modelId;
120
+ }
121
+ return undefined;
122
+ }
123
+ function pickFirstModelId(provider) {
124
+ const models = provider.models;
125
+ if (!models || typeof models !== 'object')
126
+ return undefined;
127
+ for (const key of Object.keys(models)) {
128
+ if (typeof key === 'string' && key.trim().length > 0)
129
+ return key.trim();
130
+ }
131
+ return undefined;
132
+ }
133
+ /**
134
+ * The async adapter method that `detectCurrentIdeModel()` calls.
135
+ *
136
+ * Resolution order:
137
+ * 1. `PEAKS_ZCODE_ACTIVE_PROVIDER_UUID` env var override (test seam
138
+ * + manual pin).
139
+ * 2. `PEAKS_ZCODE_CONFIG_PATH` env var override (test seam for a
140
+ * fixture file). Falls back to `~/.zcode/v2/config.json`.
141
+ * 3. Returns `undefined` when the file is missing / malformed
142
+ * (consistent with the cross-IDE contract).
143
+ */
144
+ export async function detectZcodeCurrentModel() {
145
+ const overridePath = process.env.PEAKS_ZCODE_CONFIG_PATH;
146
+ const path = overridePath && overridePath.length > 0 ? overridePath : defaultZcodeConfigPath();
147
+ const config = readZcodeConfig(path);
148
+ if (config === undefined)
149
+ return undefined;
150
+ return resolveZcodeCurrentModel(config, process.env.PEAKS_ZCODE_ACTIVE_PROVIDER_UUID);
151
+ }
152
+ export const ZCODE_ADAPTER = {
153
+ id: 'zcode',
154
+ displayName: 'z-code',
155
+ settings: {
156
+ dirName: '.zcode', // VERIFIED — 实测 z-code 项目根目录是 `.zcode`
157
+ settingsFileName: 'settings.json',
158
+ resolveSettingsFile: (scope, projectRoot) => {
159
+ const root = scope === 'global' ? homedir() : resolve(projectRoot ?? homedir());
160
+ return join(root, '.zcode', 'settings.json');
161
+ },
162
+ supportsScope: (scope) => scope === 'project' || scope === 'global',
163
+ },
164
+ // UNVERIFIED — z-code 桌面应用未公开 `ZCODE_PROJECT_DIR` env var,这里
165
+ // 用占位符,RD 阶段假设 z-code 借用了 Anthropic-compatible 协议并可能
166
+ // 注入一个项目根 env 变量。若未注入,峰 router 会回退到 cwd 探测。
167
+ envVar: 'ZCODE_PROJECT_DIR',
168
+ // UNVERIFIED — z-code 未公开 hook event key,占位用 Anthropic-compatible
169
+ // 协议的常见命名 `PreToolUse`。若 z-code 用其他 key,peaks hooks install
170
+ // 钩子在此 IDE 上不生效,可由 user 通过手动导入绕过。
171
+ hookEvent: 'PreToolUse',
172
+ // UNVERIFIED — 同上,占位用 `Bash` matcher。
173
+ toolMatcher: 'Bash',
174
+ // Slice 2026-07-09: z-code 桌面应用没有公开 Task 工具 dispatch 协议,
175
+ // 用 `nullSubAgentDispatcher` 占位 — peaks sub-agent dispatch CLI 在
176
+ // z-code 上返回 SubAgentNotSupportedError,符合 slice #008 P-5 capability
177
+ // 检查契约 (CLI 不崩溃)。
178
+ subAgentDispatcher: nullSubAgentDispatcher,
179
+ // Slice #010 G9: 假设 z-code 支持 PreToolUse (UNVERIFIED)。Opt-in,等真实
180
+ // z-code 安装 dogfood 后再决定。若 z-code 不支持 G9 hook,CLI 兜底层仍会
181
+ // 强制执行 G9 阈值。
182
+ promptSizeAware: true,
183
+ installHints: [
184
+ 'z-code 没有 CLI,因此无需重启 — skills symlink 已写入 ~/.zcode/skills/。',
185
+ '若 z-code 未自动加载 peaks-* skills,在 z-code 桌面应用里手动触发 "导入 skills" 功能即可。',
186
+ ],
187
+ capabilities: {
188
+ gateEnforce: true,
189
+ statusline: true,
190
+ },
191
+ // Standards profile: 路径常量保留 (z-code 的 "导入 skills" 功能
192
+ // 借用了上游 settings 目录,因此宪法文件路径常量同步保留)。
193
+ standardsProfile: {
194
+ rootFile: 'CLAUDE.md',
195
+ rulesDir: '.claude/rules',
196
+ rulesFileGlob: '**/*.md',
197
+ autoLoaded: true,
198
+ format: 'markdown',
199
+ migrationHint: 'z-code 借用了上游 standards 路径,无需迁移。',
200
+ },
201
+ // Skill install profile: 指向 `~/.zcode/skills` 系列 (实测 z-code 已通过
202
+ // user 手动 symlink 同步;peaks-loop 也直接支持)。
203
+ skillInstall: {
204
+ skillsDir: join(homedir(), '.zcode', 'skills'),
205
+ outputStylesDir: join(homedir(), '.zcode', 'output-styles'),
206
+ installStrategy: 'symlink',
207
+ envVarOverride: 'PEAKS_ZCODE_SKILLS_DIR',
208
+ },
209
+ // compact profile 留空 — z-code 没有 CLI binary (RD-3 §2.2 D1 决策)。
210
+ // peaks-loop 走 `llm-self-compress` fallback 路径 (LLM 自己总结 context),
211
+ // 不抛错、不警告 (符合 slice #008 P-5 capability-check contract)。
212
+ // Slice C (2026-07-09 add-zcode-adapter, C.2): runtime probe for
213
+ // the currently-active model id. Reads `~/.zcode/v2/config.json`
214
+ // and resolves the active provider via `resolveZcodeCurrentModel`.
215
+ // See `.peaks/_runtime/2026-07-08-session-17918f/qa/.../slice-C-completion.md`
216
+ // for the resolution priority chain (env override → non-builtin
217
+ // provider → first enabled → first provider). Returns undefined
218
+ // on any failure (consistent with the cross-IDE contract).
219
+ detectCurrentModel: detectZcodeCurrentModel,
220
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Probe every registered IDE adapter for its currently-active model
3
+ * id. Returns the first non-empty value found in registration order,
4
+ * or `undefined` when no adapter opts in / all opted-in adapters
5
+ * return undefined.
6
+ *
7
+ * Registration order: see `ide-registry.ts` ADAPTERS Map. The IDE
8
+ * adapter for `claude-code` is consulted first; only `zcode` opts in
9
+ * to this slice's detection (other adapters will return undefined).
10
+ *
11
+ * Async because each IDE adapter's detectCurrentModel may read local
12
+ * disk or env on a slow filesystem.
13
+ */
14
+ export declare function detectCurrentIdeModel(): Promise<string | undefined>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Runtime current-model detector (Slice 2026-07-09 add-zcode-adapter, Slice C).
3
+ *
4
+ * Walks the registered IDE adapter chain and asks the IDE adapter
5
+ * (when it opts in via `IdeAdapter.detectCurrentModel?`) to report the
6
+ * currently-active model id. This is the runtime sibling of
7
+ * `getStrongestModelId(config)` in `src/services/config/model-routing.ts`:
8
+ *
9
+ * - `getStrongestModelId(config)` reads `config.model` (user-set) and
10
+ * falls back to the env-var / `'claude-opus-4-7'` constant. Sync.
11
+ * - `detectCurrentIdeModel()` reads the IDE's runtime state (which
12
+ * provider UI is active, which model the picker shows). Async.
13
+ *
14
+ * The CLI surface `peaks ide model --current` (added in this slice)
15
+ * delegates here. The future async resolver at
16
+ * `getStrongestModelIdAsync` (Slice C §C.4) calls this so config-less
17
+ * runs (e.g. an LLM inside z-code) can derive the strongest model from
18
+ * its actual environment.
19
+ *
20
+ * Vendor-neutrality notes (SC-3 §5 + RD-3 §2.4):
21
+ * - This file never names a vendor (no "claude", "zcode", etc.).
22
+ * - It only walks `IdeAdapter.detectCurrentModel?` — any adapter
23
+ * can opt in.
24
+ * - It returns `undefined` when detection is unavailable; callers
25
+ * must fall back to the configured / hardcoded model.
26
+ */
27
+ import { getAdapter } from './ide-registry.js';
28
+ import { listAdapterIds } from './ide-registry.js';
29
+ /**
30
+ * Probe every registered IDE adapter for its currently-active model
31
+ * id. Returns the first non-empty value found in registration order,
32
+ * or `undefined` when no adapter opts in / all opted-in adapters
33
+ * return undefined.
34
+ *
35
+ * Registration order: see `ide-registry.ts` ADAPTERS Map. The IDE
36
+ * adapter for `claude-code` is consulted first; only `zcode` opts in
37
+ * to this slice's detection (other adapters will return undefined).
38
+ *
39
+ * Async because each IDE adapter's detectCurrentModel may read local
40
+ * disk or env on a slow filesystem.
41
+ */
42
+ export async function detectCurrentIdeModel() {
43
+ for (const id of listAdapterIds()) {
44
+ const adapter = getAdapter(id);
45
+ if (typeof adapter.detectCurrentModel !== 'function')
46
+ continue;
47
+ try {
48
+ const modelId = await adapter.detectCurrentModel();
49
+ if (typeof modelId === 'string' && modelId.trim().length > 0) {
50
+ return modelId.trim();
51
+ }
52
+ }
53
+ catch {
54
+ // Per-SC §3.4: detection is best-effort. Swallow errors so a
55
+ // misconfigured adapter cannot poison the chain; the next
56
+ // adapter still gets a chance.
57
+ }
58
+ }
59
+ return undefined;
60
+ }
@@ -4,6 +4,7 @@ import { CURSOR_ADAPTER } from './adapters/cursor-adapter.js';
4
4
  import { CODEX_ADAPTER } from './adapters/codex-adapter.js';
5
5
  import { HERMES_ADAPTER } from './adapters/hermes-adapter.js';
6
6
  import { OPENCLAW_ADAPTER } from './adapters/openclaw-adapter.js';
7
+ import { ZCODE_ADAPTER } from './adapters/zcode-adapter.js';
7
8
  /**
8
9
  * Built-in IDE adapter registry。Map<IdeId, IdeAdapter> 是单一来源。
9
10
  *
@@ -24,6 +25,7 @@ const ADAPTERS = new Map([
24
25
  ['codex', CODEX_ADAPTER],
25
26
  ['hermes', HERMES_ADAPTER],
26
27
  ['openclaw', OPENCLAW_ADAPTER],
28
+ ['zcode', ZCODE_ADAPTER],
27
29
  ]);
28
30
  /** Get the adapter for a given IDE id. Throws on unsupported IDE. */
29
31
  export function getAdapter(ide) {
@@ -57,4 +59,5 @@ export function _resetAdaptersForTesting() {
57
59
  ADAPTERS.set('codex', CODEX_ADAPTER);
58
60
  ADAPTERS.set('hermes', HERMES_ADAPTER);
59
61
  ADAPTERS.set('openclaw', OPENCLAW_ADAPTER);
62
+ ADAPTERS.set('zcode', ZCODE_ADAPTER);
60
63
  }
@@ -12,7 +12,7 @@
12
12
  * 其他全部归一化到 peaks 内部模型(见 hook-protocol.ts)。
13
13
  */
14
14
  import type { SubAgentDispatcher } from '../dispatch/sub-agent-dispatcher.js';
15
- export type IdeId = 'claude-code' | 'trae' | 'codex' | 'cursor' | 'qoder' | 'tongyi-lingma' | 'hermes' | 'openclaw';
15
+ export type IdeId = 'claude-code' | 'trae' | 'codex' | 'cursor' | 'qoder' | 'tongyi-lingma' | 'hermes' | 'openclaw' | 'zcode';
16
16
  export interface IdeCapabilities {
17
17
  /** peaks gate enforce 是否适用该 IDE(必备) */
18
18
  readonly gateEnforce: true;
@@ -125,6 +125,29 @@ export interface IdeAdapter {
125
125
  * `.peaks/memory/2026-06-27-auto-compact-design.md`.
126
126
  */
127
127
  readonly compact?: IdeCompactProfile;
128
+ /**
129
+ * Optional runtime probe for the IDE's currently-active model id.
130
+ * When defined, `detectCurrentIdeModel()` in
131
+ * `src/services/ide/current-model-detector.ts` calls this to
132
+ * discover which model the IDE UI is currently configured for —
133
+ * e.g. so `peaks ide model --current` and the future
134
+ * `getStrongestModelIdAsync()` can use the same value without
135
+ * forcing the user to set `config.model` manually.
136
+ *
137
+ * Returning `undefined` (or the method being absent on this
138
+ * adapter) means "I don't know — fall back to the configured
139
+ * model / env var / back-compat default".
140
+ *
141
+ * Implementations may read local files (`fs.readFileSync`),
142
+ * env vars, or both. They MUST NOT throw on missing files — catch
143
+ * locally and return `undefined`. The orchestration layer also
144
+ * catches, but defense in depth.
145
+ *
146
+ * Added in slice 2026-07-09 add-zcode-adapter (Slice C); only the
147
+ * `zcode` adapter opts in this slice. Future slices can opt in
148
+ * other IDEs without changing this signature (optional method).
149
+ */
150
+ readonly detectCurrentModel?: () => Promise<string | undefined>;
128
151
  }
129
152
  /**
130
153
  * Per-IDE auto-compact capability descriptor. See `IdeAdapter.compact`
@@ -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
+ }