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.
- package/CHANGELOG.md +320 -0
- package/dist/cli/commands/code-job-shape-commands.js +11 -8
- package/dist/cli/commands/code-mode-gate-commands.js +43 -1
- package/dist/cli/commands/code-runtime-commands.js +11 -6
- package/dist/cli/commands/core/standards-command.js +50 -5
- package/dist/cli/commands/dispatch-commands.js +39 -3
- package/dist/cli/commands/hooks-commands.js +26 -9
- package/dist/cli/commands/sub-agent-commands.js +2 -0
- package/dist/cli/commands/sub-agent-exec-command.d.ts +25 -0
- package/dist/cli/commands/sub-agent-exec-command.js +132 -0
- package/dist/cli/commands/sub-agent-shared.d.ts +11 -0
- package/dist/services/audit/backing-detector.d.ts +19 -2
- package/dist/services/audit/backing-detector.js +66 -7
- package/dist/services/audit/enforcers/lint-audit-regression.js +10 -1
- package/dist/services/context/build-dispatch-system-prompt.js +18 -0
- package/dist/services/dispatch/test-tool-detection.d.ts +14 -0
- package/dist/services/dispatch/test-tool-detection.js +22 -0
- package/dist/services/scan/archetype-service.js +52 -2
- package/dist/services/session/index.d.ts +1 -1
- package/dist/services/session/index.js +1 -1
- package/dist/services/session/session-manager.d.ts +29 -0
- package/dist/services/session/session-manager.js +33 -0
- package/dist/services/skills/hooks-settings-service.d.ts +41 -0
- package/dist/services/skills/hooks-settings-service.js +61 -5
- package/dist/services/slice/slice-check-service.js +59 -0
- package/dist/services/standards/find-skills-integration.d.ts +59 -0
- package/dist/services/standards/find-skills-integration.js +225 -0
- package/dist/services/standards/project-standards-service.js +44 -0
- package/package.json +4 -4
- package/scripts/install-skills.mjs +171 -0
- 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,
|
|
219
|
+
function resolveHookEntries(ide, options = {}) {
|
|
186
220
|
const spec = resolveHookSpec(ide);
|
|
187
|
-
|
|
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,
|
|
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
|