peaks-loop 4.0.5 → 4.0.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 (31) hide show
  1. package/CHANGELOG.md +320 -0
  2. package/dist/cli/commands/code-job-shape-commands.js +11 -8
  3. package/dist/cli/commands/code-mode-gate-commands.js +43 -1
  4. package/dist/cli/commands/code-runtime-commands.js +11 -6
  5. package/dist/cli/commands/core/standards-command.js +50 -5
  6. package/dist/cli/commands/dispatch-commands.js +39 -3
  7. package/dist/cli/commands/hooks-commands.js +26 -9
  8. package/dist/cli/commands/sub-agent-commands.js +2 -0
  9. package/dist/cli/commands/sub-agent-exec-command.d.ts +25 -0
  10. package/dist/cli/commands/sub-agent-exec-command.js +132 -0
  11. package/dist/cli/commands/sub-agent-shared.d.ts +11 -0
  12. package/dist/services/audit/backing-detector.d.ts +19 -2
  13. package/dist/services/audit/backing-detector.js +66 -7
  14. package/dist/services/audit/enforcers/lint-audit-regression.js +10 -1
  15. package/dist/services/context/build-dispatch-system-prompt.js +18 -0
  16. package/dist/services/dispatch/test-tool-detection.d.ts +14 -0
  17. package/dist/services/dispatch/test-tool-detection.js +22 -0
  18. package/dist/services/scan/archetype-service.js +52 -2
  19. package/dist/services/session/index.d.ts +1 -1
  20. package/dist/services/session/index.js +1 -1
  21. package/dist/services/session/session-manager.d.ts +29 -0
  22. package/dist/services/session/session-manager.js +33 -0
  23. package/dist/services/skills/hooks-settings-service.d.ts +41 -0
  24. package/dist/services/skills/hooks-settings-service.js +61 -5
  25. package/dist/services/slice/slice-check-service.js +59 -0
  26. package/dist/services/standards/find-skills-integration.d.ts +59 -0
  27. package/dist/services/standards/find-skills-integration.js +225 -0
  28. package/dist/services/standards/project-standards-service.js +44 -0
  29. package/package.json +4 -4
  30. package/scripts/install-skills.mjs +171 -0
  31. package/skills/peaks-code/SKILL.md +43 -5
@@ -126,6 +126,35 @@ export declare function getSessionId(projectRoot: string): string | null;
126
126
  * so this variant is opt-in.
127
127
  */
128
128
  export declare function getSessionIdCanonical(projectRoot: string): string | null;
129
+ /**
130
+ * Slice 4.0.7-dogfood-PR-2 (ice-cola surface probe 2026-08-02): the
131
+ * single shared resolver that the peaks code / peaks workflow / peaks
132
+ * route CLI families now use to look up the active session id.
133
+ *
134
+ * Pre-rid each of these 4 CLIs had its own private helper that read
135
+ * `getSkillPresence` (which is `.peaks/.active-skill.json`, set by
136
+ * `peaks skill presence:set <skill>`) instead of the canonical binding
137
+ * (`.peaks/_runtime/session.json`, written by `peaks workspace init`).
138
+ * As a result, any session that was created via `peaks workspace init`
139
+ * but never had `peaks skill presence:set peaks-code` called on it
140
+ * (the common case for downstream consumer projects and any 24h-mode
141
+ * session that never flips sub-skills) reported `NO_ACTIVE_SESSION`
142
+ * from the 4 affected CLIs even though `peaks session info --active`
143
+ * returned a valid id.
144
+ *
145
+ * Resolution order:
146
+ * 1. explicit `override` (caller-passed --session-id)
147
+ * 2. canonicalize-on-read (`getSessionIdCanonical`) — handles the
148
+ * stored "." vs caller-passed absolute realpath mismatch
149
+ * 3. strict-equality fallback (`getSessionId`) — handles the
150
+ * pre-canonicalize binding form
151
+ *
152
+ * The function returns `null` when no binding exists; callers must
153
+ * decide whether `null` is a hard error (most CLIs print
154
+ * `NO_ACTIVE_SESSION` and exit 1) or a soft skip (a few CLIs
155
+ * silently use `'unknown-sid'` as the dispatch fallback).
156
+ */
157
+ export declare function resolveActiveSessionId(projectRoot: string, override?: string): string | null;
129
158
  /**
130
159
  * Get the absolute path to the current session directory.
131
160
  * Creates the session if it doesn't exist.
@@ -408,6 +408,39 @@ export function getSessionIdCanonical(projectRoot) {
408
408
  const info = readSessionFileCanonical(projectRoot);
409
409
  return info?.sessionId ?? null;
410
410
  }
411
+ /**
412
+ * Slice 4.0.7-dogfood-PR-2 (ice-cola surface probe 2026-08-02): the
413
+ * single shared resolver that the peaks code / peaks workflow / peaks
414
+ * route CLI families now use to look up the active session id.
415
+ *
416
+ * Pre-rid each of these 4 CLIs had its own private helper that read
417
+ * `getSkillPresence` (which is `.peaks/.active-skill.json`, set by
418
+ * `peaks skill presence:set <skill>`) instead of the canonical binding
419
+ * (`.peaks/_runtime/session.json`, written by `peaks workspace init`).
420
+ * As a result, any session that was created via `peaks workspace init`
421
+ * but never had `peaks skill presence:set peaks-code` called on it
422
+ * (the common case for downstream consumer projects and any 24h-mode
423
+ * session that never flips sub-skills) reported `NO_ACTIVE_SESSION`
424
+ * from the 4 affected CLIs even though `peaks session info --active`
425
+ * returned a valid id.
426
+ *
427
+ * Resolution order:
428
+ * 1. explicit `override` (caller-passed --session-id)
429
+ * 2. canonicalize-on-read (`getSessionIdCanonical`) — handles the
430
+ * stored "." vs caller-passed absolute realpath mismatch
431
+ * 3. strict-equality fallback (`getSessionId`) — handles the
432
+ * pre-canonicalize binding form
433
+ *
434
+ * The function returns `null` when no binding exists; callers must
435
+ * decide whether `null` is a hard error (most CLIs print
436
+ * `NO_ACTIVE_SESSION` and exit 1) or a soft skip (a few CLIs
437
+ * silently use `'unknown-sid'` as the dispatch fallback).
438
+ */
439
+ export function resolveActiveSessionId(projectRoot, override) {
440
+ if (override !== undefined && override.length > 0)
441
+ return override;
442
+ return getSessionIdCanonical(projectRoot) ?? getSessionId(projectRoot);
443
+ }
411
444
  /**
412
445
  * Get the absolute path to the current session directory.
413
446
  * Creates the session if it doesn't exist.
@@ -63,6 +63,21 @@ export type HookInstallOptions = {
63
63
  * hook entries use it.
64
64
  */
