peaks-loop 4.0.37 → 4.0.39
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 +37 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/code-review-commands.js +43 -1
- package/dist/cli/commands/code-runtime-commands.js +16 -4
- package/dist/cli/commands/core/skill-command.d.ts +44 -0
- package/dist/cli/commands/core/skill-command.js +67 -3
- package/dist/cli/commands/dispatch-commands.js +42 -12
- package/dist/cli/commands/hooks-commands.js +31 -6
- package/dist/services/code/auto-compact-orchestrator.js +2 -2
- package/dist/services/context/auto-compact-dispatcher.js +3 -1
- package/dist/services/context/build-dispatch-system-prompt.d.ts +35 -1
- package/dist/services/context/build-dispatch-system-prompt.js +55 -3
- package/dist/services/hooks/auto-compact-hook-install.d.ts +14 -1
- package/dist/services/hooks/auto-compact-hook-install.js +39 -15
- package/dist/services/lint/detect-ocr-18.d.ts +9 -1
- package/dist/services/lint/detect-ocr-18.js +114 -10
- package/dist/services/lint/ocr-18-acquire.d.ts +113 -0
- package/dist/services/lint/ocr-18-acquire.js +350 -0
- package/dist/services/lint/ocr-multilang-adapter.js +12 -2
- package/dist/services/skills/hooks-codegate-superpowers.d.ts +41 -0
- package/dist/services/skills/hooks-codegate-superpowers.js +95 -0
- package/dist/services/skills/hooks-settings-service.d.ts +16 -0
- package/dist/services/skills/hooks-settings-service.js +46 -13
- package/dist/services/web/playwright-loader.js +5 -24
- package/dist/services/workflow/provision-dispatch-node.d.ts +42 -0
- package/dist/services/workflow/provision-dispatch-node.js +66 -0
- package/dist/services/workspace/claude-settings-template.d.ts +19 -3
- package/dist/services/workspace/claude-settings-template.js +25 -5
- package/dist/services/workspace/workspace-claude-settings-materializer.js +111 -29
- package/dist/shared/npm-cache.d.ts +2 -0
- package/dist/shared/npm-cache.js +31 -0
- package/package.json +5 -5
- package/skills/bee/peaks-perf-audit/SKILL.md +24 -0
- package/skills/bee/peaks-prd/SKILL.md +24 -0
- package/skills/bee/peaks-qa/SKILL.md +24 -0
- package/skills/bee/peaks-rd/SKILL.md +24 -0
- package/skills/bee/peaks-reviewer/SKILL.md +24 -0
- package/skills/bee/peaks-sc/SKILL.md +24 -0
- package/skills/bee/peaks-security-audit/SKILL.md +24 -0
- package/skills/bee/peaks-txt/SKILL.md +24 -0
- package/skills/bee/peaks-ui/SKILL.md +24 -0
- package/skills/peaks-audit/SKILL.md +24 -0
- package/skills/peaks-code/SKILL.md +24 -0
- package/skills/peaks-content/SKILL.md +24 -0
- package/skills/peaks-doctor/SKILL.md +24 -0
- package/skills/peaks-final-review/SKILL.md +24 -0
- package/skills/peaks-ide/SKILL.md +24 -0
- package/skills/peaks-issue-fix-orchestrator/SKILL.md +24 -0
- package/skills/peaks-resume/SKILL.md +24 -0
- package/skills/peaks-slice-decompose/SKILL.md +24 -0
- package/skills/peaks-solo/SKILL.md +24 -0
- package/skills/peaks-sop/SKILL.md +24 -0
- package/skills/peaks-status/SKILL.md +24 -0
- package/skills/peaks-test/SKILL.md +24 -0
|
@@ -4,7 +4,7 @@ import { join, resolve } from 'node:path';
|
|
|
4
4
|
import { assertSafeSettingsFile } from '../ide/shared/safe-path.js';
|
|
5
5
|
import { atomicWriteJson, readJsonObjectFile } from '../ide/shared/atomic-json.js';
|
|
6
6
|
import { getAdapter } from '../ide/ide-registry.js';
|
|
7
|
-
import { resolveHookSpec, resolveHookEntries, resolveLegacySentinels, SUPERPOWERS_DENIED_SKILLS, formatSuperpowersDenyEntry, SUPERPOWERS_DENY_SENTINELS } from './hooks-codegate-superpowers.js';
|
|
7
|
+
import { resolveHookSpec, resolveHookEntries, resolveLegacySentinels, SUPERPOWERS_DENIED_SKILLS, formatSuperpowersDenyEntry, SUPERPOWERS_DENY_SENTINELS, hasExternalGateExemptions, withExternalGateExemptions, withoutExternalGateExemptions } from './hooks-codegate-superpowers.js';
|
|
8
8
|
export { HOOK_ENFORCE_SENTINEL, HOOK_CODE_GATE_SENTINEL, HOOK_CODE_GATE_MATCHER, HOOK_CODE_GATE_EVENT, HOOK_CODE_GATE_COMMAND, SUPERPOWERS_DENIED_SKILLS } from './hooks-codegate-superpowers.js';
|
|
9
9
|
// --- Module-level defaults (claude-code) -----------------------------------
|
|
10
10
|
// These exports remain for backward compat — tests and downstream callers
|
|
@@ -67,16 +67,30 @@ function resolveLocalSettingsPath(scope, ide, projectRoot) {
|
|
|
67
67
|
function resolveHookTargets(scope, ide, projectRoot) {
|
|
68
68
|
const sharedPath = resolveSettingsPath(scope, ide, projectRoot);
|
|
69
69
|
const localPath = resolveLocalSettingsPath(scope, ide, projectRoot);
|
|
70
|
-
const
|
|
70
|
+
const wantsEnvExemptions = ide === 'claude-code';
|
|
71
71
|
if (localPath === undefined || localPath === sharedPath) {
|
|
72
|
-
|
|
72
|
+
// Global scope lands here: the user-level file is already machine-local,
|
|
73
|
+
// so it is the safe target (there is no sibling to prefer).
|
|
74
|
+
return [{ settingsPath: sharedPath, entries: [...resolveHookEntries(ide)], envExemptions: wantsEnvExemptions }];
|
|
73
75
|
}
|
|
74
|
-
const
|
|
76
|
+
const shared = { settingsPath: sharedPath, entries: [] };
|
|
77
|
+
const local = { settingsPath: localPath, entries: [], envExemptions: wantsEnvExemptions };
|
|
75
78
|
for (const entry of resolveHookEntries(ide)) {
|
|
76
79
|
(entry.machineLocal === true ? local : shared).entries.push(entry);
|
|
77
80
|
}
|
|
78
81
|
return [shared, local];
|
|
79
82
|
}
|
|
83
|
+
/** True when `target` already holds everything the install would write to it. */
|
|
84
|
+
function targetIsSatisfied(target, allSentinels) {
|
|
85
|
+
const settings = readSettingsFile(target.settingsPath);
|
|
86
|
+
if (!shapeMatchesDesired(settings, target.entries, allSentinels))
|
|
87
|
+
return false;
|
|
88
|
+
return target.envExemptions !== true || hasExternalGateExemptions(settings);
|
|
89
|
+
}
|
|
90
|
+
/** Flatten the resolved targets into one `{ matcher, sentinel, settingsPath }` row per entry. */
|
|
91
|
+
function describeEntryTargets(targets) {
|
|
92
|
+
return targets.flatMap((target) => target.entries.map((entry) => ({ matcher: entry.matcher, sentinel: entry.sentinel, settingsPath: target.settingsPath })));
|
|
93
|
+
}
|
|
80
94
|
/** Read a settings file as an object, or `{}` when it does not exist yet. */
|
|
81
95
|
function readSettingsFile(settingsPath) {
|
|
82
96
|
return existsSync(settingsPath) ? readJsonObjectFile(settingsPath) : {};
|
|
@@ -307,11 +321,17 @@ export function planHookInstall(scope, projectRoot, options) {
|
|
|
307
321
|
scope,
|
|
308
322
|
settingsPath,
|
|
309
323
|
exists,
|
|
310
|
-
|
|
324
|
+
// The env clause mirrors `applyHookInstall`'s: without it the dry-run would
|
|
325
|
+
// claim "nothing to do" about a file the install is going to write.
|
|
326
|
+
alreadyInstalled: targets.every((t) => {
|
|
327
|
+
const settings = readSettingsFile(t.settingsPath);
|
|
328
|
+
return isInstalledForEntries(settings, t.entries) && (t.envExemptions !== true || hasExternalGateExemptions(settings));
|
|
329
|
+
}),
|
|
311
330
|
desiredCommand: spec.hookEnforceCommand,
|
|
312
331
|
sentinel: spec.hookEnforceSentinel,
|
|
313
332
|
matcher: spec.hookEnforceMatcher,
|
|
314
|
-
...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {})
|
|
333
|
+
...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {}),
|
|
334
|
+
entryTargets: describeEntryTargets(targets)
|
|
315
335
|
};
|
|
316
336
|
}
|
|
317
337
|
/**
|
|
@@ -379,7 +399,10 @@ export function applyHookInstall(scope, projectRoot, options) {
|
|
|
379
399
|
// as not-yet-installed, so the merge strips the stale entry on the
|
|
380
400
|
// next install call. This is the only path that converges the file
|
|
381
401
|
// on the new shape; pure presence-checks are insufficient.
|
|
382
|
-
|
|
402
|
+
//
|
|
403
|
+
// The external-gate exemption is part of the desired shape too, so a project
|
|
404
|
+
// installed by a release that predates it still converges on upgrade.
|
|
405
|
+
const alreadyInstalled = targets.every((t) => targetIsSatisfied(t, allSentinels));
|
|
383
406
|
const baseResult = {
|
|
384
407
|
scope,
|
|
385
408
|
settingsPath,
|
|
@@ -388,7 +411,8 @@ export function applyHookInstall(scope, projectRoot, options) {
|
|
|
388
411
|
desiredCommand: spec.hookEnforceCommand,
|
|
389
412
|
sentinel: spec.hookEnforceSentinel,
|
|
390
413
|
matcher: spec.hookEnforceMatcher,
|
|
391
|
-
...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {})
|
|
414
|
+
...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {}),
|
|
415
|
+
entryTargets: describeEntryTargets(targets)
|
|
392
416
|
};
|
|
393
417
|
if (baseResult.alreadyInstalled) {
|
|
394
418
|
return { ...baseResult, applied: false };
|
|
@@ -409,9 +433,14 @@ export function applyHookInstall(scope, projectRoot, options) {
|
|
|
409
433
|
//
|
|
410
434
|
// The `permissions.deny` block lives in the adapter's settings file
|
|
411
435
|
// only (as it always has) — it is committed, so the deny list is
|
|
412
|
-
// shared; the machine-local file carries hook entries
|
|
436
|
+
// shared; the machine-local file carries hook entries plus, for Claude
|
|
437
|
+
// Code, the third-party gate exemptions (machine-local by the same
|
|
438
|
+
// argument as the hook `shell` pin).
|
|
413
439
|
for (const target of targets) {
|
|
414
|
-
|
|
440
|
+
let next = withHooksInstalled(readSettingsFile(target.settingsPath), target.entries, allSentinels);
|
|
441
|
+
if (target.envExemptions === true) {
|
|
442
|
+
next = withExternalGateExemptions(next);
|
|
443
|
+
}
|
|
415
444
|
const merged = target.settingsPath === settingsPath
|
|
416
445
|
? withTriggeredDenyList(withSuperpowersSkillDenylist(next))
|
|
417
446
|
: next;
|
|
@@ -478,9 +507,13 @@ export function removeHookInstall(scope, projectRoot, options) {
|
|
|
478
507
|
// trigger-style deny entries via withoutTriggeredDenyList. The
|
|
479
508
|
// chain of helpers is order-independent (each is idempotent and
|
|
480
509
|
// additive over the same set of peaks-managed entries).
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
510
|
+
let finalSettings = nextSettings;
|
|
511
|
+
if (target.envExemptions === true) {
|
|
512
|
+
finalSettings = withoutExternalGateExemptions(finalSettings);
|
|
513
|
+
}
|
|
514
|
+
if (target.settingsPath === settingsPath) {
|
|
515
|
+
finalSettings = withoutTriggeredDenyList(withoutSuperpowersSkillDenylist(finalSettings));
|
|
516
|
+
}
|
|
484
517
|
atomicWriteJson(target.settingsPath, finalSettings);
|
|
485
518
|
}
|
|
486
519
|
return {
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
32
32
|
import { createRequire } from 'node:module';
|
|
33
|
-
import { homedir } from 'node:os';
|
|
34
33
|
import { basename, dirname, join } from 'node:path';
|
|
35
34
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
35
|
+
import { npmExecCacheRoots } from '../../shared/npm-cache.js';
|
|
36
36
|
import { isInsidePath } from '../../shared/path-utils.js';
|
|
37
37
|
/**
|
|
38
38
|
* Exact pin, no caret (tech-doc §3.2). AC2 is a BYTE-COUNT contract and the
|
|
@@ -88,7 +88,10 @@ export function resolvePlaywrightModule() {
|
|
|
88
88
|
* exact pin exists to prevent.
|
|
89
89
|
*/
|
|
90
90
|
function resolveFromNpxCache() {
|
|
91
|
-
|
|
91
|
+
// The roots themselves live in `shared/npm-cache.ts` — the exec cache has
|
|
92
|
+
// exactly one definition, shared with the OCR probe, so the two cannot
|
|
93
|
+
// disagree about what "already installed" means.
|
|
94
|
+
for (const cacheRoot of npmExecCacheRoots()) {
|
|
92
95
|
for (const entry of safeReaddir(cacheRoot)) {
|
|
93
96
|
const resolved = tryResolveFrom(join(cacheRoot, entry, 'node_modules'));
|
|
94
97
|
if (resolved !== null) {
|
|
@@ -98,28 +101,6 @@ function resolveFromNpxCache() {
|
|
|
98
101
|
}
|
|
99
102
|
return null;
|
|
100
103
|
}
|
|
101
|
-
/**
|
|
102
|
-
* `<npm cache>/_npx` candidates: the per-user defaults, and nothing else.
|
|
103
|
-
*
|
|
104
|
-
* `npm_config_cache` / `NPM_CONFIG_CACHE` used to be taken first "when
|
|
105
|
-
* configured". They are not configuration this module may trust: under
|
|
106
|
-
* `npm run`, npm exports the value a repo's own `.npmrc` chose, so a committed
|
|
107
|
-
* `.npmrc` plus a committed `_npx`-shaped tree selected the package that
|
|
108
|
-
* `import()` then executed (security review S2, reproduced). The default roots
|
|
109
|
-
* below are where npm actually puts an `npx` cache.
|
|
110
|
-
*/
|
|
111
|
-
function npxCacheRoots() {
|
|
112
|
-
const roots = [join(homedir(), '.npm', '_npx')];
|
|
113
|
-
if (process.platform === 'win32') {
|
|
114
|
-
for (const key of ['LOCALAPPDATA', 'APPDATA']) {
|
|
115
|
-
const base = process.env[key];
|
|
116
|
-
if (base !== undefined && base.length > 0) {
|
|
117
|
-
roots.push(join(base, 'npm-cache', '_npx'));
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return roots;
|
|
122
|
-
}
|
|
123
104
|
/**
|
|
124
105
|
* Resolve `playwright` as if from `moduleRoot`, and admit it only when it
|
|
125
106
|
* passes `verifyPinnedPackage` against that same root. The anchor is what
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface ProvisionedDispatchNode {
|
|
2
|
+
readonly nodeId: string;
|
|
3
|
+
readonly workflowId: string;
|
|
4
|
+
readonly graphRef: string;
|
|
5
|
+
/** True when this call created the graph; false when it appended to one. */
|
|
6
|
+
readonly graphCreated: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Bind a dispatch to a graph node, creating the graph and the node when
|
|
10
|
+
* they do not exist yet.
|
|
11
|
+
*
|
|
12
|
+
* Why this exists. `--graph-node` was a `.requiredOption`, and the failure
|
|
13
|
+
* message told the caller to "Prepare a graph node via
|
|
14
|
+
* `peaks workflow node prepare` and re-run dispatch". That instruction was
|
|
15
|
+
* unwalkable:
|
|
16
|
+
*
|
|
17
|
+
* - `workflow node prepare` never calls `writeGraph`, so the node it
|
|
18
|
+
* reports is never persisted — it is printed into the envelope's
|
|
19
|
+
* `graph` field (which is a single NODE, not a graph) and dropped;
|
|
20
|
+
* - the graph that command reads must already exist, and no CLI verb
|
|
21
|
+
* creates one.
|
|
22
|
+
*
|
|
23
|
+
* So the documented three-step ritual had no viable first step anywhere
|
|
24
|
+
* outside peaks-loop's own tree, while the flag itself guaranteed nothing:
|
|
25
|
+
* `dispatch-record-writer` treats the graph transition as best-effort and
|
|
26
|
+
* silently returns when the node is absent, and `PEAKS_GRAPH_NODE_NOT_PREPARED`
|
|
27
|
+
* is imported but never thrown on this path. A required parameter that is
|
|
28
|
+
* never validated, blocking every consumer project, is pure friction.
|
|
29
|
+
*
|
|
30
|
+
* Provisioning makes the binding cheap and real instead: the node exists
|
|
31
|
+
* from the first dispatch, so the transition in the record writer has
|
|
32
|
+
* something to find.
|
|
33
|
+
*/
|
|
34
|
+
export declare function provisionDispatchNode(input: {
|
|
35
|
+
readonly projectRoot: string;
|
|
36
|
+
readonly sessionId: string;
|
|
37
|
+
readonly role: string;
|
|
38
|
+
readonly workflowId?: string | undefined;
|
|
39
|
+
readonly graphRef?: string | undefined;
|
|
40
|
+
/** Injectable for tests; defaults to `Date.now()`. */
|
|
41
|
+
readonly now?: (() => number) | undefined;
|
|
42
|
+
}): ProvisionedDispatchNode;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { emptyGraph, readGraph, writeGraph, PEAKS_GRAPH_NOT_FOUND } from './workflow-graph-store.js';
|
|
2
|
+
import { WORKFLOW_ID_REGEX } from './workflow-graph-types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Bind a dispatch to a graph node, creating the graph and the node when
|
|
5
|
+
* they do not exist yet.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists. `--graph-node` was a `.requiredOption`, and the failure
|
|
8
|
+
* message told the caller to "Prepare a graph node via
|
|
9
|
+
* `peaks workflow node prepare` and re-run dispatch". That instruction was
|
|
10
|
+
* unwalkable:
|
|
11
|
+
*
|
|
12
|
+
* - `workflow node prepare` never calls `writeGraph`, so the node it
|
|
13
|
+
* reports is never persisted — it is printed into the envelope's
|
|
14
|
+
* `graph` field (which is a single NODE, not a graph) and dropped;
|
|
15
|
+
* - the graph that command reads must already exist, and no CLI verb
|
|
16
|
+
* creates one.
|
|
17
|
+
*
|
|
18
|
+
* So the documented three-step ritual had no viable first step anywhere
|
|
19
|
+
* outside peaks-loop's own tree, while the flag itself guaranteed nothing:
|
|
20
|
+
* `dispatch-record-writer` treats the graph transition as best-effort and
|
|
21
|
+
* silently returns when the node is absent, and `PEAKS_GRAPH_NODE_NOT_PREPARED`
|
|
22
|
+
* is imported but never thrown on this path. A required parameter that is
|
|
23
|
+
* never validated, blocking every consumer project, is pure friction.
|
|
24
|
+
*
|
|
25
|
+
* Provisioning makes the binding cheap and real instead: the node exists
|
|
26
|
+
* from the first dispatch, so the transition in the record writer has
|
|
27
|
+
* something to find.
|
|
28
|
+
*/
|
|
29
|
+
export function provisionDispatchNode(input) {
|
|
30
|
+
// A caller-supplied workflowId is honoured only when it is well-formed;
|
|
31
|
+
// the option was previously ignored outright, so falling back is strictly
|
|
32
|
+
// additive, and the effective id is returned so the choice is visible.
|
|
33
|
+
const workflowId = typeof input.workflowId === 'string' && WORKFLOW_ID_REGEX.test(input.workflowId)
|
|
34
|
+
? input.workflowId
|
|
35
|
+
: `dispatch-${input.sessionId}`;
|
|
36
|
+
const graphRef = input.graphRef ?? `graphs/${workflowId}.json`;
|
|
37
|
+
let graph;
|
|
38
|
+
let graphCreated = false;
|
|
39
|
+
try {
|
|
40
|
+
graph = readGraph({ projectRoot: input.projectRoot, sessionId: input.sessionId, graphRef, workflowId });
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (err.code !== PEAKS_GRAPH_NOT_FOUND)
|
|
44
|
+
throw err;
|
|
45
|
+
// `emptyGraph` supplies the one terminal node `validateGraph` requires.
|
|
46
|
+
graph = emptyGraph({ workflowId, rootSkill: 'peaks-code' });
|
|
47
|
+
graphCreated = true;
|
|
48
|
+
}
|
|
49
|
+
const stamp = (input.now ?? Date.now)().toString(36);
|
|
50
|
+
const nodeId = `dispatch-${input.role}-${stamp}`;
|
|
51
|
+
const node = {
|
|
52
|
+
id: nodeId,
|
|
53
|
+
kind: 'dispatch',
|
|
54
|
+
label: `${input.role} dispatch`,
|
|
55
|
+
status: 'prepared',
|
|
56
|
+
dependsOn: [],
|
|
57
|
+
};
|
|
58
|
+
writeGraph({
|
|
59
|
+
projectRoot: input.projectRoot,
|
|
60
|
+
sessionId: input.sessionId,
|
|
61
|
+
graphRef,
|
|
62
|
+
workflowId,
|
|
63
|
+
graph: { ...graph, nodes: [...graph.nodes, node] },
|
|
64
|
+
});
|
|
65
|
+
return { nodeId, workflowId, graphRef, graphCreated };
|
|
66
|
+
}
|
|
@@ -75,14 +75,23 @@ export declare const CLAUDE_SETTINGS_LOCAL_FILENAME = ".claude/settings.local.js
|
|
|
75
75
|
* string carries no shell-escaped payload at all and the handler
|
|
76
76
|
* can take the same platform `shell` pin as its siblings. The
|
|
77
77
|
* decision itself is a verbatim relocation — see that file.
|
|
78
|
+
* 1.7.0 — added the `env` block declaring Peaks' workspace tree exempt
|
|
79
|
+
* from a THIRD-PARTY PreToolUse fact-forcing gate
|
|
80
|
+
* (`EXTERNAL_GATE_EXEMPT_ENV`). The comparator now requires the
|
|
81
|
+
* on-disk file to declare those exemptions too, so a project
|
|
82
|
+
* installed by an earlier release refreshes once and converges.
|
|
78
83
|
*/
|
|
79
|
-
export declare const TEMPLATE_VERSION = "1.
|
|
84
|
+
export declare const TEMPLATE_VERSION = "1.7.0";
|
|
80
85
|
/**
|
|
81
|
-
* Compare two serialized template strings for semantic equivalence
|
|
86
|
+
* Compare two serialized template strings for semantic equivalence: does the
|
|
87
|
+
* on-disk file already declare everything the generated template declares?
|
|
82
88
|
*
|
|
83
89
|
* Returns `true` iff both strings parse to objects whose
|
|
84
90
|
* `hooks.PreToolUse` arrays are structurally identical (same length;
|
|
85
|
-
* each entry's `matcher`, `hooks[].type`, `hooks[].command` match)
|
|
91
|
+
* each entry's `matcher`, `hooks[].type`, `hooks[].command` match) AND the
|
|
92
|
+
* on-disk `env` already carries every exemption the template declares (extra
|
|
93
|
+
* on-disk keys and extra globs are allowed — a user may exempt other trees,
|
|
94
|
+
* and a requirement the file already exceeds must not re-trigger a write).
|
|
86
95
|
*
|
|
87
96
|
* Returns `false` on any `JSON.parse` error, shape mismatch, or
|
|
88
97
|
* missing `hooks.PreToolUse`. Whitespace and key order do NOT affect
|
|
@@ -120,6 +129,13 @@ type ClaudeSettingsLocal = {
|
|
|
120
129
|
hooks: {
|
|
121
130
|
PreToolUse: ClaudePreToolUseEntry[];
|
|
122
131
|
};
|
|
132
|
+
/**
|
|
133
|
+
* Exemptions declared to the third-party PreToolUse gate peaks does not own
|
|
134
|
+
* (`EXTERNAL_GATE_EXEMPT_ENV`). They belong in THIS file because it is
|
|
135
|
+
* machine-local and gitignored: a third-party variable name in the committed
|
|
136
|
+
* shared `settings.json` would be pushed to every consumer of the project.
|
|
137
|
+
*/
|
|
138
|
+
env: Record<string, string>;
|
|
123
139
|
};
|
|
124
140
|
/**
|
|
125
141
|
* Build the full template object. The shape is the subset of Claude
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import { dirname, resolve } from 'node:path';
|
|
37
37
|
import { fileURLToPath } from 'node:url';
|
|
38
|
-
import { resolveHookShell, resolveHookSpec } from '../skills/hooks-codegate-superpowers.js';
|
|
38
|
+
import { EXTERNAL_GATE_EXEMPT_ENV, hasExternalGateExemptions, resolveHookShell, resolveHookSpec } from '../skills/hooks-codegate-superpowers.js';
|
|
39
39
|
export const CLAUDE_SETTINGS_LOCAL_FILENAME = '.claude/settings.local.json';
|
|
40
40
|
/**
|
|
41
41
|
* Informational version of the offline template shape. Bumped when the
|
|
@@ -78,14 +78,23 @@ export const CLAUDE_SETTINGS_LOCAL_FILENAME = '.claude/settings.local.json';
|
|
|
78
78
|
* string carries no shell-escaped payload at all and the handler
|
|
79
79
|
* can take the same platform `shell` pin as its siblings. The
|
|
80
80
|
* decision itself is a verbatim relocation — see that file.
|
|
81
|
+
* 1.7.0 — added the `env` block declaring Peaks' workspace tree exempt
|
|
82
|
+
* from a THIRD-PARTY PreToolUse fact-forcing gate
|
|
83
|
+
* (`EXTERNAL_GATE_EXEMPT_ENV`). The comparator now requires the
|
|
84
|
+
* on-disk file to declare those exemptions too, so a project
|
|
85
|
+
* installed by an earlier release refreshes once and converges.
|
|
81
86
|
*/
|
|
82
|
-
export const TEMPLATE_VERSION = '1.
|
|
87
|
+
export const TEMPLATE_VERSION = '1.7.0';
|
|
83
88
|
/**
|
|
84
|
-
* Compare two serialized template strings for semantic equivalence
|
|
89
|
+
* Compare two serialized template strings for semantic equivalence: does the
|
|
90
|
+
* on-disk file already declare everything the generated template declares?
|
|
85
91
|
*
|
|
86
92
|
* Returns `true` iff both strings parse to objects whose
|
|
87
93
|
* `hooks.PreToolUse` arrays are structurally identical (same length;
|
|
88
|
-
* each entry's `matcher`, `hooks[].type`, `hooks[].command` match)
|
|
94
|
+
* each entry's `matcher`, `hooks[].type`, `hooks[].command` match) AND the
|
|
95
|
+
* on-disk `env` already carries every exemption the template declares (extra
|
|
96
|
+
* on-disk keys and extra globs are allowed — a user may exempt other trees,
|
|
97
|
+
* and a requirement the file already exceeds must not re-trigger a write).
|
|
89
98
|
*
|
|
90
99
|
* Returns `false` on any `JSON.parse` error, shape mismatch, or
|
|
91
100
|
* missing `hooks.PreToolUse`. Whitespace and key order do NOT affect
|
|
@@ -127,7 +136,12 @@ export function templateContentMatches(generated, onDisk) {
|
|
|
127
136
|
return false;
|
|
128
137
|
}
|
|
129
138
|
}
|
|
130
|
-
|
|
139
|
+
// A project installed by a release that predates a template-declared
|
|
140
|
+
// exemption still needs the refresh this comparator gates — otherwise the
|
|
141
|
+
// entry would only ever appear on a machine that re-ran `peaks hooks
|
|
142
|
+
// install`. `hasExternalGateExemptions` is the same predicate the installer
|
|
143
|
+
// uses, so the two writers cannot drift apart.
|
|
144
|
+
return hasExternalGateExemptions({ env: parsedOnDisk.env });
|
|
131
145
|
}
|
|
132
146
|
function isTemplateShape(value) {
|
|
133
147
|
if (typeof value !== 'object' || value === null) {
|
|
@@ -234,6 +248,12 @@ export function buildClaudeSettingsLocalJson() {
|
|
|
234
248
|
// the key entirely.
|
|
235
249
|
const writeShell = resolveHookShell();
|
|
236
250
|
return {
|
|
251
|
+
// Slice emit-gateguard-exemption — the third-party gate exemption. Peaks
|
|
252
|
+
// already bypasses its OWN fact-forcing gate for `.peaks/**` (the
|
|
253
|
+
// Write|Edit|MultiEdit handler below); this is the same intent declared in
|
|
254
|
+
// the currency an external PreToolUse gate reads. `peaks hooks install`
|
|
255
|
+
// merges the same row into this file, so the two writers agree.
|
|
256
|
+
env: { ...EXTERNAL_GATE_EXEMPT_ENV },
|
|
237
257
|
hooks: {
|
|
238
258
|
PreToolUse: [
|
|
239
259
|
{
|
|
@@ -9,10 +9,37 @@
|
|
|
9
9
|
* the parent module and calls into this sibling. Function signatures
|
|
10
10
|
* and behaviour are unchanged (verbatim move).
|
|
11
11
|
*/
|
|
12
|
-
import { existsSync } from 'node:fs';
|
|
13
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
|
+
import { withExternalGateExemptions } from '../skills/hooks-codegate-superpowers.js';
|
|
15
16
|
import { buildClaudeSettingsLocalJson, CLAUDE_SETTINGS_LOCAL_FILENAME, templateContentMatches } from './claude-settings-template.js';
|
|
17
|
+
/** Read a file as text, or `undefined` when it cannot be read. */
|
|
18
|
+
function readTextIfPresent(filePath) {
|
|
19
|
+
try {
|
|
20
|
+
return readFileSync(filePath, 'utf8');
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The `env` object of a serialized settings file, or `undefined` when the file
|
|
28
|
+
* is malformed or has no `env` object. Tolerant on purpose: a bad on-disk file
|
|
29
|
+
* must not stop the materialization.
|
|
30
|
+
*/
|
|
31
|
+
function readEnvObject(serialized) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(serialized);
|
|
34
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
35
|
+
return undefined;
|
|
36
|
+
const env = parsed.env;
|
|
37
|
+
return typeof env === 'object' && env !== null && !Array.isArray(env) ? env : undefined;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
16
43
|
/**
|
|
17
44
|
* The peaks-managed snippet appended to the consumer project's
|
|
18
45
|
* `.peaks/.gitignore` so the local-only settings file never lands
|
|
@@ -32,6 +59,8 @@ const PEAKS_GITIGNORE_SNIPPET = [
|
|
|
32
59
|
'# Gitignored so the init flow\'s drift-driven refresh does not show up as',
|
|
33
60
|
'# "modified" in `git status` on every release bump. Recovery path: re-run',
|
|
34
61
|
'# `peaks workspace init` to regenerate; or copy from peaks-loop source.',
|
|
62
|
+
'# Both patterns below are PROJECT-ROOT-relative, so this snippet must land',
|
|
63
|
+
'# in the root .gitignore — see `upsertPeaksGitignoreSnippet`.',
|
|
35
64
|
'.peaks/.claude-settings-template.json',
|
|
36
65
|
PEAKS_GITIGNORE_FOOTER,
|
|
37
66
|
''
|
|
@@ -66,7 +95,15 @@ export async function materializeClaudeSettingsLocal(projectRoot, noClaudeHooks)
|
|
|
66
95
|
const settingsRel = CLAUDE_SETTINGS_LOCAL_FILENAME;
|
|
67
96
|
const settingsPath = join(projectRoot, settingsRel);
|
|
68
97
|
const template = buildClaudeSettingsLocalJson();
|
|
69
|
-
const
|
|
98
|
+
const fileExists = existsSync(settingsPath);
|
|
99
|
+
const existing = fileExists ? readTextIfPresent(settingsPath) : undefined;
|
|
100
|
+
// `.claude/settings.local.json` has a second writer: `peaks hooks install`
|
|
101
|
+
// unions the user's own exemption globs into `env`. Carry that value across
|
|
102
|
+
// the rewrite this function is about to do, or a refresh would silently
|
|
103
|
+
// drop someone else's exemptions. The template's own row is added on top, so
|
|
104
|
+
// the result is a union either way.
|
|
105
|
+
const onDiskEnv = existing === undefined ? undefined : readEnvObject(existing);
|
|
106
|
+
const serialized = JSON.stringify(withExternalGateExemptions(onDiskEnv === undefined ? template : { ...template, env: onDiskEnv }), null, 2) + '\n';
|
|
70
107
|
// Always drop (or self-heal) a copy of the template under .peaks/
|
|
71
108
|
// so the --no-claude-hooks recovery flow has a known source-of-truth
|
|
72
109
|
// on disk. The file is gitignored by the snippet below.
|
|
@@ -84,27 +121,15 @@ export async function materializeClaudeSettingsLocal(projectRoot, noClaudeHooks)
|
|
|
84
121
|
// hooks-settings-service applies the safety check for the Bash
|
|
85
122
|
// gate-enforce path).
|
|
86
123
|
await mkdir(join(projectRoot, '.claude'), { recursive: true });
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (templateContentMatches(serialized, existing)) {
|
|
97
|
-
action = 'already-current';
|
|
98
|
-
}
|
|
99
|
-
else {
|
|
100
|
-
action = 'refreshed';
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
// Treat any read failure as "needs refresh" so the consumer
|
|
105
|
-
// always ends up with a valid template on disk.
|
|
106
|
-
action = 'refreshed';
|
|
107
|
-
}
|
|
124
|
+
// An existing-but-unreadable file is treated as drifted, so the consumer
|
|
125
|
+
// always ends up with a valid template on disk.
|
|
126
|
+
let action = fileExists ? 'refreshed' : 'written';
|
|
127
|
+
// Structural comparison (not a byte comparison): `peaks hooks install` also
|
|
128
|
+
// writes this file, through a different serializer, so an equal hooks tree
|
|
129
|
+
// must be recognized as current or every init would rewrite the file and
|
|
130
|
+
// drop the installer's entries.
|
|
131
|
+
if (existing !== undefined && templateContentMatches(serialized, existing)) {
|
|
132
|
+
action = 'already-current';
|
|
108
133
|
}
|
|
109
134
|
if (action !== 'already-current') {
|
|
110
135
|
await writeFile(settingsPath, serialized, 'utf8');
|
|
@@ -171,13 +196,70 @@ async function writeOfflineTemplateCopy(projectRoot, serialized) {
|
|
|
171
196
|
return action;
|
|
172
197
|
}
|
|
173
198
|
/**
|
|
174
|
-
* Append the peaks-managed
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
199
|
+
* Append the peaks-managed snippet to the consumer project's ROOT
|
|
200
|
+
* `.gitignore`. Preserves any user-managed entries above the snippet.
|
|
201
|
+
* Idempotent: re-running on a project that already has the snippet is a
|
|
202
|
+
* no-op.
|
|
203
|
+
*
|
|
204
|
+
* Root, not `.peaks/.gitignore`. A gitignore pattern containing a slash is
|
|
205
|
+
* anchored to the directory of the .gitignore that holds it, and both
|
|
206
|
+
* patterns here are project-root-relative. Written into `.peaks/.gitignore`
|
|
207
|
+
* they resolved to `.peaks/.claude/settings.local.json` and
|
|
208
|
+
* `.peaks/.peaks/.claude-settings-template.json` — matching nothing, in
|
|
209
|
+
* every project. The root-level `settings.local.json` entry could not be
|
|
210
|
+
* expressed from inside `.peaks/` at all, since gitignore has no `..`.
|
|
211
|
+
*/
|
|
212
|
+
/**
|
|
213
|
+
* The snippet's former home. Projects initialized before the move carry a
|
|
214
|
+
* managed block there that now matches nothing and is maintained by nobody —
|
|
215
|
+
* yet still announces itself as "do not edit by hand", which is worse than
|
|
216
|
+
* absent: a reader takes it for live configuration.
|
|
217
|
+
*/
|
|
218
|
+
const LEGACY_PEAKS_GITIGNORE_PATH = ['.peaks', '.gitignore'];
|
|
219
|
+
/**
|
|
220
|
+
* Strip the managed block from its legacy home, preserving every line the
|
|
221
|
+
* user wrote. Idempotent; a no-op on projects that never had one.
|
|
222
|
+
*
|
|
223
|
+
* The file is deleted only when nothing remains. An empty `.peaks/.gitignore`
|
|
224
|
+
* left behind reads as "peaks put something here and stopped", which invites
|
|
225
|
+
* the next reader to guess.
|
|
226
|
+
*
|
|
227
|
+
* A malformed block (header without footer) is left untouched rather than
|
|
228
|
+
* guessed at — deleting a user's file to tidy our own mess is not a trade
|
|
229
|
+
* worth making.
|
|
178
230
|
*/
|
|
231
|
+
async function stripLegacyPeaksGitignoreSnippet(projectRoot) {
|
|
232
|
+
const legacyPath = join(projectRoot, ...LEGACY_PEAKS_GITIGNORE_PATH);
|
|
233
|
+
if (!existsSync(legacyPath))
|
|
234
|
+
return;
|
|
235
|
+
let existing;
|
|
236
|
+
try {
|
|
237
|
+
existing = await readFile(legacyPath, 'utf8');
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const start = existing.indexOf(PEAKS_GITIGNORE_HEADER);
|
|
243
|
+
if (start === -1)
|
|
244
|
+
return;
|
|
245
|
+
const footerAt = existing.indexOf(PEAKS_GITIGNORE_FOOTER, start);
|
|
246
|
+
if (footerAt === -1)
|
|
247
|
+
return;
|
|
248
|
+
const remainder = (existing.slice(0, start) + existing.slice(footerAt + PEAKS_GITIGNORE_FOOTER.length))
|
|
249
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
250
|
+
.trim();
|
|
251
|
+
if (remainder.length === 0) {
|
|
252
|
+
await rm(legacyPath, { force: true });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
await writeFile(legacyPath, `${remainder}\n`, 'utf8');
|
|
256
|
+
}
|
|
179
257
|
async function upsertPeaksGitignoreSnippet(projectRoot) {
|
|
180
|
-
|
|
258
|
+
// Migration first: the snippet used to live in `.peaks/.gitignore`, where
|
|
259
|
+
// its patterns silently matched nothing. Strip that copy before writing the
|
|
260
|
+
// one that works.
|
|
261
|
+
await stripLegacyPeaksGitignoreSnippet(projectRoot);
|
|
262
|
+
const gitignorePath = join(projectRoot, '.gitignore');
|
|
181
263
|
await mkdir(join(projectRoot, '.peaks'), { recursive: true });
|
|
182
264
|
let existing = '';
|
|
183
265
|
if (existsSync(gitignorePath)) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where npm puts its per-user exec (`npx`) cache.
|
|
3
|
+
*
|
|
4
|
+
* This list lives in exactly ONE place on purpose: whoever resolves a package
|
|
5
|
+
* out of that cache (the Playwright loader `import()`s what it finds, the OCR
|
|
6
|
+
* probe reports it as installed) is answering the same question — "is this
|
|
7
|
+
* package already here?" — and a second copy of the roots is a second answer
|
|
8
|
+
* that would drift from this one.
|
|
9
|
+
*
|
|
10
|
+
* `npm_config_cache` / `NPM_CONFIG_CACHE` are deliberately NOT honoured: under
|
|
11
|
+
* `npm run`, npm exports the value a repo's own `.npmrc` chose, so trusting
|
|
12
|
+
* them lets a committed `.npmrc` select the tree that gets executed. The
|
|
13
|
+
* default roots below are where npm actually puts an `npx` cache.
|
|
14
|
+
*/
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
/** `<npm cache>/_npx` candidates: the per-user defaults, and nothing else. */
|
|
18
|
+
export function npmExecCacheRoots() {
|
|
19
|
+
// Resolved per call, never at module load: callers that relocate the home
|
|
20
|
+
// (tests, and any future sandboxed run) must see the relocation.
|
|
21
|
+
const roots = [join(homedir(), '.npm', '_npx')];
|
|
22
|
+
if (process.platform === 'win32') {
|
|
23
|
+
for (const key of ['LOCALAPPDATA', 'APPDATA']) {
|
|
24
|
+
const base = process.env[key];
|
|
25
|
+
if (base !== undefined && base.length > 0) {
|
|
26
|
+
roots.push(join(base, 'npm-cache', '_npx'));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return roots;
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peaks-loop",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.39",
|
|
4
4
|
"description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
|
|
5
5
|
"author": "SquabbyZ",
|
|
6
6
|
"keywords": [
|
|
@@ -101,10 +101,10 @@
|
|
|
101
101
|
"fzf": "^0.5.2",
|
|
102
102
|
"yaml": "^2.9.0",
|
|
103
103
|
"zod": "^4.4.3",
|
|
104
|
-
"peaks-loop-
|
|
105
|
-
"peaks-loop-
|
|
106
|
-
"peaks-loop-shared
|
|
107
|
-
"peaks-loop-
|
|
104
|
+
"peaks-loop-internal-runtime": "0.0.24",
|
|
105
|
+
"peaks-loop-shared-channel": "0.0.41",
|
|
106
|
+
"peaks-loop-shared": "0.0.73",
|
|
107
|
+
"peaks-loop-mut": "0.1.37"
|
|
108
108
|
},
|
|
109
109
|
"devDependencies": {
|
|
110
110
|
"@changesets/cli": "2.31.1",
|
|
@@ -12,6 +12,30 @@ metadata:
|
|
|
12
12
|
- schema: .peaks/project-scan/audit-output-schema.md
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
+
<!-- peaks:loop-hygiene — generated block; keep byte-identical in every SKILL.md -->
|
|
16
|
+
|
|
17
|
+
## Loop hygiene (every turn — MANDATORY)
|
|
18
|
+
|
|
19
|
+
**Skill header.** While this skill is active, open every turn with
|
|
20
|
+
`Peaks-Loop Skill: <skill> | Peaks-Loop Gate: <gate> | Next: <one short action>`.
|
|
21
|
+
Every turn, not only the first — it is how the user knows which skill is driving.
|
|
22
|
+
|
|
23
|
+
**Context is this skill's own business.** Run `peaks skill presence --json` every turn and
|
|
24
|
+
read its `context` block. When `action` is `auto-fire`, `pre-compact`, or `red-line`,
|
|
25
|
+
run `peaks code auto-compact --project .` **yourself**, then continue. Tell the user the
|
|
26
|
+
ratio in one line if it helps, but never hand them the compaction step: asking the user to
|
|
27
|
+
run `/compact` is the regression the zero-pause contract forbids. This holds in **every
|
|
28
|
+
mode** — standard *and* 24h. The mode moves the threshold, never the obligation.
|
|
29
|
+
|
|
30
|
+
**Read before you edit.** Read a file BEFORE your first `Edit` / `Write` / `MultiEdit` on
|
|
31
|
+
it — for every path outside `.peaks/**` (source, tests, docs, config); `.peaks/**` writes
|
|
32
|
+
are exempt. Skipping that read trips a `PreToolUse` gate (ECC's "Fact-Forcing Gate"),
|
|
33
|
+
which denies the edit. **A denial is not a failure and the tool is not broken — your edit
|
|
34
|
+
was not applied.** Read the file, state the facts the gate asks for (importers, affected
|
|
35
|
+
API, data schemas if any, the user's verbatim instruction), then retry the same operation.
|
|
36
|
+
Do not switch tools, do not give up, do not retry blindly.
|
|
37
|
+
<!-- /peaks:loop-hygiene -->
|
|
38
|
+
|
|
15
39
|
# peaks-perf-audit
|
|
16
40
|
|
|
17
41
|
> **Independent performance audit skill** — separate from `peaks-rd`'s
|