peaks-loop 4.0.0-beta.10 → 4.0.0-beta.12

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 (66) hide show
  1. package/CHANGELOG.md +27 -11
  2. package/dist/cli/cli-helpers.d.ts +0 -5
  3. package/dist/cli/cli-helpers.js +0 -24
  4. package/dist/cli/commands/audit-commands.js +1 -1
  5. package/dist/cli/commands/capability-worker-config-sc-commands.js +0 -2
  6. package/dist/cli/commands/code-commands.js +1 -1
  7. package/dist/cli/commands/code-review-commands.js +1 -1
  8. package/dist/cli/commands/config-commands.js +3 -74
  9. package/dist/cli/commands/core/skill-command.js +5 -2
  10. package/dist/cli/commands/dispatch-commands.js +14 -0
  11. package/dist/cli/commands/ecc-commands.d.ts +30 -0
  12. package/dist/cli/commands/ecc-commands.js +119 -0
  13. package/dist/cli/commands/perf-audit-commands.js +1 -1
  14. package/dist/cli/commands/prd-commands.js +3 -1
  15. package/dist/cli/commands/qa-commands.js +1 -1
  16. package/dist/cli/commands/sc-commands.js +3 -1
  17. package/dist/cli/commands/security-audit-commands.js +1 -1
  18. package/dist/cli/commands/skill-search-commands.js +3 -0
  19. package/dist/cli/commands/upgrade-commands.js +1 -1
  20. package/dist/cli/index.js +55 -3
  21. package/dist/cli/program.js +46 -4
  22. package/dist/services/agent/ecc-cache-service.d.ts +97 -0
  23. package/dist/services/agent/ecc-cache-service.js +491 -0
  24. package/dist/services/audit/static-service.d.ts +32 -25
  25. package/dist/services/audit/static-service.js +30 -109
  26. package/dist/services/config/config-service.d.ts +1 -12
  27. package/dist/services/config/config-service.js +3 -72
  28. package/dist/services/config/config-types.d.ts +2 -4
  29. package/dist/services/context/context-schema.d.ts +2 -2
  30. package/dist/services/job/job-types.d.ts +3 -3
  31. package/dist/services/log/retention.d.ts +16 -0
  32. package/dist/services/log/retention.js +17 -0
  33. package/dist/services/migrate-skill-name/schema.d.ts +2 -2
  34. package/dist/services/rd/rd-service.js +10 -11
  35. package/dist/services/rd/types.d.ts +2 -2
  36. package/dist/services/recommendations/capability-seed-items.js +0 -1
  37. package/dist/services/recommendations/capability-seed-mappings.js +0 -1
  38. package/dist/services/recommendations/capability-seed-sources.js +0 -1
  39. package/dist/services/skill/skill-search-service.d.ts +5 -0
  40. package/dist/services/skill/skill-search-service.js +6 -2
  41. package/dist/services/skills/skill-registry.d.ts +1 -0
  42. package/dist/services/skills/skill-registry.js +4 -2
  43. package/dist/services/workflow/workflow-router-service.js +7 -7
  44. package/dist/shared/version.d.ts +1 -1
  45. package/dist/shared/version.js +1 -1
  46. package/package.json +1 -1
  47. package/skills/bee/peaks-perf-audit/SKILL.md +1 -0
  48. package/skills/bee/peaks-rd/SKILL.md +1 -24
  49. package/skills/bee/peaks-reviewer/SKILL.md +1 -0
  50. package/skills/bee/peaks-security-audit/SKILL.md +1 -0
  51. package/skills/peaks-code/SKILL.md +1 -99
  52. package/skills/peaks-code/references/startup-sequence.md +110 -0
  53. package/skills/peaks-doctor/SKILL.md +1 -1
  54. package/skills/peaks-ide/SKILL.md +1 -1
  55. package/dist/cli/commands/agent-commands.d.ts +0 -20
  56. package/dist/cli/commands/agent-commands.js +0 -48
  57. package/dist/cli/commands/worker-commands.d.ts +0 -3
  58. package/dist/cli/commands/worker-commands.js +0 -52
  59. package/dist/services/agent/ecc-agent-service.d.ts +0 -47
  60. package/dist/services/agent/ecc-agent-service.js +0 -143
  61. package/dist/services/config/provider-service.d.ts +0 -48
  62. package/dist/services/config/provider-service.js +0 -143
  63. package/dist/services/providers/minimax-provider-service.d.ts +0 -24
  64. package/dist/services/providers/minimax-provider-service.js +0 -143
  65. package/dist/services/providers/minimax-worker-service.d.ts +0 -21
  66. package/dist/services/providers/minimax-worker-service.js +0 -83
