peaks-cli 1.3.2 → 1.3.4

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 (115) hide show
  1. package/README.md +6 -2
  2. package/dist/src/cli/commands/core-artifact-commands.js +6 -3
  3. package/dist/src/cli/commands/gate-commands.js +28 -19
  4. package/dist/src/cli/commands/hook-handle.d.ts +17 -0
  5. package/dist/src/cli/commands/hook-handle.js +111 -0
  6. package/dist/src/cli/commands/hooks-commands.js +72 -21
  7. package/dist/src/cli/commands/progress-commands.js +9 -2
  8. package/dist/src/cli/commands/progress-start-spawn.js +30 -4
  9. package/dist/src/cli/commands/project-commands.js +8 -4
  10. package/dist/src/cli/commands/statusline-commands.js +75 -17
  11. package/dist/src/cli/commands/sub-agent-commands.d.ts +5 -0
  12. package/dist/src/cli/commands/sub-agent-commands.js +488 -0
  13. package/dist/src/cli/commands/sub-agent-dispatch-guard.d.ts +55 -0
  14. package/dist/src/cli/commands/sub-agent-dispatch-guard.js +57 -0
  15. package/dist/src/cli/commands/workflow-commands.js +2 -1
  16. package/dist/src/cli/commands/workspace-commands.js +3 -0
  17. package/dist/src/cli/program.js +9 -0
  18. package/dist/src/hooks/pre-tool-use-sub-agent.d.ts +28 -0
  19. package/dist/src/hooks/pre-tool-use-sub-agent.js +105 -0
  20. package/dist/src/services/config/config-types.d.ts +1 -1
  21. package/dist/src/services/context/artifact-meta.d.ts +72 -0
  22. package/dist/src/services/context/artifact-meta.js +105 -0
  23. package/dist/src/services/context/context-guard.d.ts +49 -0
  24. package/dist/src/services/context/context-guard.js +91 -0
  25. package/dist/src/services/context/dispatch-context-guard.d.ts +27 -0
  26. package/dist/src/services/context/dispatch-context-guard.js +192 -0
  27. package/dist/src/services/context/headroom-client.d.ts +34 -0
  28. package/dist/src/services/context/headroom-client.js +117 -0
  29. package/dist/src/services/context/shared-channel.d.ts +92 -0
  30. package/dist/src/services/context/shared-channel.js +285 -0
  31. package/dist/src/services/context/threshold.d.ts +35 -0
  32. package/dist/src/services/context/threshold.js +76 -0
  33. package/dist/src/services/dashboard/project-dashboard-service.d.ts +23 -0
  34. package/dist/src/services/dashboard/project-dashboard-service.js +21 -0
  35. package/dist/src/services/dispatch/batch-counter.d.ts +27 -0
  36. package/dist/src/services/dispatch/batch-counter.js +85 -0
  37. package/dist/src/services/dispatch/dispatch-record-writer.d.ts +93 -0
  38. package/dist/src/services/dispatch/dispatch-record-writer.js +261 -0
  39. package/dist/src/services/dispatch/heartbeat-truncator.d.ts +26 -0
  40. package/dist/src/services/dispatch/heartbeat-truncator.js +13 -0
  41. package/dist/src/services/dispatch/leak-detector.d.ts +11 -0
  42. package/dist/src/services/dispatch/leak-detector.js +72 -0
  43. package/dist/src/services/dispatch/sub-agent-dispatcher.d.ts +127 -0
  44. package/dist/src/services/dispatch/sub-agent-dispatcher.js +98 -0
  45. package/dist/src/services/ide/adapters/claude-code-adapter.d.ts +18 -0
  46. package/dist/src/services/ide/adapters/claude-code-adapter.js +80 -0
  47. package/dist/src/services/ide/adapters/trae-adapter.d.ts +42 -0
  48. package/dist/src/services/ide/adapters/trae-adapter.js +98 -0
  49. package/dist/src/services/ide/hook-protocol.d.ts +47 -0
  50. package/dist/src/services/ide/hook-protocol.js +74 -0
  51. package/dist/src/services/ide/hook-translator.d.ts +72 -0
  52. package/dist/src/services/ide/hook-translator.js +128 -0
  53. package/dist/src/services/ide/ide-detector.d.ts +10 -0
  54. package/dist/src/services/ide/ide-detector.js +19 -0
  55. package/dist/src/services/ide/ide-registry.d.ts +14 -0
  56. package/dist/src/services/ide/ide-registry.js +45 -0
  57. package/dist/src/services/ide/ide-types.d.ts +180 -0
  58. package/dist/src/services/ide/ide-types.js +2 -0
  59. package/dist/src/services/ide/resource-profile.d.ts +52 -0
  60. package/dist/src/services/ide/resource-profile.js +33 -0
  61. package/dist/src/services/ide/shared/atomic-json.d.ts +15 -0
  62. package/dist/src/services/ide/shared/atomic-json.js +58 -0
  63. package/dist/src/services/ide/shared/safe-path.d.ts +11 -0
  64. package/dist/src/services/ide/shared/safe-path.js +29 -0
  65. package/dist/src/services/memory/project-context-service.js +2 -1
  66. package/dist/src/services/memory/project-memory-service.js +4 -3
  67. package/dist/src/services/perf/perf-baseline-service.js +2 -1
  68. package/dist/src/services/progress/progress-service.d.ts +1 -1
  69. package/dist/src/services/progress/progress-service.js +18 -14
  70. package/dist/src/services/security/safe-settings-path.d.ts +12 -0
  71. package/dist/src/services/security/safe-settings-path.js +104 -0
  72. package/dist/src/services/session/getSessionDir.d.ts +1 -0
  73. package/dist/src/services/session/getSessionDir.js +27 -0
  74. package/dist/src/services/session/index.d.ts +1 -0
  75. package/dist/src/services/session/index.js +1 -0
  76. package/dist/src/services/signal/cancel-handler.d.ts +14 -0
  77. package/dist/src/services/signal/cancel-handler.js +76 -0
  78. package/dist/src/services/skill/resume-detector.d.ts +54 -0
  79. package/dist/src/services/skill/resume-detector.js +334 -0
  80. package/dist/src/services/skill/skill-scheduler.d.ts +40 -0
  81. package/dist/src/services/skill/skill-scheduler.js +53 -0
  82. package/dist/src/services/skills/hooks-settings-service.d.ts +47 -29
  83. package/dist/src/services/skills/hooks-settings-service.js +190 -144
  84. package/dist/src/services/skills/statusline-settings-service.d.ts +33 -6
  85. package/dist/src/services/skills/statusline-settings-service.js +31 -34
  86. package/dist/src/services/slice/slice-archive-service.d.ts +20 -0
  87. package/dist/src/services/slice/slice-archive-service.js +111 -0
  88. package/dist/src/services/solo/batch-heartbeat-poller.d.ts +51 -0
  89. package/dist/src/services/solo/batch-heartbeat-poller.js +88 -0
  90. package/dist/src/services/solo/status-line-renderer.d.ts +34 -0
  91. package/dist/src/services/solo/status-line-renderer.js +55 -0
  92. package/dist/src/services/standards/ide-aware-standards-service.d.ts +94 -0
  93. package/dist/src/services/standards/ide-aware-standards-service.js +89 -0
  94. package/dist/src/services/standards/project-standards-service.d.ts +1 -2
  95. package/dist/src/services/workspace/reconcile-service.d.ts +36 -0
  96. package/dist/src/services/workspace/reconcile-service.js +107 -6
  97. package/dist/src/services/workspace/reconcile-types.d.ts +12 -0
  98. package/dist/src/shared/version.d.ts +1 -1
  99. package/dist/src/shared/version.js +1 -1
  100. package/package.json +2 -1
  101. package/scripts/install-skills.mjs +112 -2
  102. package/skills/peaks-ide/SKILL.md +159 -0
  103. package/skills/peaks-ide/references/audit-log-helper.md +52 -0
  104. package/skills/peaks-qa/SKILL.md +153 -55
  105. package/skills/peaks-qa/references/qa-fanout-contract.md +150 -0
  106. package/skills/peaks-rd/SKILL.md +134 -62
  107. package/skills/peaks-solo/SKILL.md +124 -37
  108. package/skills/peaks-solo/references/browser-workflow.md +22 -20
  109. package/skills/peaks-solo/references/context-governance.md +144 -0
  110. package/skills/peaks-solo/references/headroom-integration.md +107 -0
  111. package/skills/peaks-solo/references/runbook.md +3 -3
  112. package/skills/peaks-solo/references/sub-agent-dispatch.md +261 -0
  113. package/skills/peaks-solo/references/swarm-dispatch-contract.md +3 -37
  114. package/skills/peaks-txt/SKILL.md +17 -0
  115. package/skills/peaks-ui/SKILL.md +45 -10