65
65
  readonly skipProgress?: boolean;
66
+ /**
67
+ * Slice 4.0.7-PR-meta-5: opt-in Edit-enforcement hook entry. When
68
+ * `true`, the install emits the additional PreToolUse hook entry
69
+ * pointing at `pre-tool-edit-enforcement.sh`. Default `false` to
70
+ * preserve the existing install behavior; users who want the
71
+ * Edit layer enforced pass `--with-edit-enforcement` on the CLI
72
+ * (or set `PEAKS_INSTALL_EDIT_ENFORCEMENT=1`).
73
+ */
74
+ readonly withEditEnforcement?: boolean;
75
+ /**
76
+ * Slice 4.0.7-PR-meta-5: opt-in scope-counter hook entry. When
77
+ * `true`, the install emits the additional PreToolUse hook entry
78
+ * pointing at `pre-tool-scope-counter.sh`. Default `false`.
79
+ */
80
+ readonly withScopeCounter?: boolean;
66
81
  };
67
82
  /** Sentinel substring identifying a Claude-Code gate-enforce hook entry. */
68
83
  export declare const HOOK_ENFORCE_SENTINEL = "peaks gate enforce";
@@ -129,6 +144,32 @@ export type PeaksHookEntry = {
129
144
  command: string;
130
145
  event: string;
131
146
  };
147
+ /**
148
+ * Slice 4.0.7-PR-meta-1: the Edit-enforcement hook entry. Off by
149
+ * default; opt in via `peaks hooks install --with-edit-enforcement`
150
+ * (or `PEAKS_INSTALL_EDIT_ENFORCEMENT=1` env). The entry points at
151
+ * the new `pre-tool-edit-enforcement.sh` script in
152
+ * `src/services/hooks/`. The matcher targets Edit / Write / MultiEdit
153
+ * / NotebookEdit (Claude Code: "Edit|Write|MultiEdit|NotebookEdit";
154
+ * Trae: same). When the hook fires, the script blocks src/** edits
155
+ * without an RD artifact on disk (per slice 4.0.7-PR-meta-1 design).
156
+ */
157
+ export declare const EDIT_ENFORCEMENT_HOOK_MATCHER = "Edit|Write|MultiEdit|NotebookEdit";
158
+ export declare const EDIT_ENFORCEMENT_HOOK_SENTINEL = "peaks edit enforcement";
159
+ export declare const EDIT_ENFORCEMENT_HOOK_EVENT = "PreToolUse";
160
+ /**
161
+ * Slice 4.0.7-PR-meta-3: the scope-counter hook entry. Off by
162
+ * default. Tracks cumulative application-source edits in the
163
+ * active session and emits a fail-LOUD reflection reminder at
164
+ * thresholds 5, 10, 20. The LLM cannot continue without writing
165
+ * to `.peaks/_runtime/<sid>/scope-reflection.md` once a threshold
166
+ * is hit. Different from the harness-side SCOPE WARNING (which
167
+ * is informational noise); the meta-3 hook surfaces a checkpoint
168
+ * the LLM must reference in its next turn.
169
+ */
170
+ export declare const SCOPE_COUNTER_HOOK_MATCHER = "Edit|Write|MultiEdit|NotebookEdit";
171
+ export declare const SCOPE_COUNTER_HOOK_SENTINEL = "peaks scope counter";
172
+ export declare const SCOPE_COUNTER_HOOK_EVENT = "PreToolUse";
132
173
  /**
133
174
  * The set of Skills whose use is denied at the IDE permissions layer for
134
175
  * every `peaks hooks install` call. Each entry is the namespace-qualified
@@ -57,6 +57,14 @@ function resolveIde(options) {
57
57
  function resolveSkipProgress(options) {
58
58
  return options?.skipProgress === true;
59
59
  }
60
+ /** Slice 4.0.7-PR-meta-5: read the withEditEnforcement opt-in flag. */
61
+ function resolveWithEditEnforcement(options) {
62
+ return options?.withEditEnforcement === true;
63
+ }
64
+ /** Slice 4.0.7-PR-meta-5: read the withScopeCounter opt-in flag. */
65
+ function resolveWithScopeCounter(options) {
66
+ return options?.withScopeCounter === true;
67
+ }
60
68
  /** Resolve settings root dir for a scope. */