@@ -58,7 +58,7 @@ import { registerWorkflowEvalCommands } from './commands/loop-eval-commands.js';
58
58
  import { registerEvolutionCommands } from './commands/evolution-commands.js';
59
59
  import { registerAssetCommands } from './commands/asset-commands.js';
60
60
  import { registerBeeCommands } from './commands/bee-commands.js';
61
- import { registerAgentCommands } from './commands/agent-commands.js';
61
+ import { registerEccCommands } from './commands/ecc-commands.js';
62
62
  import { registerUpgradeCommands } from './commands/upgrade-commands.js';
63
63
  import { registerCodeReviewCommands } from './commands/code-review-commands.js';
64
64
  import { registerSecurityAuditCommands } from './commands/security-audit-commands.js';
@@ -99,7 +99,7 @@ import { registerAdapterS2ACommands } from './commands/adapter-commands-s2a.js';
99
99
  // Slice RD-2 S2-b: `peaks polyrepo *` (init / status / dispatch).
100
100
  import { registerPolyrepoCommands } from './commands/polyrepo-commands.js';
101
101
  import { registerSkillVisibilityCommand } from './commands/skill-visibility.js';
102
- import { applyRetention } from '../services/log/retention.js';
102
+ import { applyRetention, cleanupEccCache } from '../services/log/retention.js';
103
103
  import { writeLogEntry, maybeWriteStderr } from '../services/log/logger.js';
104
104
  export { printResult } from './cli-helpers.js';
105
105
  /**
@@ -120,6 +120,13 @@ function bootstrapLogger(verbose) {
120
120
  catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
121
121
  /* best-effort retention sweep; never block the CLI */
122
122
  }
123
+ // Slice 3 (on-demand-ecc): 7-day TTL sweep over ecc-<sha>/ cache dirs.
124
+ try {
125
+ cleanupEccCache({ retentionDays: 7, nowMs: Date.now() });
126
+ }
127
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
128
+ /* best-effort ECC retention sweep; never block the CLI */
129
+ }
123
130
  const dateOverride = process.env.PEAKS_LOG_DATE_OVERRIDE;
124
131
  const entry = {
125
132
  ts: new Date().toISOString(),
@@ -200,6 +207,40 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
200
207
  }
201
208
  })