@@ -0,0 +1,10 @@
1
+ import type { IdeId } from './ide-types.js';
2
+ /**
3
+ * Detect which IDE a given project root is using, by looking for the IDE's
4
+ * settings directory (`.claude`, `.trae`, etc.). Returns the first match in
5
+ * adapter insertion order, or `null` if no adapter's directory is present.
6
+ *
7
+ * 启发式:基于 cwd 目录存在性。CLI 后续 slice 会扩展为 env 变量检测、settings
8
+ * 文件内容检测、显式 `--ide` flag 覆盖等。
9
+ */
10
+ export declare function detectInstalledIde(projectRoot: string): IdeId | null;
@@ -0,0 +1,19 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { listAdapters } from './ide-registry.js';
4
+ /**
5
+ * Detect which IDE a given project root is using, by looking for the IDE's
6
+ * settings directory (`.claude`, `.trae`, etc.). Returns the first match in
7
+ * adapter insertion order, or `null` if no adapter's directory is present.
8
+ *
9
+ * 启发式:基于 cwd 目录存在性。CLI 后续 slice 会扩展为 env 变量检测、settings
10
+ * 文件内容检测、显式 `--ide` flag 覆盖等。
11
+ */
12
+ export function detectInstalledIde(projectRoot) {
13
+ for (const adapter of listAdapters()) {
14
+ if (existsSync(join(projectRoot, adapter.settings.dirName))) {
15
+ return adapter.id;
16
+ }
17
+ }
18
+ return null;
19
+ }
@@ -0,0 +1,14 @@
1
+ import type { IdeAdapter, IdeId } from './ide-types.js';
2
+ /** Get the adapter for a given IDE id. Throws on unsupported IDE. */
3
+ export declare function getAdapter(ide: IdeId): IdeAdapter;
4
+ /** All registered adapter ids (insertion order). */
5
+ export declare function listAdapterIds(): readonly IdeId[];
6
+ /** All registered adapters (insertion order). */
7
+ export declare function listAdapters(): readonly IdeAdapter[];
8
+ /**
9
+ * Test seam: register or replace an adapter. Used by future slices when adding
10
+ * a new IDE. Caller is responsible for ensuring the adapter is well-formed.
11
+ */
12
+ export declare function _setAdapterForTesting(ide: IdeId, adapter: IdeAdapter): void;
13
+ /** Test seam: reset to built-in defaults. */
14
+ export declare function _resetAdaptersForTesting(): void;
@@ -0,0 +1,45 @@
1
+ import { CLAUDE_CODE_ADAPTER } from './adapters/claude-code-adapter.js';
2
+ import { TRAE_ADAPTER } from './adapters/trae-adapter.js';
3
+ /**
4
+ * Built-in IDE adapter registry。Map<IdeId, IdeAdapter> 是单一来源。
5
+ *
6
+ * Slice #1 注册 claude-code。
7
+ * Slice #2 注册 trae —— 这是 slice #1 抽出的 IdeAdapter 形状的
8
+ * 第一个真实客户,验证"填表"承诺。
9
+ * 后续 slice 注入 codex / cursor / qoder / tongyi-lingma 时,只需在此
10
+ * Map 加条目 —— 所有 adapter 使用方(hook-translator、hooks install、statusline
11
+ * install、mcp apply)通过 `getAdapter(ide)` 拿取,无需修改。
12
+ */
13
+ const ADAPTERS = new Map([
14
+ ['claude-code', CLAUDE_CODE_ADAPTER],
15
+ ['trae', TRAE_ADAPTER],
16
+ ]);
17
+ /** Get the adapter for a given IDE id. Throws on unsupported IDE. */
18
+ export function getAdapter(ide) {
19
+ const adapter = ADAPTERS.get(ide);
20
+ if (!adapter) {
21
+ throw new Error(`Unsupported IDE: ${ide}. Registered: ${listAdapterIds().join(', ') || '(none)'}`);
22
+ }
23
+ return adapter;
24
+ }
25
+ /** All registered adapter ids (insertion order). */
26
+ export function listAdapterIds() {
27
+ return Array.from(ADAPTERS.keys());
28
+ }
29
+ /** All registered adapters (insertion order). */
30
+ export function listAdapters() {
31
+ return Array.from(ADAPTERS.values());
32
+ }
33
+ /**
34
+ * Test seam: register or replace an adapter. Used by future slices when adding
35
+ * a new IDE. Caller is responsible for ensuring the adapter is well-formed.
36
+ */
37
+ export function _setAdapterForTesting(ide, adapter) {
38
+ ADAPTERS.set(ide, adapter);
39
+ }
40
+ /** Test seam: reset to built-in defaults. */
41
+ export function _resetAdaptersForTesting() {
42
+ ADAPTERS.clear();
43
+ ADAPTERS.set('claude-code', CLAUDE_CODE_ADAPTER);
44
+ ADAPTERS.set('trae', TRAE_ADAPTER);
45
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * peaks 自有 hook 协议 + slim IDE adapter 接口。
3
+ *
4
+ * peaks-cli 不再适配 IDE 私有的 hook 协议;反之 peaks 定义自己的 canonical
5
+ * schema,每个 IDE 只需要填 4 字符串 + 1 settings 函数,新 IDE 适配变成"填表"。
6
+ *
7
+ * 不可消除的 per-IDE 字段(诚实交代,见 PRD R-1..R-4):
8
+ * - settings.json 物理位置
9
+ * - 项目根 env 变量名
10
+ * - hook 事件名 + matcher 名
11
+ *
12
+ * 其他全部归一化到 peaks 内部模型(见 hook-protocol.ts)。
13
+ */
14
+ import type { SubAgentDispatcher } from '../dispatch/sub-agent-dispatcher.js';
15
+ export type IdeId = 'claude-code' | 'trae' | 'codex' | 'cursor' | 'qoder' | 'tongyi-lingma';
16
+ export interface IdeCapabilities {
17
+ /** peaks gate enforce 是否适用该 IDE(必备) */
18
+ readonly gateEnforce: true;
19
+ /** peaks progress start(sub-agent 派发)是否适用 */
20
+ readonly progressStart: boolean;
21
+ /** peaks statusline 状态栏是否适用 */
22
+ readonly statusline: boolean;
23
+ /** peaks mcp install 是否适用 */
24
+ readonly mcpInstall: boolean;
25
+ }
26
+ export interface IdeSettingsLocation {
27
+ /** 项目根下的 settings 目录名,例如 '.claude' / '.trae' / '.cursor' */
28
+ readonly dirName: string;
29
+ /** settings 文件名(部分 IDE 叫 settings.json / mcp.json) */
30
+ readonly settingsFileName: string;
31
+ /** 解析出 settings.json 绝对路径 */
32
+ resolveSettingsFile(scope: 'project' | 'global', projectRoot: string | undefined): string;
33
+ /** 该 IDE 是否支持此 scope(用于清晰报错) */
34
+ supportsScope(scope: 'project' | 'global'): boolean;
35
+ }
36
+ /**
37
+ * Slim IDE adapter 描述。每 IDE 一个静态常量(无需 DI)。
38
+ * 字段故意保持少:让 adapter 的添加是"填表"而非"重写"。
39
+ */
40
+ export interface IdeAdapter {
41
+ readonly id: IdeId;
42
+ /** 人类可读名,出现在 CLI help / 命令输出 */
43
+ readonly displayName: string;
44
+ readonly settings: IdeSettingsLocation;
45
+ /** IDE 注入的项目根 env 变量名;`peaks gate enforce` 等命令模板会引用此 env */
46
+ readonly envVar: string;
47
+ /** settings.json 里 hook 数组的 key,例如 'PreToolUse' / 'beforeToolCall' */
48
+ readonly hookEvent: string;
49
+ /** hook 数组元素的 matcher 字段(工具名匹配),例如 'Bash' / 'Task' / 'terminal' */
50
+ readonly toolMatcher: string;
51
+ /**
52
+ * The tool name used by this IDE to invoke a sub-agent (e.g. Claude Code
53
+ * uses 'Task' to dispatch a sub-agent, Trae may use a different name).
54
+ * Consumed by the `peaks progress start` hook entry so each IDE self-
55
+ * reports its sub-agent tool name. Additive on `toolMatcher`: the
56
+ * `toolMatcher` field still drives the gate-enforce hook entry, this
57
+ * one drives the sub-agent-progress hook entry.
58
+ *
59
+ * Added in slice 2026-06-06-sub-agent-spawn-bug-and-decouple.
60
+ */
61
+ readonly subAgentToolMatcher: string;
62
+ /**
63
+ * Per-IDE sub-agent dispatcher. The `peaks sub-agent dispatch` CLI reads
64
+ * this field, calls `supportsRole` + `buildToolCall`, and returns the
65
+ * resulting tool-call descriptor in the JSON envelope. Additive on
66
+ * `subAgentToolMatcher`: the matcher still drives the gate-enforce hook
67
+ * entry; this field drives the runtime sub-agent dispatch surface.
68
+ *
69
+ * Added in slice 2026-06-07-sub-agent-dispatch-decouple. See PRD #002
70
+ * G1 (AC-1, AC-2) + [[slim-ideadapter-shape-is-the-contract]].
71
+ */
72
+ readonly subAgentDispatcher: SubAgentDispatcher;
73
+ /**
74
+ * Per-IDE opt-in to the G9 prompt-size gate. When `true`, the
75
+ * `peaks hooks install` command registers the G9 PreToolUse hook
76
+ * (`peaks sub-agent-dispatch-guard`) for this IDE. When `false`,
77
+ * the hook is NOT installed (the IDE either doesn't support the
78
+ * PreToolUse event in a useful form, or the user has opted out).
79
+ *
80
+ * The CLI 兜底 layer in `peaks sub-agent dispatch` still enforces
81
+ * the threshold regardless of this field — `promptSizeAware` only
82
+ * controls the hook layer (R-15: G9 hook is LLM-platform-specific).
83
+ *
84
+ * Added in slice 2026-06-07-sub-agent-context-governance. See PRD
85
+ * #003 G9.2 + AC-56. Default `false` to preserve slice #009's
86
+ * `peaks hooks install` output byte-stability.
87
+ */
88
+ readonly promptSizeAware: boolean;
89
+ /** install / uninstall 后展示给用户的提示文本(各 IDE 不同,例如 Claude 提示重启窗口) */
90
+ readonly installHints: readonly string[];
91
+ /** 该 IDE 在 peaks 上可启用的能力(用于在不支持的 IDE 上软警告) */
92
+ readonly capabilities: IdeCapabilities;
93
+ /**
94
+ * Where this IDE reads its project-level agent instructions from.
95
+ * When undefined, the postinstall + `peaks standards init` codepath falls
96
+ * back to the legacy Claude Code path (CLAUDE.md + .claude/rules/**)
97
+ * AND emits a stderr warning. Adapters in slice 1.3.2 declare this
98
+ * value (Claude Code), are annotated UNVERIFIED for future slices
99
+ * (Trae, slice #012+), or omit it entirely (not-yet-registered IDEs).
100
+ *
101
+ * Added in slice 011-2026-06-07-ide-adapter-resource-profile.
102
+ */
103
+ readonly standardsProfile?: IdeStandardsProfile;
104
+ /**
105
+ * Where `scripts/install-skills.mjs` symlinks the bundled skills +
106
+ * output styles. When undefined, the postinstall falls back to
107
+ * `~/.claude/skills` + `~/.claude/output-styles` (legacy) AND emits
108
+ * a stderr warning. Adapters that opt into the dispatch layer fill
109
+ * this; adapters that don't (Trae in slice 1.3.2) leave it undefined
110
+ * and follow the legacy path with a warning.
111
+ *
112
+ * Added in slice 011-2026-06-07-ide-adapter-resource-profile.
113
+ */
114
+ readonly skillInstall?: IdeSkillInstall;
115
+ }
116
+ /**
117
+ * Per-IDE standards-file location + format profile. Used by the
118
+ * `peaks standards init` dispatch layer (slice 011) to write the
119
+ * project-level standards files at the IDE-specific path, not the
120
+ * Claude Code hardcoded one. Adapters that omit this field trigger
121
+ * the legacy Claude Code path with a stderr warning.
122
+ */
123
+ export interface IdeStandardsProfile {
124
+ /** Filename for the project-root constitution (e.g. 'CLAUDE.md'), or null if the IDE has no equivalent. */
125
+ readonly rootFile: string | null;
126
+ /** Directory for module-level rules (e.g. '.claude/rules'), or null if the IDE has no equivalent. */
127
+ readonly rulesDir: string | null;
128
+ /** Glob under rulesDir to enumerate rule files. */
129
+ readonly rulesFileGlob: string;
130
+ /** True if the IDE auto-loads these files at session start. */
131
+ readonly autoLoaded: boolean;
132
+ /** Output format. markdown = plain text; markdown+frontmatter = adds YAML frontmatter to each rule file. */
133
+ readonly format: 'markdown' | 'markdown+frontmatter';
134
+ /** Human-readable hint surfaced in the fallback warning. */
135
+ readonly migrationHint?: string;
136
+ }
137
+ /**
138
+ * Per-IDE postinstall target roots. The `scripts/install-skills.mjs`
139
+ * script consumes this to symlink the bundled skills + output styles
140
+ * to the IDE-specific install location, with back-compat for the
141
+ * legacy `PEAKS_CLAUDE_SKILLS_DIR` / `PEAKS_CLAUDE_OUTPUT_STYLES_DIR`
142
+ * env vars (precedence: explicit option > env var > IDE profile > legacy default).
143
+ */
144
+ export interface IdeSkillInstall {
145
+ /** Absolute path under which the postinstall script symlinks the bundled `skills/` directory. */
146
+ readonly skillsDir: string;
147
+ /** Absolute path under which the postinstall script writes the bundled `output-styles/`. Null if the IDE has no equivalent. */
148
+ readonly outputStylesDir: string | null;
149
+ /** Symlink strategy. */
150
+ readonly installStrategy: 'symlink' | 'copy';
151
+ /** Back-compat env var name (e.g. PEAKS_CLAUDE_SKILLS_DIR). Null if no env var is supported. */
152
+ readonly envVarOverride: string | null;
153
+ }
154
+ /** peaks canonical hook schema 版本标识 */
155
+ export declare const PEAKS_HOOK_SCHEMA: "peaks-hook/v1";
156
+ /** peaks canonical hook 形态 —— 单一协议,所有 IDE 经 hook-translator 归一化到此 */
157
+ export interface PeaksCanonicalHook {
158
+ readonly schema: typeof PEAKS_HOOK_SCHEMA;
159
+ readonly event: 'pre-tool-use' | 'post-tool-use' | 'sub-agent-start';
160
+ readonly toolName: string;
161
+ readonly toolInput: Record<string, unknown>;
162
+ /** 解析自 env 变量或 --project 的项目根 */
163
+ readonly projectRoot: string;
164
+ /** 选输出格式 */
165
+ readonly rawIdeFormat: IdeId;
166
+ /** 原始 stdin,留作回退 */
167
+ readonly rawPayload: unknown;
168
+ }
169
+ /** peaks 决策发回形态枚举(按 IDE 期望的"发回"形式) */
170
+ export type PeaksDecisionTransport = {
171
+ kind: 'stdout-json';
172
+ denyShape: Record<string, unknown>;
173
+ } | {
174
+ kind: 'exit-code';
175
+ denyCode: number;
176
+ } | {
177
+ kind: 'both';
178
+ denyShape: Record<string, unknown>;
179
+ denyCode: number;
180
+ };
@@ -0,0 +1,2 @@
1
+ /** peaks canonical hook schema 版本标识 */
2
+ export const PEAKS_HOOK_SCHEMA = 'peaks-hook/v1';
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Resource profile accessors for the per-IDE dispatch layer.
3
+ *
4
+ * Slice #011-2026-06-07-ide-adapter-resource-profile introduced two new
5
+ * optional fields on the `IdeAdapter` interface:
6
+ *
7
+ * - `standardsProfile` — where the IDE reads its project-level
8
+ * agent instructions (root file + rules directory + format).
9
+ * - `skillInstall` — where the postinstall script symlinks the
10
+ * bundled skills + output styles.
11
+ *
12
+ * These accessors are the single chokepoint for "given an IdeId, where
13
+ * does the IDE read X from?". The two consumers that consume them:
14
+ *
15
+ * 1. `src/services/standards/ide-aware-standards-service.ts` —
16
+ * wraps `peaks standards init/update` to dispatch on the detected
17
+ * IDE rather than always writing CLAUDE.md + .claude/rules/**.
18
+ * 2. `scripts/install-skills.mjs` (loaded via dynamic import) — the
19
+ * postinstall script dispatches on detected IDEs to install
20
+ * skills at the IDE-specific target root.
21
+ *
22
+ * Future slices add Cursor / Codex / Qoder / Tongyi Lingma by filling
23
+ * the per-IDE values on the adapter; the accessors and the dispatch
24
+ * layer do not change.
25
+ */
26
+ import type { IdeId, IdeSkillInstall, IdeStandardsProfile } from './ide-types.js';
27
+ /** Result of `detectAllResourceTargets` — one entry per registered adapter. */
28
+ export interface ResourceTarget {
29
+ readonly ideId: IdeId;
30
+ readonly standardsProfile: IdeStandardsProfile | null;
31
+ readonly skillInstall: IdeSkillInstall | null;
32
+ }
33
+ /**
34
+ * Look up the standards-file profile for a given IDE. Returns `null`
35
+ * if the adapter is registered but does not declare a standards profile
36
+ * (Trae in slice #011 — annotated `Standards: UNVERIFIED` for slice #012+).
37
+ * Throws if the IDE id is not registered at all.
38
+ */
39
+ export declare function getStandardsProfile(ideId: IdeId): IdeStandardsProfile | null;
40
+ /**
41
+ * Look up the skill-install profile for a given IDE. Returns `null`
42
+ * if the adapter does not declare one (Trae in slice #011). Throws
43
+ * if the IDE id is not registered.
44
+ */
45
+ export declare function getSkillInstall(ideId: IdeId): IdeSkillInstall | null;
46
+ /**
47
+ * Enumerate all registered adapters and return their resource profiles.
48
+ * Used by `install-skills.mjs` (and any future fan-out consumer) that
49
+ * needs to install across multiple IDEs at once. Returns the profiles
50
+ * in adapter insertion order.
51
+ */
52
+ export declare function detectAllResourceTargets(): readonly ResourceTarget[];
@@ -0,0 +1,33 @@
1
+ import { getAdapter, listAdapterIds } from './ide-registry.js';
2
+ /**
3
+ * Look up the standards-file profile for a given IDE. Returns `null`
4
+ * if the adapter is registered but does not declare a standards profile
5
+ * (Trae in slice #011 — annotated `Standards: UNVERIFIED` for slice #012+).
6
+ * Throws if the IDE id is not registered at all.
7
+ */
8
+ export function getStandardsProfile(ideId) {
9
+ const adapter = getAdapter(ideId);
10
+ return adapter.standardsProfile ?? null;
11
+ }
12
+ /**
13
+ * Look up the skill-install profile for a given IDE. Returns `null`
14
+ * if the adapter does not declare one (Trae in slice #011). Throws
15
+ * if the IDE id is not registered.
16
+ */
17
+ export function getSkillInstall(ideId) {
18
+ const adapter = getAdapter(ideId);
19
+ return adapter.skillInstall ?? null;
20
+ }
21
+ /**
22
+ * Enumerate all registered adapters and return their resource profiles.
23
+ * Used by `install-skills.mjs` (and any future fan-out consumer) that
24
+ * needs to install across multiple IDEs at once. Returns the profiles
25
+ * in adapter insertion order.
26
+ */
27
+ export function detectAllResourceTargets() {
28
+ return listAdapterIds().map((ideId) => ({
29
+ ideId,
30
+ standardsProfile: getStandardsProfile(ideId),
31
+ skillInstall: getSkillInstall(ideId),
32
+ }));
33
+ }
@@ -0,0 +1,15 @@
1
+ export declare const ATOMIC_JSON_FILE_MODE = 384;
2
+ /**
3
+ * Read a JSON object file using a no-follow open. Returns an empty object when
4
+ * the file does not exist or is empty. Throws when the file exists but does not
5
+ * contain a JSON object (so callers can distinguish "no settings" from
6
+ * "malformed settings").
7
+ */
8
+ export declare function readJsonObjectFile<T extends Record<string, unknown> = Record<string, unknown>>(filePath: string): T;
9
+ /**
10
+ * Atomically write a JSON file: create a unique temp file in the same
11
+ * directory, fsync its contents via close, then `rename` over the target. A
12
+ * failure during rename removes the temp file (best effort). The target is
13
+ * created with 0o600 permissions.
14
+ */
15
+ export declare function atomicWriteJson(filePath: string, value: unknown): void;
@@ -0,0 +1,58 @@
1
+ import { closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { dirname, join } from 'node:path';
4
+ export const ATOMIC_JSON_FILE_MODE = 0o600;
5
+ /**
6
+ * Read a JSON object file using a no-follow open. Returns an empty object when
7
+ * the file does not exist or is empty. Throws when the file exists but does not
8
+ * contain a JSON object (so callers can distinguish "no settings" from
9
+ * "malformed settings").
10
+ */
11
+ export function readJsonObjectFile(filePath) {
12
+ if (!existsSync(filePath))
13
+ return {};
14
+ const fd = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
15
+ try {
16
+ const raw = readFileSync(fd, 'utf8').trim();
17
+ if (raw.length === 0)
18
+ return {};
19
+ const parsed = JSON.parse(raw);
20
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
21
+ throw new Error('settings file must contain a JSON object');
22
+ }
23
+ return parsed;
24
+ }
25
+ finally {
26
+ closeSync(fd);
27
+ }
28
+ }
29
+ /**
30
+ * Atomically write a JSON file: create a unique temp file in the same
31
+ * directory, fsync its contents via close, then `rename` over the target. A
32
+ * failure during rename removes the temp file (best effort). The target is
33
+ * created with 0o600 permissions.
34
+ */
35
+ export function atomicWriteJson(filePath, value) {
36
+ const dir = dirname(filePath);
37
+ mkdirSync(dir, { recursive: true });
38
+ const tempPath = join(dir, `.settings.${randomUUID()}.tmp`);
39
+ const fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, ATOMIC_JSON_FILE_MODE);
40
+ try {
41
+ writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
42
+ }
43
+ finally {
44
+ closeSync(fd);
45
+ }
46
+ try {
47
+ renameSync(tempPath, filePath);
48
+ }
49
+ catch (error) {
50
+ try {
51
+ unlinkSync(tempPath);
52
+ }
53
+ catch {
54
+ // best effort cleanup
55
+ }
56
+ throw error;
57
+ }
58
+ }
@@ -0,0 +1,11 @@
1
+ export type HookScope = 'project' | 'global';
2
+ /** True iff `childPath` resolves to `parentPath` or any path nested inside it. */
3
+ export declare function isInsidePath(childPath: string, parentPath: string): boolean;
4
+ /**
5
+ * Reject settings targets that are symlinked or escape the configured root.
6
+ * Used by the hook / statusline / MCP install paths to keep the project root the
7
+ * sole owner of writable settings files.
8
+ */
9
+ export declare function assertSafeSettingsFile(scope: HookScope, root: string, dirName: string, settingsFileName: string): {
10
+ settingsPath: string;
11
+ };
@@ -0,0 +1,29 @@
1
+ import { existsSync, lstatSync, realpathSync } from 'node:fs';
2
+ import { isAbsolute, join, relative } from 'node:path';
3
+ /** True iff `childPath` resolves to `parentPath` or any path nested inside it. */
4
+ export function isInsidePath(childPath, parentPath) {
5
+ const rel = relative(parentPath, childPath);
6
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
7
+ }
8
+ /**
9
+ * Reject settings targets that are symlinked or escape the configured root.
10
+ * Used by the hook / statusline / MCP install paths to keep the project root the
11
+ * sole owner of writable settings files.
12
+ */
13
+ export function assertSafeSettingsFile(scope, root, dirName, settingsFileName) {
14
+ const settingsPath = join(root, dirName, settingsFileName);
15
+ const dirPath = join(root, dirName);
16
+ if (existsSync(dirPath) && lstatSync(dirPath).isSymbolicLink()) {
17
+ throw new Error(`${dirName} directory must not be a symlink`);
18
+ }
19
+ if (existsSync(settingsPath)) {
20
+ if (lstatSync(settingsPath).isSymbolicLink()) {
21
+ throw new Error(`${settingsFileName} must not be a symlink`);
22
+ }
23
+ const realRoot = realpathSync(root);
24
+ if (!isInsidePath(realpathSync(settingsPath), realRoot)) {
25
+ throw new Error(`${settingsFileName} must stay inside the ${scope} root`);
26
+ }
27
+ }
28
+ return { settingsPath };
29
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { listSessionMetas } from '../session/session-manager.js';
4
+ import { getSessionDir } from '../session/getSessionDir.js';
4
5
  const PROJECT_CONTEXT_FILE = '.peaks/PROJECT.md';
5
6
  const CONTEXT_HEADER = `# Peaks Project Context
6
7
 
@@ -89,7 +90,7 @@ function buildSessionHistory(projectRoot) {
89
90
  const title = (meta.title ?? 'Untitled').slice(0, 40);
90
91
  const skill = meta.skill ?? '-';
91
92
  // Extract one-line summary from artifacts for the "What" column
92
- const sessionRoot = join(projectRoot, '.peaks', meta.sessionId);
93
+ const sessionRoot = getSessionDir(projectRoot, meta.sessionId);
93
94
  const summary = extractOneLineSummary(sessionRoot);
94
95
  const brief = summary ? summary.slice(0, 70) : skill;
95
96
  body += `| ${date} | \`${dir}\` | ${title} | ${brief} |\n`;
@@ -2,6 +2,7 @@ import { closeSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, o
2
2
  import { dirname, basename, isAbsolute, join, relative, resolve } from 'node:path';
3
3
  import { isInsidePath, isWindowsAbsolutePath, normalizePath, resolveInputPath, stablePath, stableRealPath } from '../../shared/path-utils.js';
4
4
  import { containsSensitiveConfigValue, isSensitiveConfigPath } from '../config/config-service.js';
5
+ import { getSessionDir } from '../session/getSessionDir.js';
5
6
  // Hot kinds: full body kept in index for always-available context
6
7
  const HOT_KINDS = new Set(['feedback', 'decision', 'rule', 'convention', 'module', 'lesson']);
7
8
  // ---------------------------------------------------------------------------
@@ -237,13 +238,13 @@ function summarizeMemoryBody(body) {
237
238
  function assertSafeSessionDir(projectRoot, sessionId) {
238
239
  const normalizedRoot = normalizeRoot(projectRoot);
239
240
  const realRoot = normalizeRealRoot(projectRoot);
240
- const sessionDir = join(normalizedRoot, '.peaks', sessionId);
241
+ const sessionDir = getSessionDir(normalizedRoot, sessionId);
241
242
  if (!existsSync(sessionDir)) {
242
243
  // Distinguish "not found" (caller will treat as no-op) from "escapes project
243
244
  // root" (caller must surface a hard error). We probe by checking whether the
244
245
  // joined path, after realpath, would still be inside the project root.
245
- if (isAbsolute(join(normalizedRoot, '.peaks', sessionId))) {
246
- const realJoined = safeRealpath(join(normalizedRoot, '.peaks', sessionId));
246
+ if (isAbsolute(getSessionDir(normalizedRoot, sessionId))) {
247
+ const realJoined = safeRealpath(getSessionDir(normalizedRoot, sessionId));
247
248
  if (realJoined && !isInsidePath(realJoined, realRoot)) {
248
249
  throw new Error('Session directory must stay inside the project root');
249
250
  }
@@ -39,6 +39,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
39
39
  import { existsSync } from 'node:fs';
40
40
  import { join } from 'node:path';
41
41
  import { getSessionId } from '../session/session-manager.js';
42
+ import { getSessionDir } from '../session/getSessionDir.js';
42
43
  import { findProjectRoot } from '../config/config-safety.js';
43
44
  const README_BODY = `# Performance baseline
44
45
 
@@ -117,7 +118,7 @@ function renderBaselineTemplate() {
117
118
  function buildPlan(projectRoot, apply) {
118
119
  const sessionId = getSessionId(projectRoot);
119
120
  const sessionRoot = sessionId !== null
120
- ? join(projectRoot, '.peaks', sessionId)
121
+ ? getSessionDir(projectRoot, sessionId)
121
122
  : null;
122
123
  const perfBaselinePath = sessionRoot !== null
123
124
  ? join(sessionRoot, 'rd', 'perf-baseline.md')
@@ -2,7 +2,7 @@
2
2
  * Sub-agent progress surfacing for the RD/QA sub-agents in
3
3
  * `peaks-solo`'s Swarm phase. A sub-agent (or the LLM via the
4
4
  * `peaks progress step` CLI) writes a stable JSON file at
5
- * `.peaks/<sid>/system/subagent-progress.json`. The user-side
5
+ * `.peaks/_sub_agents/<sid>/subagent-progress.json`. The user-side
6
6
  * `peaks progress watch` CLI polls this file in a separate
7
7
  * terminal tab and renders elapsed / spinner / sub-step. The
8
8
  * `peaks progress start` CLI auto-spawns the watch in a new
@@ -2,7 +2,7 @@
2
2
  * Sub-agent progress surfacing for the RD/QA sub-agents in
3
3
  * `peaks-solo`'s Swarm phase. A sub-agent (or the LLM via the
4
4
  * `peaks progress step` CLI) writes a stable JSON file at
5
- * `.peaks/<sid>/system/subagent-progress.json`. The user-side
5
+ * `.peaks/_sub_agents/<sid>/subagent-progress.json`. The user-side
6
6
  * `peaks progress watch` CLI polls this file in a separate
7
7
  * terminal tab and renders elapsed / spinner / sub-step. The
8
8
  * `peaks progress start` CLI auto-spawns the watch in a new
@@ -34,20 +34,24 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
34
34
  import { dirname, join, resolve } from 'node:path';
35
35
  import { getSessionIdCanonical } from '../session/session-manager.js';
36
36
  import { findProjectRoot } from '../config/config-safety.js';
37
- const PROGRESS_REL_PATH = 'system/subagent-progress.json';
38
- const SPAWN_REL_PATH = 'system/progress-spawn.json';
37
+ // As of slice 2026-06-06-sub-agent-spawn-bug-and-decouple, the per-session
38
+ // sub-agent state files live under `.peaks/_sub_agents/<sid>/`, NOT under
39
+ // `.peaks/<sid>/system/`. The new path mirrors the existing `_runtime/`
40
+ // and `_dogfood/` convention (leading underscore = meta-classification, not
41
+ // a per-session artifact). The previous `<sid>/system/...` locations are
42
+ // migrated to the new path on first run of `peaks workspace reconcile
43
+ // --apply` (see `migrateSubAgentState` in reconcile-service.ts).
44
+ const SUB_AGENTS_DIR = '_sub_agents';
45
+ const PROGRESS_FILE_NAME = 'subagent-progress.json';
46
+ const SPAWN_FILE_NAME = 'progress-spawn.json';
39
47
  function progressPath(projectRoot) {
40
- // The progress file lives under the *session* directory, not
41
- // directly under .peaks/. Every other per-slice artefact
42
- // (rd/tech-doc.md, qa/test-cases/<rid>.md, prd/requests/<rid>.md,
43
- // memory/, openspec/) lives under .peaks/<sid>/, so progress
44
- // should too. Without the session prefix, a session rotation
45
- // would orphan the file in the project root, and switching
46
- // sessions would have the watch reading the wrong slice's
47
- // progress.
48
+ // The progress file lives at `.peaks/_sub_agents/<sid>/subagent-progress.json`.
49
+ // The leading `_sub_agents/` is a meta-classification (mirrors `_runtime/`,
50
+ // `_dogfood/`) — the SID is the per-session discriminator inside that meta
51
+ // dir. Without the SID, sessions would collide on the same file.
48
52
  const sessionId = getSessionIdCanonical(projectRoot);
49
53
  const subDir = sessionId ?? 'unbound';
50
- return join(projectRoot, '.peaks', subDir, PROGRESS_REL_PATH);
54
+ return join(projectRoot, '.peaks', SUB_AGENTS_DIR, subDir, PROGRESS_FILE_NAME);
51
55
  }
52
56
  function ensureParentDir(path) {
53
57
  const dir = dirname(path);
@@ -199,12 +203,12 @@ export function subAgentProgressPath(projectRoot) {
199
203
  export function subAgentSpawnPath(projectRoot) {
200
204
  const sessionId = getSessionIdCanonical(projectRoot);
201
205
  const subDir = sessionId ?? 'unbound';
202
- return join(projectRoot, '.peaks', subDir, SPAWN_REL_PATH);
206
+ return join(projectRoot, '.peaks', SUB_AGENTS_DIR, subDir, SPAWN_FILE_NAME);
203
207
  }
204
208
  function spawnRecordPath(projectRoot) {
205
209
  const sessionId = getSessionIdCanonical(projectRoot);
206
210
  const subDir = sessionId ?? 'unbound';
207
- return join(projectRoot, '.peaks', subDir, SPAWN_REL_PATH);
211
+ return join(projectRoot, '.peaks', SUB_AGENTS_DIR, subDir, SPAWN_FILE_NAME);
208
212
  }
209
213
  export function writeSpawnRecord(options) {
210
214
  const sessionId = getSessionIdCanonical(options.projectRoot);
@@ -0,0 +1,12 @@
1
+ /** Build the canonical record path for a given session/rid/timestamp. */
2
+ export declare function dispatchRecordPath(projectRoot: string, sid: string, rid: string, ts?: Date): string;
3
+ /** The directory under which dispatch records live. */
4
+ export declare function dispatchRecordsDir(projectRoot: string, sid: string): string;
5
+ /**
6
+ * Assert that `recordPath` lives under `projectRoot/.peaks/_sub_agents/<sid>/`.
7
+ * Rejects symlink/junction escapes and `..` segments.
8
+ *
9
+ * Throws an Error with `.code = 'INVALID_RECORD_PATH'` on rejection so
10
+ * the CLI can map to `{ok: false, code: "INVALID_RECORD_PATH"}`.
11
+ */
12
+ export declare function assertSafeDispatchRecordPath(recordPath: string, projectRoot: string): string;