61
69
  function resolveSettingsRoot(scope, projectRoot) {
62
70
  if (scope === 'global')
@@ -165,6 +173,32 @@ export function readInstalledEntriesFromSettings(settings, ide) {
165
173
  }
166
174
  return result;
167
175
  }
176
+ /**
177
+ * Slice 4.0.7-PR-meta-1: the Edit-enforcement hook entry. Off by
178
+ * default; opt in via `peaks hooks install --with-edit-enforcement`
179
+ * (or `PEAKS_INSTALL_EDIT_ENFORCEMENT=1` env). The entry points at
180
+ * the new `pre-tool-edit-enforcement.sh` script in
181
+ * `src/services/hooks/`. The matcher targets Edit / Write / MultiEdit
182
+ * / NotebookEdit (Claude Code: "Edit|Write|MultiEdit|NotebookEdit";
183
+ * Trae: same). When the hook fires, the script blocks src/** edits
184
+ * without an RD artifact on disk (per slice 4.0.7-PR-meta-1 design).
185
+ */
186
+ export const EDIT_ENFORCEMENT_HOOK_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
187
+ export const EDIT_ENFORCEMENT_HOOK_SENTINEL = 'peaks edit enforcement';
188
+ export const EDIT_ENFORCEMENT_HOOK_EVENT = 'PreToolUse';
189
+ /**
190
+ * Slice 4.0.7-PR-meta-3: the scope-counter hook entry. Off by
191
+ * default. Tracks cumulative application-source edits in the
192
+ * active session and emits a fail-LOUD reflection reminder at
193
+ * thresholds 5, 10, 20. The LLM cannot continue without writing
194
+ * to `.peaks/_runtime/<sid>/scope-reflection.md` once a threshold
195
+ * is hit. Different from the harness-side SCOPE WARNING (which
196
+ * is informational noise); the meta-3 hook surfaces a checkpoint
197
+ * the LLM must reference in its next turn.
198
+ */
199
+ export const SCOPE_COUNTER_HOOK_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
200
+ export const SCOPE_COUNTER_HOOK_SENTINEL = 'peaks scope counter';
201
+ export const SCOPE_COUNTER_HOOK_EVENT = 'PreToolUse';
168
202
  /**
169
203
  * Compute the per-IDE peaks hook entries to merge into the settings file.
170
204
  * Replaces the slice #1 hardcoded `PEAKS_HOOK_ENTRIES` constant; the constant
@@ -182,11 +216,33 @@ export function readInstalledEntriesFromSettings(settings, ide) {
182
216
  * can find + remove any stale progress-start entry that an older
183
217
  * `peaks hooks install` may have written before this slice.
184
218
  */