202
209
  .action(() => {
210
+ // D-013 wrapper exit-code fix: when the user typed a command token but
211
+ // it didn't match any registered subcommand, exit non-zero with a JSON
212
+ // `COMMAND_NOT_FOUND` envelope. Without this check, Commander's root
213
+ // `.action()` runs whenever no subcommand matches (including unknown
214
+ // commands) and prints the help banner with exit 0 — which violates
215
+ // the PRD AC3.9/AC3.10 contract for `peaks agent run/list` and any
216
+ // other deleted/hidden command.
217
+ //
218
+ // Detection: use `program.args` (Commander's parsed argv) for the
219
+ // first non-option token. If it's present and Commander didn't route
220
+ // to a subcommand (we got here, so it didn't), the user typed an
221
+ // unknown command. Exit 1. `program.args` works correctly under both
222
+ // `parseAsync(['node', 'peaks', ...])` (real CLI) and direct in-process
223
+ // calls (vitest integration tests), unlike `process.argv` which is
224
+ // the test runner's argv.
225
+ //
226
+ // --help and --version bypass this path entirely (handled below).
227
+ const firstNonOption = program.args.find((arg) => !arg.startsWith('-'));
228
+ if (firstNonOption !== undefined) {
229
+ // Commander reached this `.action()` despite a positional token,
230
+ // which means the token was NOT routed to any subcommand. This is
231
+ // the unknown-command path. Emit a JSON envelope + exit 1.
232
+ io.stdout(JSON.stringify({
233
+ ok: false,
234
+ command: 'cli',
235
+ code: 'COMMAND_NOT_FOUND',
236
+ message: `Unknown command: ${firstNonOption}. Run \`peaks --help\` for available commands.`,
237
+ data: { argv: firstNonOption },
238
+ warnings: [],
239
+ nextActions: ['Run `peaks --help` to list available commands.']
240
+ }, null, 2));
241
+ process.exitCode = 1;
242
+ return;
243
+ }
203
244
  const opts = program.opts();
204
245
  if (opts.V || opts.version) {
205
246
  // AC1: write the peaks-loop start log line BEFORE printing the
@@ -329,8 +370,9 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
329
370
  // M7 (2026-07-07 spec §7A.2 / §10 RL-9): `peaks bee export|import`
330
371
  // — cross-user share surface for bee_release rows.
331
372
  registerBeeCommands(program, io);
332
- // Slice: ECC 64 agents soft-optional (per spec §7.2 line 818).
333
- registerAgentCommands(program, io);
373
+ // Slice 3 (on-demand-ecc): `peaks ecc install|status|ls|show` replaces the
374
+ // deleted `peaks agent run|list` subprocess surface.
375
+ registerEccCommands(program, io);
334
376
  // Slice: 1.x → 2.0 umbrella (per "one-key completion" + "minimal-user-operation" tenets).
335
377
  registerUpgradeCommands(program, io);
336
378
  // Slice: ocr soft-optional integration (peaks-rd Gate B3 augmentation).
@@ -0,0 +1,97 @@
1
+ /**
2
+ * ECC cache service — Slice 3 of 4.0.0-beta.11.
3
+ *
4
+ * Drop-in replacement for the deleted `ecc-agent-service.ts`. The
5
+ * pre-Slice-3 implementation shelled out to `npx ecc agent run ...`,
6
+ * but the upstream `affaan-m/everything-claude-code` v2.0.0 release
7
+ * has NO `ecc` binary — the repo is `agents/*.md` flat files plus
8
+ * SKILL.md descriptors. The peaks-loop path is therefore download
9
+ * + cache only; the LLM consumes cached `agents/*.md` directly
10
+ * through `peaks ecc show <name>`.
11
+ *
12
+ * Six exports per RD §2:
13
+ * - setCacheDirPermissions
14
+ * - downloadToCache
15
+ * - readCacheManifest
16
+ * - listCachedAgents
17
+ * - readAgentSkill
18
+ * - cleanupStaleCache
19
+ *
20
+ * The cache layout is `~/.peaks/cache/ecc-installed.json` (manifest)
21
+ * + `~/.peaks/cache/ecc-<sha>/agents/<name>.md`. The manifest is
22
+ * the active-cache pointer; cleanupStaleCache iterates the on-disk
23
+ * sha directories and decides based on the manifest's `fetchedAt`
24
+ * (active) vs `mtimeMs` (orphan).
25
+ *
26
+ * Hard contracts (Karpathy #2 Simplicity First):
27
+ * - No subprocess plumbing. Network is `fetch()` only.
28
+ * - No factory; one module, six functions, six dependencies.
29
+ * - Strict selective extract: agents/*.md only, no symlinks, no
30
+ * `..`, no absolute paths, no devices.
31
+ * - D-009 fallback: parseFrontmatter throws on malformed input —
32
+ * wrap it in try/catch and synthesize `{name, description}`
33
+ * from the file name + first non-empty body line.
34
+ * - D-010 fallback: PRD's `ecc.tar.gz` asset URL does NOT exist
35
+ * upstream; try PRD first, fall back to GitHub's `tarball_url`
36
+ * on the release JSON.
37
+ */
38
+ export declare const ECC_REPO_OWNER = "affaan-m";
39
+ export declare const ECC_REPO_NAME = "everything-claude-code";
40
+ export type CacheManifest = {
41
+ version: string;
42
+ sha: string;
43
+ fetchedAt: string;
44
+ agents: string[];
45
+ };
46
+ export type DownloadResult = {
47
+ sha: string;
48
+ agents: number;
49
+ };
50
+ /**
51
+ * Cache dir resolution — single source of truth. Tests inject
52
+ * via `dirOverride` on the functions that take one.
53
+ */
54
+ export declare function resolveEccCacheDir(): string;
55
+ export declare function resolveManifestPath(dirOverride?: string): string;
56
+ export declare function resolveShaDir(sha: string, dirOverride?: string): string;
57
+ export declare function resolveAgentsDir(sha: string, dirOverride?: string): string;
58
+ /**
59
+ * Best-effort chmod 0o700 on POSIX; no-op on Windows (NTFS uses
60
+ * ACLs, not POSIX mode bits). Swallows errors with WARN so a
61
+ * permissions failure never blocks the CLI.
62
+ */
63
+ export declare function setCacheDirPermissions(cacheDir: string): void;
64
+ /**
65
+ * Download ECC into `~/.peaks/cache/ecc-<sha>/agents/`. Returns
66
+ * the resolved SHA + the number of agents extracted.
67
+ *
68
+ * Throws `Error('fetch-failed')` on network failure so the CLI
69
+ * layer can render the manual-install instructions.
70
+ */
71
+ export declare function downloadToCache({ ref }?: {
72
+ ref?: string;
73
+ }): Promise<DownloadResult>;
74
+ export declare function readCacheManifest(): CacheManifest | null;
75
+ export declare function listCachedAgents(): {
76
+ name: string;
77
+ description: string;
78
+ }[];
79
+ export declare function readAgentSkill(name: string): string | null;
80
+ /**
81
+ * 7-day TTL sweep over `ecc-<sha>/` directories.
82
+ *
83
+ * - Active cache (per manifest `sha`): use `fetchedAt` as the
84
+ * age source; remove if older than `retentionDays`.
85
+ * - Orphan caches (any other `ecc-<40hex>` directory): use
86
+ * directory `mtimeMs` as the age source.
87
+ * - If the active cache is removed, invalidate the manifest.
88
+ *
89
+ * Returns the absolute paths of the removed directories.
90
+ */
91
+ export declare function cleanupStaleCache({ retentionDays, nowMs, dirOverride, }: {
92
+ retentionDays: number;
93
+ nowMs: number;
94
+ dirOverride?: string;
95
+ }): {
96
+ removed: string[];
97
+ };
@@ -0,0 +1,491 @@
1
+ /**
2
+ * ECC cache service — Slice 3 of 4.0.0-beta.11.
3
+ *
4
+ * Drop-in replacement for the deleted `ecc-agent-service.ts`. The
5
+ * pre-Slice-3 implementation shelled out to `npx ecc agent run ...`,
6
+ * but the upstream `affaan-m/everything-claude-code` v2.0.0 release
7
+ * has NO `ecc` binary — the repo is `agents/*.md` flat files plus
8
+ * SKILL.md descriptors. The peaks-loop path is therefore download
9
+ * + cache only; the LLM consumes cached `agents/*.md` directly
10
+ * through `peaks ecc show <name>`.
11
+ *
12
+ * Six exports per RD §2:
13
+ * - setCacheDirPermissions
14
+ * - downloadToCache
15
+ * - readCacheManifest
16
+ * - listCachedAgents
17
+ * - readAgentSkill
18
+ * - cleanupStaleCache
19
+ *
20
+ * The cache layout is `~/.peaks/cache/ecc-installed.json` (manifest)
21
+ * + `~/.peaks/cache/ecc-<sha>/agents/<name>.md`. The manifest is
22
+ * the active-cache pointer; cleanupStaleCache iterates the on-disk
23
+ * sha directories and decides based on the manifest's `fetchedAt`
24
+ * (active) vs `mtimeMs` (orphan).
25
+ *
26
+ * Hard contracts (Karpathy #2 Simplicity First):
27
+ * - No subprocess plumbing. Network is `fetch()` only.
28
+ * - No factory; one module, six functions, six dependencies.
29
+ * - Strict selective extract: agents/*.md only, no symlinks, no
30
+ * `..`, no absolute paths, no devices.
31
+ * - D-009 fallback: parseFrontmatter throws on malformed input —
32
+ * wrap it in try/catch and synthesize `{name, description}`
33
+ * from the file name + first non-empty body line.
34
+ * - D-010 fallback: PRD's `ecc.tar.gz` asset URL does NOT exist
35
+ * upstream; try PRD first, fall back to GitHub's `tarball_url`
36
+ * on the release JSON.
37
+ */
38
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
39
+ import { join } from 'node:path';
40
+ import { homedir } from 'node:os';
41
+ import { parseFrontmatter } from '../../shared/frontmatter.js';
42
+ export const ECC_REPO_OWNER = 'affaan-m';
43
+ export const ECC_REPO_NAME = 'everything-claude-code';
44
+ const ECC_CACHE_VERSION = '1';
45
+ const ECC_TARBALL_BASENAME = 'ecc.tar.gz';
46
+ /**
47
+ * Cache dir resolution — single source of truth. Tests inject
48
+ * via `dirOverride` on the functions that take one.
49
+ */
50
+ export function resolveEccCacheDir() {
51
+ return join(homedir(), '.peaks', 'cache');
52
+ }
53
+ export function resolveManifestPath(dirOverride) {
54
+ return join(dirOverride ?? resolveEccCacheDir(), 'ecc-installed.json');
55
+ }
56
+ export function resolveShaDir(sha, dirOverride) {
57
+ return join(dirOverride ?? resolveEccCacheDir(), `ecc-${sha}`);
58
+ }
59
+ export function resolveAgentsDir(sha, dirOverride) {
60
+ return join(resolveShaDir(sha, dirOverride), 'agents');
61
+ }
62
+ /**
63
+ * Best-effort chmod 0o700 on POSIX; no-op on Windows (NTFS uses
64
+ * ACLs, not POSIX mode bits). Swallows errors with WARN so a
65
+ * permissions failure never blocks the CLI.
66
+ */
67
+ export function setCacheDirPermissions(cacheDir) {
68
+ if (process.platform === 'win32')
69
+ return;
70
+ try {
71
+ chmodSync(cacheDir, 0o700);
72
+ }
73
+ catch {
74
+ /* best-effort */
75
+ }
76
+ }
77
+ function isSafeAgentName(name) {
78
+ return /^[a-z][a-z0-9-]*$/.test(name);
79
+ }
80
+ function isSafeArchiveEntry(entryName) {
81
+ if (entryName.length === 0)
82
+ return false;
83
+ if (entryName.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(entryName))
84
+ return false;
85
+ if (entryName.includes('..'))
86
+ return false;
87
+ // Strip the leading "<repo-root>/" segment (github tarballs prefix
88
+ // every entry with "<repo>-<sha>/..."); everything after must
89
+ // start with "agents/" and end with ".md" at a single level.
90
+ const segments = entryName.split('/');
91
+ // Drop the first "<repo>-<sha>" segment if present.
92
+ if (segments.length >= 3 && segments[0]?.startsWith(`${ECC_REPO_NAME}-`)) {
93
+ segments.shift();
94
+ }
95
+ if (segments.length !== 2)
96
+ return false;
97
+ return segments[0] === 'agents' && segments[1]?.endsWith('.md') === true;
98
+ }
99
+ function safeAgentNameFromEntry(entryName) {
100
+ const segments = entryName.split('/');
101
+ if (segments.length >= 3 && segments[0]?.startsWith(`${ECC_REPO_NAME}-`)) {
102
+ segments.shift();
103
+ }
104
+ const file = segments[segments.length - 1] ?? '';
105
+ const base = file.replace(/\.md$/i, '');
106
+ return isSafeAgentName(base) ? base : null;
107
+ }
108
+ /**
109
+ * Best-effort fetch of the upstream release JSON. Returns the
110
+ * parsed body, or null on any network/parse error. Used as the
111
+ * D-010 fallback path.
112
+ */
113
+ async function fetchReleaseJson(apiBase) {
114
+ try {
115
+ const res = await fetch(apiBase, {
116
+ headers: { accept: 'application/vnd.github+json', 'user-agent': 'peaks-loop' },
117
+ });
118
+ if (!res.ok)
119
+ return null;
120
+ return (await res.json());
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ /**
127
+ * Resolve a 40-char commit SHA for `tag`. Prefers the GitHub
128
+ * releases API (which dereferences annotated tags to commit SHA
129
+ * via `target_commitish`). Falls back to the tag name if the
130
+ * upstream returns a non-SHA-shaped identifier (older tags).
131
+ */
132
+ async function resolveCommitSha(ref) {
133
+ const apiBase = `https://api.github.com/repos/${ECC_REPO_OWNER}/${ECC_REPO_NAME}/releases/tags/${encodeURIComponent(ref)}`;
134
+ const release = await fetchReleaseJson(apiBase);
135
+ const tagName = release?.tag_name ?? ref;
136
+ if (/^[0-9a-f]{40}$/.test(tagName))
137
+ return tagName;
138
+ return tagName;
139
+ }
140
+ function asSha(value) {
141
+ return /^[0-9a-f]{40}$/.test(value) ? value : value;
142
+ }
143
+ /**
144
+ * Tiny tar.gz extractor. Public-domain-compatible tar parser:
145
+ * - reads 512-byte header blocks
146
+ * - validates name + size
147
+ * - rejects anything that is not a regular file at a safe path
148
+ * - filters to the `agents/*.md` allowlist via isSafeArchiveEntry
149
+ *
150
+ * We do NOT decompress gzip via a third-party lib; Node's
151
+ * `node:zlib` ships with `gunzip` so the implementation is
152
+ * self-contained.
153
+ */
154
+ async function extractAgentsFromTarGz(buffer, outDir) {
155
+ if (!existsSync(outDir))
156
+ mkdirSync(outDir, { recursive: true });
157
+ const extracted = [];
158
+ // Lazy import to keep the cold path cheap.
159
+ const { gunzipSync } = await import('node:zlib');
160
+ let tar;
161
+ try {
162
+ tar = gunzipSync(buffer);
163
+ }
164
+ catch {
165
+ return [];
166
+ }
167
+ const BLOCK = 512;
168
+ let offset = 0;
169
+ while (offset + BLOCK <= tar.length) {
170
+ const header = tar.subarray(offset, offset + BLOCK);
171
+ // Two consecutive zero blocks mark end-of-archive.
172
+ if (header.every((b) => b === 0))
173
+ break;
174
+ const name = readCString(header, 0, 100);
175
+ if (name === null || name.length === 0)
176
+ break;
177
+ const sizeOctal = readCString(header, 124, 12);
178
+ if (sizeOctal === null)
179
+ break;
180
+ const size = parseOctal(sizeOctal);
181
+ if (size === null)
182
+ break;
183
+ const typeFlag = String.fromCharCode(header[156] ?? 0);
184
+ // '0' or '\0' = regular file. Reject symlinks (2), dirs (5), etc.
185
+ if (typeFlag !== '0' && typeFlag !== '\0') {
186
+ offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK;
187
+ continue;
188
+ }
189
+ if (!isSafeArchiveEntry(name)) {
190
+ offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK;
191
+ continue;
192
+ }
193
+ const safeName = safeAgentNameFromEntry(name);
194
+ if (safeName === null) {
195
+ offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK;
196
+ continue;
197
+ }
198
+ const data = tar.subarray(offset + BLOCK, offset + BLOCK + size);
199
+ writeFileSync(join(outDir, `${safeName}.md`), data);
200
+ extracted.push(safeName);
201
+ offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK;
202
+ }
203
+ return extracted;
204
+ }
205
+ function readCString(buf, start, len) {
206
+ let end = start;
207
+ while (end < start + len && buf[end] !== 0)
208
+ end += 1;
209
+ if (end === start)
210
+ return '';
211
+ try {
212
+ return Buffer.from(buf.subarray(start, end)).toString('utf8');
213
+ }
214
+ catch {
215
+ return null;
216
+ }
217
+ }
218
+ function parseOctal(value) {
219
+ const trimmed = value.trim().replace(/\0+$/g, '');
220
+ if (trimmed.length === 0)
221
+ return 0;
222
+ const n = Number.parseInt(trimmed, 8);
223
+ return Number.isFinite(n) && n >= 0 ? n : null;
224
+ }
225
+ async function fetchBuffer(url) {
226
+ try {
227
+ const res = await fetch(url, {
228
+ headers: { accept: 'application/octet-stream', 'user-agent': 'peaks-loop' },
229
+ });
230
+ if (!res.ok)
231
+ return null;
232
+ const ab = await res.arrayBuffer();
233
+ return new Uint8Array(ab);
234
+ }
235
+ catch {
236
+ return null;
237
+ }
238
+ }
239
+ async function downloadTarball(ref, sha, outDir) {
240
+ // D-010: PRD URL first, GitHub fallback second.
241
+ const prdUrl = `https://github.com/${ECC_REPO_OWNER}/${ECC_REPO_NAME}/releases/download/${encodeURIComponent(ref)}/${ECC_TARBALL_BASENAME}`;
242
+ let buffer = await fetchBuffer(prdUrl);
243
+ if (buffer === null) {
244
+ const release = await fetchReleaseJson(`https://api.github.com/repos/${ECC_REPO_OWNER}/${ECC_REPO_NAME}/releases/tags/${encodeURIComponent(ref)}`);
245
+ const tarballUrl = release?.tarball_url;
246
+ if (typeof tarballUrl === 'string' && tarballUrl.length > 0) {
247
+ buffer = await fetchBuffer(tarballUrl);
248
+ }
249
+ if (buffer === null && Array.isArray(release?.assets)) {
250
+ const asset = release.assets.find((a) => a.name === ECC_TARBALL_BASENAME) ??
251
+ release.assets.find((a) => a.name === `${ECC_REPO_NAME}-universal-${ref}.tgz`) ??
252
+ release.assets.find((a) => a.name.endsWith('.tgz') || a.name.endsWith('.tar.gz'));
253
+ if (asset)
254
+ buffer = await fetchBuffer(asset.browser_download_url);
255
+ }
256
+ }
257
+ if (buffer === null) {
258
+ throw new Error('fetch-failed');
259
+ }
260
+ return extractAgentsFromTarGz(buffer, outDir);
261
+ }
262
+ /**
263
+ * Download ECC into `~/.peaks/cache/ecc-<sha>/agents/`. Returns
264
+ * the resolved SHA + the number of agents extracted.
265
+ *
266
+ * Throws `Error('fetch-failed')` on network failure so the CLI
267
+ * layer can render the manual-install instructions.
268
+ */
269
+ export async function downloadToCache({ ref } = {}) {
270
+ const requestedRef = ref ?? 'latest';
271
+ let resolvedSha;
272
+ if (requestedRef === 'latest') {
273
+ const release = await fetchReleaseJson(`https://api.github.com/repos/${ECC_REPO_OWNER}/${ECC_REPO_NAME}/releases/latest`);
274
+ const tag = release?.tag_name;
275
+ if (typeof tag !== 'string' || tag.length === 0) {
276
+ throw new Error('fetch-failed');
277
+ }
278
+ resolvedSha = asSha(await resolveCommitSha(tag) ?? tag);
279
+ }
280
+ else {
281
+ resolvedSha = asSha(await resolveCommitSha(requestedRef) ?? requestedRef);
282
+ }
283
+ const cacheDir = resolveEccCacheDir();
284
+ if (!existsSync(cacheDir))
285
+ mkdirSync(cacheDir, { recursive: true });
286
+ setCacheDirPermissions(cacheDir);
287
+ const agentsDir = resolveAgentsDir(resolvedSha, cacheDir);
288
+ if (existsSync(agentsDir) && readdirSync(agentsDir).length > 0) {
289
+ // Already populated; emit manifest + return.
290
+ const agents = readdirSync(agentsDir)
291
+ .filter((f) => f.endsWith('.md'))
292
+ .map((f) => f.replace(/\.md$/i, ''));
293
+ writeManifest({ version: ECC_CACHE_VERSION, sha: resolvedSha, fetchedAt: new Date().toISOString(), agents });
294
+ return { sha: resolvedSha, agents: agents.length };
295
+ }
296
+ const extracted = await downloadTarball(requestedRef === 'latest' ? resolvedSha : requestedRef, resolvedSha, agentsDir);
297
+ const manifest = {
298
+ version: ECC_CACHE_VERSION,
299
+ sha: resolvedSha,
300
+ fetchedAt: new Date().toISOString(),
301
+ agents: extracted,
302
+ };
303
+ writeManifest(manifest);
304
+ return { sha: resolvedSha, agents: extracted.length };
305
+ }
306
+ function writeManifest(manifest) {
307
+ const path = resolveManifestPath();
308
+ writeFileSync(path, JSON.stringify(manifest, null, 2));
309
+ }
310
+ export function readCacheManifest() {
311
+ const path = resolveManifestPath();
312
+ if (!existsSync(path))
313
+ return null;
314
+ try {
315
+ const raw = readFileSync(path, 'utf8');
316
+ const parsed = JSON.parse(raw);
317
+ if (typeof parsed.version === 'string' &&
318
+ typeof parsed.sha === 'string' &&
319
+ typeof parsed.fetchedAt === 'string' &&
320
+ Array.isArray(parsed.agents)) {
321
+ return parsed;
322
+ }
323
+ return null;
324
+ }
325
+ catch {
326
+ return null;
327
+ }
328
+ }
329
+ let warnedAboutFallback = false;
330
+ function fallbackMetadata(fileName) {
331
+ const base = fileName.replace(/\.md$/i, '');
332
+ let description = '';
333
+ try {
334
+ const body = readFileSync(join(resolveManifestDirForAgents(), fileName), 'utf8');
335
+ const lines = body.split(/\r?\n/);
336
+ let inFrontmatter = false;
337
+ let seenClosing = false;
338
+ for (const raw of lines) {
339
+ const line = raw.trim();
340
+ if (!seenClosing) {
341
+ if (!inFrontmatter && line === '---') {
342
+ inFrontmatter = true;
343
+ continue;
344
+ }
345
+ if (inFrontmatter && line === '---') {
346
+ seenClosing = true;
347
+ continue;
348
+ }
349
+ continue;
350
+ }
351
+ if (line.length === 0)
352
+ continue;
353
+ description = line;
354
+ break;
355
+ }
356
+ }
357
+ catch {
358
+ /* best-effort */
359
+ }
360
+ if (description.length > 80)
361
+ description = `${description.slice(0, 77)}...`;
362
+ if (!warnedAboutFallback) {
363
+ try {
364
+ process.stderr.write(`warning: cached agent "${base}" has malformed frontmatter; falling back to filename + first body line\n`);
365
+ }
366
+ catch {
367
+ /* best-effort */
368
+ }
369
+ warnedAboutFallback = true;
370
+ }
371
+ return { name: base, description };
372
+ }
373
+ function resolveManifestDirForAgents() {
374
+ const manifest = readCacheManifest();
375
+ if (manifest === null)
376
+ return '';
377
+ return resolveAgentsDir(manifest.sha);
378
+ }
379
+ export function listCachedAgents() {
380
+ const manifest = readCacheManifest();
381
+ if (manifest === null)
382
+ return [];
383
+ const agentsDir = resolveAgentsDir(manifest.sha);
384
+ if (!existsSync(agentsDir))
385
+ return [];
386
+ const out = [];
387
+ for (const file of readdirSync(agentsDir).sort()) {
388
+ if (!file.endsWith('.md'))
389
+ continue;
390
+ const fullPath = join(agentsDir, file);
391
+ let meta;
392
+ try {
393
+ const raw = readFileSync(fullPath, 'utf8');
394
+ const fm = parseFrontmatter(raw);
395
+ meta = { name: fm.name, description: fm.description };
396
+ }
397
+ catch {
398
+ // D-009 fallback — name from filename, description from first body line.
399
+ meta = fallbackMetadata(file);
400
+ }
401
+ out.push(meta);
402
+ }
403
+ return out;
404
+ }
405
+ export function readAgentSkill(name) {
406
+ if (!isSafeAgentName(name))
407
+ return null;
408
+ const manifest = readCacheManifest();
409
+ if (manifest === null)
410
+ return null;
411
+ const file = join(resolveAgentsDir(manifest.sha), `${name}.md`);
412
+ if (!existsSync(file))
413
+ return null;
414
+ try {
415
+ return readFileSync(file, 'utf8');
416
+ }
417
+ catch {
418
+ return null;
419
+ }
420
+ }
421
+ /**
422
+ * 7-day TTL sweep over `ecc-<sha>/` directories.
423
+ *
424
+ * - Active cache (per manifest `sha`): use `fetchedAt` as the
425
+ * age source; remove if older than `retentionDays`.
426
+ * - Orphan caches (any other `ecc-<40hex>` directory): use
427
+ * directory `mtimeMs` as the age source.
428
+ * - If the active cache is removed, invalidate the manifest.
429
+ *
430
+ * Returns the absolute paths of the removed directories.
431
+ */
432
+ export function cleanupStaleCache({ retentionDays, nowMs, dirOverride, }) {
433
+ const cacheDir = dirOverride ?? resolveEccCacheDir();
434
+ if (!existsSync(cacheDir))
435
+ return { removed: [] };
436
+ const dayMs = 24 * 60 * 60 * 1000;
437
+ const cutoff = nowMs - retentionDays * dayMs;
438
+ const manifest = readCacheManifest();
439
+ const activeSha = manifest?.sha ?? null;
440
+ const activeFetchedAt = manifest?.fetchedAt ? Date.parse(manifest.fetchedAt) : NaN;
441
+ const removed = [];
442
+ let names;
443
+ try {
444
+ names = readdirSync(cacheDir);
445
+ }
446
+ catch {
447
+ return { removed: [] };
448
+ }
449
+ for (const name of names) {
450
+ const match = /^ecc-([0-9a-f]{40})$/.exec(name);
451
+ if (match === null)
452
+ continue;
453
+ const sha = match[1] ?? '';
454
+ const fullPath = join(cacheDir, name);
455
+ let stat;
456
+ try {
457
+ stat = statSync(fullPath);
458
+ }
459
+ catch {
460
+ continue;
461
+ }
462
+ if (!stat.isDirectory())
463
+ continue;
464
+ let ageMs;
465
+ if (sha === activeSha && Number.isFinite(activeFetchedAt)) {
466
+ ageMs = nowMs - activeFetchedAt;
467
+ }
468
+ else {
469
+ ageMs = nowMs - stat.mtimeMs;
470
+ }
471
+ if (ageMs > retentionDays * dayMs) {
472
+ try {
473
+ rmSync(fullPath, { recursive: true, force: true });
474
+ removed.push(fullPath);
475
+ }
476
+ catch {
477
+ /* best-effort */
478
+ }
479
+ }
480
+ }
481
+ // Invalidate manifest if active cache was removed.
482
+ if (activeSha !== null && removed.some((p) => p.includes(`ecc-${activeSha}`))) {
483
+ try {
484
+ rmSync(resolveManifestPath(cacheDir), { force: true });
485
+ }
486
+ catch {
487
+ /* best-effort */
488
+ }
489
+ }
490
+ return { removed };
491
+ }