185
- function resolveHookEntries(ide, _skipProgress = false) {
219
+ function resolveHookEntries(ide, options = {}) {
186
220
  const spec = resolveHookSpec(ide);
187
- return [
221
+ const entries = [
188
222
  { sentinel: spec.hookEnforceSentinel, matcher: spec.hookEnforceMatcher, command: spec.hookEnforceCommand, event: spec.hookEnforceEvent }
189
223
  ];
224
+ // Slice 4.0.7-PR-meta-1: opt-in Edit enforcement. Default false
225
+ // to preserve the existing `peaks hooks install` behavior; users
226
+ // who want the LLM Edit layer enforced call install with the
227
+ // opt-in flag or env var.
228
+ if ('withEditEnforcement' in options && options.withEditEnforcement === true) {
229
+ entries.push({
230
+ sentinel: EDIT_ENFORCEMENT_HOOK_SENTINEL,
231
+ matcher: EDIT_ENFORCEMENT_HOOK_MATCHER,
232
+ command: 'peaks edit-enforcement',
233
+ event: EDIT_ENFORCEMENT_HOOK_EVENT
234
+ });
235
+ }
236
+ // Slice 4.0.7-PR-meta-3: opt-in scope counter. Default false.
237
+ if ('withScopeCounter' in options && options.withScopeCounter === true) {
238
+ entries.push({
239
+ sentinel: SCOPE_COUNTER_HOOK_SENTINEL,
240
+ matcher: SCOPE_COUNTER_HOOK_MATCHER,
241
+ command: 'peaks scope-counter',
242
+ event: SCOPE_COUNTER_HOOK_EVENT
243
+ });
244
+ }
245
+ return entries;
190
246
  }
191
247
  /**
192
248
  * Legacy sentinel set used by uninstall + status to find and remove stale
@@ -393,7 +449,7 @@ export function planHookInstall(scope, projectRoot, options) {
393
449
  };
394
450
  }
395
451
  /** Merge all peaks-managed hook entries into settings, preserving all other keys and hooks. */
396
- function withHooksInstalledForIde(settings, ide, _skipProgress = false) {
452
+ function withHooksInstalledForIde(settings, ide, options = {}) {
397
453
  const existingHooks = (settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks))
398
454
  ? settings.hooks
399
455
  : {};
@@ -401,7 +457,7 @@ function withHooksInstalledForIde(settings, ide, _skipProgress = false) {
401
457
  // beforeToolCall; Claude: gate-enforce on PreToolUse). Group by event
402
458
  // so each event array is independently merged.
403
459
  const ourByEvent = new Map();
404
- for (const spec of resolveHookEntries(ide)) {
460
+ for (const spec of resolveHookEntries(ide, options)) {
405
461
  const list = ourByEvent.get(spec.event) ?? [];
406
462
  list.push(spec);
407
463
  ourByEvent.set(spec.event, list);
@@ -474,7 +530,7 @@ export function applyHookInstall(scope, projectRoot, options) {
474
530
  // entry. The chain cannot run raw `git worktree add` (or
475
531
  // `podman run`) without an explicit `peaks hooks uninstall`
476
532
  // first. Both helpers are pure + idempotent.
477
- const merged = withTriggeredDenyList(withSuperpowersSkillDenylist(withHooksInstalledForIde(settings, ide)));
533
+ const merged = withTriggeredDenyList(withSuperpowersSkillDenylist(withHooksInstalledForIde(settings, ide, { withEditEnforcement: resolveWithEditEnforcement(options), withScopeCounter: resolveWithScopeCounter(options) })));
478
534
  atomicWriteJson(settingsPath, merged);
479
535
  return { ...baseResult, alreadyInstalled: false, applied: true };
480
536
  }
@@ -83,6 +83,19 @@ function tailLines(text, max) {
83
83
  }
84
84
  async function runTypecheck(projectRoot) {
85
85
  const start = Date.now();
86
+ // Slice 4.0.7-dogfood-PR-10 (ice-cola surface probe 2026-08-02):
87
+ // detect pnpm-workspace.yaml and run a per-package typecheck instead
88
+ // of the root `tsc --noEmit`. Pre-rid the typecheck stage ran
89
+ // against the root, which has no `tsconfig.json` (only
90
+ // `tsconfig.base.json`) in ice-cola, and reported
91
+ // `tsc exited with code 1` in 2ms — a fake-fail with no actionable
92
+ // detail. The fix dispatches to `pnpm -r --parallel exec tsc
93
+ // --noEmit` (or `pnpm -r typecheck` if defined) and reports
94
+ // per-package failures in `data.failedPackages`.
95
+ const isMonorepo = existsSync(join(projectRoot, 'pnpm-workspace.yaml'));
96
+ if (isMonorepo) {
97
+ return runMonorepoTypecheck(projectRoot, start);
98
+ }
86
99
  // Per Windows npx ENOENT (observations 2317+2792 from
87
100
  // 2026-06-09), prefer the project-local `node_modules/.bin/tsc`
88
101
  // (symlink on Unix, .cmd on Windows). The local binary is
@@ -102,6 +115,52 @@ async function runTypecheck(projectRoot) {
102
115
  data: { exitCode: result.exitCode }
103
116
  };
104
117
  }
118
+ /**
119
+ * Slice 4.0.7-dogfood-PR-10: per-package typecheck for pnpm
120
+ * monorepos. Tries `pnpm -r typecheck` first (the convention), then
121
+ * falls back to `pnpm -r --parallel exec tsc --noEmit`. Captures
122
+ * per-package failures so callers can see which sub-packages
123
+ * actually failed (the pre-rid "tsc exited with code 1" was an
124
+ * unhelpful 1-line summary that took 2ms — clear fake-fail).
125
+ */
126
+ async function runMonorepoTypecheck(projectRoot, start) {
127
+ const pnpm = resolveLocalBinary(projectRoot, 'pnpm');
128
+ // First try the convention script.
129
+ const r1 = runCommand(pnpm.command, [...pnpm.args, '-r', '--workspace-concurrency=4', 'typecheck'], projectRoot, 300_000, pnpm.shell);
130
+ let result = r1;
131
+ let mode = 'pnpm -r typecheck';
132
+ if (result.status === 'fail') {
133
+ // Convention script missing in some sub-packages. Fall back to
134
+ // direct tsc invocation in parallel.
135
+ const tsc = resolveLocalBinary(projectRoot, 'tsc');
136
+ result = runCommand(pnpm.command, [...pnpm.args, '-r', '--parallel', 'exec', tsc.command, ...tsc.args, '--noEmit'], projectRoot, 300_000, pnpm.shell);
137
+ mode = 'pnpm -r exec tsc --noEmit';
138
+ }
139
+ const failedPackages = [];
140
+ // pnpm -r prints "<pkg>> error TS..." lines on failure. Extract
141
+ // them so the operator can see which sub-package broke.
142
+ const failLines = result.stdout.split('\n');
143
+ for (const line of failLines) {
144
+ const m = line.match(/^([^>\s]+)>\s+(.+)$/);
145
+ if (m && m[1] !== undefined && m[2] !== undefined && /error TS/.test(m[2])) {
146
+ failedPackages.push({
147
+ package: m[1],
148
+ exitCode: result.exitCode,
149
+ stderrTail: m[2].slice(0, 200)
150
+ });
151
+ }
152
+ }
153
+ return {
154
+ name: 'typecheck',
155
+ description: `${mode} (monorepo per-package typecheck)`,
156
+ status: result.status,
157
+ durationMs: Date.now() - start,
158
+ detail: result.status === 'pass'
159
+ ? `Monorepo typecheck passed in ${Date.now() - start}ms (${mode}).`
160
+ : tailLines(result.stdout + result.stderr, 10) || `pnpm exited with code ${result.exitCode}.`,
161
+ data: { exitCode: result.exitCode, mode, failedPackages }
162
+ };
163
+ }
105
164
  function parseVitestSummary(stdout, fallbackDuration) {
106
165
  // Vitest 2.x prints e.g. "Test Files 1 passed (1)" and "Tests 1 passed (1)"
107
166
  // and "Duration 0.50s" or "Duration 1.23s". Be lenient with regex.
@@ -0,0 +1,59 @@
1
+ import type { ProjectContext } from './project-context.js';
2
+ export interface SkillRecommendation {
3
+ /** GitHub owner/repo, e.g. "vercel-labs/agent-skills". */
4
+ readonly owner: string;
5
+ /** Skill slug within the repo, e.g. "react-best-practices". */
6
+ readonly skillSlug: string;
7
+ /** Display name (best-effort; may equal slug). */
8
+ readonly displayName: string;
9
+ /** Short description from the registry; empty when not parseable. */
10
+ readonly description: string;
11
+ /** Install count from the registry listing; null when unparseable. */
12
+ readonly installCount: number | null;
13
+ /** The shell command the user can paste to install this skill. */
14
+ readonly installCommand: string;
15
+ }
16
+ export interface FindSkillsResult {
17
+ readonly recommendations: readonly SkillRecommendation[];
18
+ /** Queries that found zero candidates (operator-visible). */
19
+ readonly emptyQueries: readonly string[];
20
+ /** `npx skills find` runs that failed (network / registry); non-fatal. */
21
+ readonly failedRuns: readonly {
22
+ readonly query: string;
23
+ readonly reason: string;
24
+ }[];
25
+ }
26
+ /**
27
+ * Map a ProjectContext to a small query set. Returns at most 8
28
+ * queries to keep the network surface bounded; ordering is
29
+ * best-to-worst (more specific first, then broad). Deliberately
30
+ * no hardcoded owner whitelist — `findSkillsForContext` runs
31
+ * `npx skills find <query>` with no `--owner` filter so the
32
+ * registry / leaderboard surfaces whatever the user might want
33
+ * (per find-skills SKILL.md Step 2-3 guidance).
34
+ */
35
+ export declare function queriesForContext(ctx: ProjectContext): readonly string[];
36
+ /**
37
+ * Spawn runner injection point. The default spawns
38
+ * `npx skills find <query>` (no `--owner` filter, so the registry
39
+ * decides what surfaces). Tests pass a deterministic stub.
40
+ */
41
+ export type SkillFindRunner = (query: string) => Promise<string>;
42
+ export declare const defaultSkillFindRunner: SkillFindRunner;
43
+ /**
44
+ * Parse a `npx skills find` stdout blob into SkillRecommendation rows.
45
+ * The CLI output is line-oriented; a row looks like
46
+ * <owner>/<repo>@<slug> <install-count> <description>
47
+ * The parser is lenient — any line that contains a
48
+ * `<owner>/<repo>@<slug>` triple is captured.
49
+ */
50
+ export declare function parseFindOutput(stdout: string): readonly SkillRecommendation[];
51
+ /**
52
+ * Main entry: query the registry for each query, dedup, sort by
53
+ * install-count desc (the registry's leaderboard already orders by
54
+ * popularity; we keep the same ordering). Returns top-N rows.
55
+ */
56
+ export declare function findSkillsForContext(ctx: ProjectContext, options?: {
57
+ readonly topN?: number;
58
+ readonly findRunner?: SkillFindRunner;
59
+ }): Promise<FindSkillsResult>;
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Slice 4.0.7-PR-12 (ice-cola surface probe 2026-08-02).
3
+ *
4
+ * After `peaks standards init` / `peaks standards update` materializes
5
+ * the project-local standards tree, the user often wants to also
6
+ * bring in matching skills from the open agent-skills ecosystem
7
+ * (https://skills.sh/). A TypeScript monorepo with React + NestJS +
8
+ * Tailwind + Playwright + pnpm has natural companions in
9
+ * vercel-labs/agent-skills, anthropics/skills, and similar curated
10
+ * collections.
11
+ *
12
+ * Slice 4.0.7 dogfood round 2 (user direction 2026-08-02): do NOT
13
+ * hardcode a `VERIFIED_SKILL_OWNERS` whitelist. The first version
14
+ * shipped a static list of "trusted" owners, but that is exactly
15
+ * what the find-skills skill tells users NOT to do — let the
16
+ * registry + leaderboard decide. This service now:
17
+ * 1. maps the detected ProjectContext to a small set of natural
18
+ * language queries (component library, build tool, language).
19
+ * 2. runs `npx skills find <query>` ONCE per query with no
20
+ * `--owner` filter, so the registry returns whatever the
21
+ * leaderboard surfaces (vercel-labs/agent-skills at the top
22
+ * for "react" is a registry result, not a hardcoded one).
23
+ * 3. parses + dedups + ranks by install count desc (the
24
+ * registry / leaderboard already orders by popularity).
25
+ * 4. emits a `SkillRecommendation` list with `install` command.
26
+ *
27
+ * **Hard rule (per peaks-loop external-references):** this service
28
+ * NEVER calls `npx skills add` itself. Installation is gated on the
29
+ * user passing `--suggest-skills` to `peaks standards init/update`,
30
+ * and the install command is emitted as a single shell snippet the
31
+ * user can review before pasting. Default behavior: recommendation
32
+ * only, no install. The CLI caller owns the gating.
33
+ *
34
+ * Pure: no I/O of the project tree; only `npx skills find` calls
35
+ * (which are external side effects that we treat as testable via a
36
+ * `skillFindRunner` injection).
37
+ */
38
+ import { spawn } from 'node:child_process';
39
+ /**
40
+ * Map a ProjectContext to a small query set. Returns at most 8
41
+ * queries to keep the network surface bounded; ordering is
42
+ * best-to-worst (more specific first, then broad). Deliberately
43
+ * no hardcoded owner whitelist — `findSkillsForContext` runs
44
+ * `npx skills find <query>` with no `--owner` filter so the
45
+ * registry / leaderboard surfaces whatever the user might want
46
+ * (per find-skills SKILL.md Step 2-3 guidance).
47
+ */
48
+ export function queriesForContext(ctx) {
49
+ const queries = [];
50
+ // Component library first (most specific)
51
+ if (ctx.componentLibrary.name === 'antd')
52
+ queries.push('antd', 'react');
53
+ else if (ctx.componentLibrary.name === 'antd-pro')
54
+ queries.push('antd-pro', 'react', 'pro-components');
55
+ else if (ctx.componentLibrary.name === 'mui')
56
+ queries.push('mui', 'react');
57
+ else if (ctx.componentLibrary.name === 'shadcn')
58
+ queries.push('shadcn', 'react', 'tailwind');
59
+ else if (ctx.componentLibrary.name === 'arco')
60
+ queries.push('arco', 'react');
61
+ else if (ctx.componentLibrary.name === 'tdesign')
62
+ queries.push('tdesign', 'react');
63
+ else if (ctx.componentLibrary.name === 'element-plus')
64
+ queries.push('element-plus', 'vue');
65
+ else if (ctx.componentLibrary.name === 'vant')
66
+ queries.push('vant', 'mobile');
67
+ // Build tool
68
+ if (ctx.buildTool === 'next')
69
+ queries.push('nextjs', 'react');
70
+ else if (ctx.buildTool === 'vite')
71
+ queries.push('vite', 'frontend');
72
+ else if (ctx.buildTool === 'umi')
73
+ queries.push('umi');
74
+ else if (ctx.buildTool === 'rsbuild')
75
+ queries.push('rsbuild', 'rspack');
76
+ else if (ctx.buildTool === 'rspack')
77
+ queries.push('rspack');
78
+ // CSS framework
79
+ if (ctx.cssFrameworks.includes('tailwind'))
80
+ queries.push('tailwind');
81
+ if (ctx.cssFrameworks.includes('less'))
82
+ queries.push('less');
83
+ // Domain hints
84
+ for (const dep of ctx.notableDeps) {
85
+ if (dep.startsWith('@nestjs/'))
86
+ queries.push('nestjs');
87
+ if (dep === 'playwright' || dep === '@playwright/test')
88
+ queries.push('playwright', 'e2e');
89
+ if (dep === 'vitest')
90
+ queries.push('vitest', 'testing');
91
+ if (dep === 'prisma')
92
+ queries.push('prisma');
93
+ if (dep === 'drizzle-orm')
94
+ queries.push('drizzle');
95
+ if (dep === 'graphql' || dep === '@apollo/client')
96
+ queries.push('graphql');
97
+ }
98
+ // Always include the language + the platform itself
99
+ queries.push(ctx.hasPackageJson ? 'typescript' : 'javascript');
100
+ // Dedup while preserving order, then cap at 12 to bound the
101
+ // network surface (each query fans out to the registry). 12
102
+ // keeps both "specific" (antd, react, nestjs) and "broad"
103
+ // (typescript / javascript) queries in the same list.
104
+ const seen = new Set();
105
+ const deduped = queries.filter((q) => (seen.has(q) ? false : (seen.add(q), true)));
106
+ return deduped.slice(0, 12);
107
+ }
108
+ export const defaultSkillFindRunner = (query) => new Promise((resolveText, reject) => {
109
+ // No `--owner` flag: the registry's leaderboard decides the
110
+ // ranking. We do NOT hardcode a "verified owners" whitelist —
111
+ // that is exactly the anti-pattern the find-skills SKILL.md
112
+ // warns against (Step 4: verify install count + source
113
+ // reputation, but trust the leaderboard first).
114
+ const child = spawn('npx', ['skills', 'find', query], {
115
+ stdio: ['ignore', 'pipe', 'pipe'],
116
+ });
117
+ let stdout = '';
118
+ let stderr = '';
119
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
120
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
121
+ child.on('error', reject);
122
+ child.on('close', (code) => {
123
+ if (code === 0)
124
+ resolveText(stdout);
125
+ else
126
+ reject(new Error(`npx skills find exited ${code}: ${stderr.slice(0, 200)}`));
127
+ });
128
+ });
129
+ /**
130
+ * Parse a `npx skills find` stdout blob into SkillRecommendation rows.
131
+ * The CLI output is line-oriented; a row looks like
132
+ * <owner>/<repo>@<slug> <install-count> <description>
133
+ * The parser is lenient — any line that contains a
134
+ * `<owner>/<repo>@<slug>` triple is captured.
135
+ */
136
+ export function parseFindOutput(stdout) {
137
+ const lines = stdout.split('\n');
138
+ const rows = [];
139
+ const rowRe = /([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)@([A-Za-z0-9_.-]+)/;
140
+ for (const line of lines) {
141
+ const m = line.match(rowRe);
142
+ if (!m || m[1] === undefined || m[2] === undefined || m[3] === undefined)
143
+ continue;
144
+ const rowOwner = m[1];
145
+ const repoName = m[2];
146
+ const skillSlug = m[3];
147
+ // Parse install count if the line has a number with K/M suffix.
148
+ let installCount = null;
149
+ const countMatch = line.match(/(\d+(?:\.\d+)?)\s*([KkMm]?)\b/);
150
+ if (countMatch && countMatch[1] !== undefined) {
151
+ const base = parseFloat(countMatch[1]);
152
+ const suffix = (countMatch[2] ?? '').toUpperCase();
153
+ if (!Number.isNaN(base)) {
154
+ installCount = suffix === 'K' ? base * 1_000 : suffix === 'M' ? base * 1_000_000 : base;
155
+ }
156
+ }
157
+ const desc = line.replace(rowRe, '').trim();
158
+ const fullSlug = `${rowOwner}/${repoName}@${skillSlug}`;
159
+ rows.push({
160
+ owner: rowOwner,
161
+ skillSlug,
162
+ displayName: skillSlug,
163
+ description: desc,
164
+ installCount,
165
+ installCommand: `npx skills add ${fullSlug} -g -y`,
166
+ });
167
+ }
168
+ return rows;
169
+ }
170
+ /**
171
+ * Main entry: query the registry for each query, dedup, sort by
172
+ * install-count desc (the registry's leaderboard already orders by
173
+ * popularity; we keep the same ordering). Returns top-N rows.
174
+ */
175
+ export async function findSkillsForContext(ctx, options = {}) {
176
+ const topN = options.topN ?? 8;
177
+ const findRunner = options.findRunner ?? defaultSkillFindRunner;
178
+ const queries = queriesForContext(ctx);
179
+ const seen = new Set();
180
+ const all = [];
181
+ const emptyQueries = [];
182
+ const failedRuns = [];
183
+ for (const query of queries) {
184
+ try {
185
+ const stdout = await findRunner(query);
186
+ const rows = parseFindOutput(stdout);
187
+ if (rows.length === 0) {
188
+ emptyQueries.push(query);
189
+ continue;
190
+ }
191
+ for (const r of rows) {
192
+ const key = `${r.owner}/${r.skillSlug}`;
193
+ if (seen.has(key))
194
+ continue;
195
+ seen.add(key);
196
+ all.push(r);
197
+ }
198
+ }
199
+ catch (err) {
200
+ failedRuns.push({
201
+ query,
202
+ reason: err instanceof Error ? err.message : String(err),
203
+ });
204
+ }
205
+ }
206
+ // Sort by install count desc (registry leaderboard is already
207
+ // ordered by popularity; we keep the same order so the user sees
208
+ // the same top results they would see in `npx skills find`).
209
+ // Tiebreaker: display name asc.
210
+ all.sort((a, b) => {
211
+ if (a.installCount !== null && b.installCount !== null && a.installCount !== b.installCount) {
212
+ return b.installCount - a.installCount;
213
+ }
214
+ if (a.installCount === null && b.installCount !== null)
215
+ return 1;
216
+ if (a.installCount !== null && b.installCount === null)
217
+ return -1;
218
+ return a.displayName.localeCompare(b.displayName);
219
+ });
220
+ return {
221
+ recommendations: all.slice(0, topN),
222
+ emptyQueries,
223
+ failedRuns,
224
+ };
225
+ }
@@ -85,8 +85,23 @@ function parseLanguage(value) {
85
85
  throw new Error('Unsupported standards language');
86
86
  }
87
87
  function detectLanguageInternal(projectRoot) {
88
+ // Slice 4.0.7-dogfood-PR-7 (ice-cola surface probe 2026-08-02): a
89
+ // monorepo root often has no `tsconfig.json` (it has
90
+ // `tsconfig.base.json` + per-package `tsconfig.json`), and the root
91
+ // `package.json` may be a thin workspace manifest (no language-bearing
92
+ // deps). Pre-rid, ice-cola (4-package TypeScript monorepo:
93
+ // NestJS server / React admin / Tauri client / Hermes agent) was
94
+ // misclassified as `javascript` because the root has no `tsconfig.json`
95
+ // and the root `package.json#devDependencies` only lists `peaks-loop`
96
+ // (a string of "javascript" in the standards devDep pack). The fix
97
+ // walks `packages/*/` recursively and prefers the first match of a
98
+ // language-bearing manifest over the root.
99
+ if (scanMonorepoPackages(projectRoot, 'tsconfig.json'))
100
+ return 'typescript';
88
101
  if (existsSync(join(projectRoot, 'tsconfig.json')))
89
102
  return 'typescript';
103
+ if (scanMonorepoPackages(projectRoot, 'package.json'))
104
+ return 'javascript';
90
105
  if (existsSync(join(projectRoot, 'package.json')))
91
106
  return 'javascript';
92
107
  if (existsSync(join(projectRoot, 'pyproject.toml')) || existsSync(join(projectRoot, 'requirements.txt')))
@@ -97,6 +112,35 @@ function detectLanguageInternal(projectRoot) {
97
112
  return 'rust';
98
113
  return 'generic';
99
114
  }
115
+ /**
116
+ * Slice 4.0.7-dogfood-PR-7: look one level deep into the standard
117
+ * pnpm monorepo layout (`packages/<name>/`) and return true if any
118
+ * sub-package contains the named manifest. We deliberately do NOT
119
+ * recurse arbitrarily (no fs.walk) — the convention is one
120
+ * packages/[name]/ level. A repo with a nested
121
+ * workspaces/[name]/[name]/package.json is not in the supported
122
+ * monorepo shape and will fall through to the root scan.
123
+ */
124
+ function scanMonorepoPackages(projectRoot, manifestName) {
125
+ const packagesDir = join(projectRoot, 'packages');
126
+ if (!existsSync(packagesDir))
127
+ return false;
128
+ let entries;
129
+ try {
130
+ entries = readdirSync(packagesDir);
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ for (const entry of entries) {
136
+ if (entry === '.' || entry === '..')
137
+ continue;
138
+ const candidate = join(packagesDir, entry, manifestName);
139
+ if (existsSync(candidate))
140
+ return true;
141
+ }
142
+ return false;
143
+ }
100
144
  /**
101
145
  * Public alias for `detectLanguageInternal` so callers outside this
102
146
  * module (e.g. `workspace-service.ts` for the slice 2026-06-16 RD#7