@jaybeeuu/agent-cortex 1.39.0 → 1.41.0
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/.agents/skills/style-versioning/SKILL.md +1 -1
- package/.github/workflows/ci.yml +47 -14
- package/AGENTS.md +35 -8
- package/CHANGELOG.md +60 -0
- package/README.md +174 -97
- package/agents/README.md +14 -11
- package/bin/agent-cortex.mjs +55 -10
- package/bin/installers/claude.mjs +674 -174
- package/bin/installers/copilot.mjs +13 -18
- package/bin/installers/pi.mjs +293 -51
- package/claude/settings.json +4 -0
- package/{claude → claude-extras}/scripts/statusline-command.sh +32 -4
- package/docs/claude-hooks.md +15 -3
- package/docs/ideas/ban-content-assertion-tests.md +73 -0
- package/docs/ideas/bead-progress-notes-for-session-resumption.md +75 -0
- package/docs/ideas/classify-beads-via-deterministic-script-with-agent-fallback.md +70 -0
- package/docs/ideas/ctx-alias-version-and-update-commands.md +65 -0
- package/docs/ideas/ctx-feedback-command-for-capturing-ideas-from-any-machine.md +67 -0
- package/docs/ideas/deployment-beads-for-post-merge-pipelines.md +36 -0
- package/docs/ideas/design-and-delivery-as-separate-bead-trees/design-and-delivery-as-separate-bead-trees.md +99 -0
- package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +33 -0
- package/docs/ideas/extension-manifests.md +55 -14
- package/docs/ideas/improve-pi-system-prompt.md +5 -2
- package/docs/ideas/map-every-cortex-tool-to-a-real-pi-equivalent.md +95 -0
- package/docs/ideas/record-idea-implementation-questions.md +68 -0
- package/docs/ideas/self-flushing-session.md +72 -0
- package/docs/ideas/state-in-beads-not-md.md +69 -0
- package/docs/ideas/task-session-context.local.md +95 -0
- package/docs/ideas/test-data-creation-helpers.md +92 -0
- package/docs/ideas/test-planning-stage-bead-in-feature-planning.md +68 -0
- package/docs/ideas/worktree-housekeeping-for-closed-beads.md +39 -0
- package/docs/technical-direction/harness-config.md +161 -0
- package/docs/technical-direction/migration-execution-labels.md +60 -23
- package/extensions/agent-guardrails/README.md +45 -0
- package/extensions/agent-guardrails/guardrails.test.ts +42 -0
- package/extensions/agent-guardrails/guardrails.ts +66 -0
- package/extensions/agent-guardrails/index.ts +22 -0
- package/extensions/agent-guardrails/node_modules/.bin/pi +21 -0
- package/extensions/agent-guardrails/node_modules/.bin/tsc +21 -0
- package/extensions/agent-guardrails/node_modules/.bin/tsserver +21 -0
- package/extensions/agent-guardrails/node_modules/.bin/tsx +21 -0
- package/extensions/agent-guardrails/package.json +19 -0
- package/extensions/agent-guardrails/tsconfig.json +12 -0
- package/extensions/agent-modes/README.md +12 -5
- package/extensions/agent-modes/discover.test.ts +30 -7
- package/extensions/agent-modes/discover.ts +1 -1
- package/extensions/agent-modes/index.test.ts +208 -0
- package/extensions/agent-modes/index.ts +23 -6
- package/extensions/agent-modes/package.json +1 -1
- package/extensions/skill-stats/index.test.ts +3 -2
- package/lib/cli.mjs +30 -12
- package/lib/pi-packages.mjs +169 -0
- package/package.json +8 -4
- package/pi/settings.json +23 -12
- package/plugin.json +1 -1
- package/scripts/build-copilot-agents.mjs +1 -1
- package/scripts/check-no-sync.mjs +49 -0
- package/scripts/lib/compose-agent.mjs +32 -14
- package/scripts/lib/fs.mjs +31 -0
- package/skills/engineering/grill-with-docs/SKILL.md +1 -1
- package/skills/planning/classify-bead/REFERENCE.md +85 -0
- package/skills/planning/classify-bead/SKILL.md +32 -36
- package/skills/planning/classify-bead/scripts/classify-bead.mjs +292 -0
- package/skills/planning/create-task/SKILL.md +19 -10
- package/skills/planning/write-a-prd/SKILL.md +2 -2
- package/skills/style/style-code/REFERENCE.md +32 -2
- package/skills/style/style-code/SKILL.md +5 -21
- package/skills/style/style-tests/SKILL.md +69 -17
- package/skills/workflow/hitl-collab/SKILL.md +1 -1
- package/skills/workflow/run-pipeline-stage/SKILL.md +13 -1
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +12 -0
- package/test/classify-bead.test.mjs +277 -0
- package/test/claude-installer.test.mjs +199 -139
- package/test/cli.test.mjs +95 -41
- package/test/copilot-installer.test.mjs +92 -82
- package/test/fs.test.mjs +76 -0
- package/test/helpers/fake-claude.mjs +152 -0
- package/test/installer.test.mjs +542 -57
- package/test/pi-installer.test.mjs +457 -102
- package/test/pi-packages.test.mjs +295 -0
- package/token-map.README.md +23 -12
- package/token-map.json +8 -7
- package/.claude-plugin/marketplace.json +0 -16
- package/claude/.claude-plugin/plugin.json +0 -11
- package/claude/agents/plan.md +0 -58
- package/claude/agents/ralph-plan.md +0 -121
- package/claude/agents/ralph.md +0 -193
- package/claude/agents/strategy.md +0 -111
- package/claude/hooks/scripts/notify.mjs +0 -54
- package/claude/hooks.json +0 -38
- /package/{claude → claude-extras}/.mcp.json +0 -0
|
@@ -30,22 +30,15 @@
|
|
|
30
30
|
//
|
|
31
31
|
// Zero dependencies so it runs on the CI Node and local Node alike.
|
|
32
32
|
|
|
33
|
-
import {
|
|
33
|
+
import { writeFile, mkdir, readdir } from "node:fs/promises";
|
|
34
34
|
import { join, dirname } from "node:path";
|
|
35
35
|
import { fileURLToPath } from "node:url";
|
|
36
36
|
import { composeAgent } from "../../scripts/lib/compose-agent.mjs";
|
|
37
|
+
import { isDirectory, isFile } from "../../scripts/lib/fs.mjs";
|
|
37
38
|
|
|
38
39
|
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
39
40
|
const DEFAULT_ROOT = join(MODULE_DIR, "..", "..");
|
|
40
41
|
|
|
41
|
-
function isDirectory(p) {
|
|
42
|
-
try {
|
|
43
|
-
return statSync(p).isDirectory();
|
|
44
|
-
} catch {
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
42
|
function byName(a, b) {
|
|
50
43
|
return a.localeCompare(b);
|
|
51
44
|
}
|
|
@@ -71,19 +64,21 @@ function frontmatterYaml(name, fm) {
|
|
|
71
64
|
}
|
|
72
65
|
|
|
73
66
|
/** Compose agents from agents/<name>/; every composable dir must ship a copilot harness. */
|
|
74
|
-
function buildAgents(root) {
|
|
67
|
+
async function buildAgents(root) {
|
|
75
68
|
const files = [];
|
|
76
|
-
const entries =
|
|
69
|
+
const entries = (await readdir(join(root, "agents"), { withFileTypes: true })).sort((a, b) =>
|
|
70
|
+
byName(a.name, b.name),
|
|
71
|
+
);
|
|
77
72
|
|
|
78
73
|
for (const entry of entries) {
|
|
79
74
|
if (!entry.isDirectory()) continue; // skip generated *.agent.md flat files and README
|
|
80
75
|
const dir = join(root, "agents", entry.name);
|
|
81
|
-
if (!
|
|
82
|
-
if (!isDirectory(join(dir, "copilot")) || !
|
|
76
|
+
if (!(await isFile(join(dir, "agent.md")))) continue; // not a composable agent dir
|
|
77
|
+
if (!(await isDirectory(join(dir, "copilot"))) || !(await isFile(join(dir, "copilot", "frontmatter.json")))) {
|
|
83
78
|
throw new Error(`agent "${entry.name}": composable directory without copilot/frontmatter.json — define the copilot harness or exclude the agent`);
|
|
84
79
|
}
|
|
85
80
|
|
|
86
|
-
const fm = composeAgent(root, entry.name, "copilot");
|
|
81
|
+
const fm = await composeAgent(root, entry.name, "copilot");
|
|
87
82
|
files.push({
|
|
88
83
|
name: entry.name,
|
|
89
84
|
file: `${entry.name}.agent.md`,
|
|
@@ -105,9 +100,9 @@ function buildAgents(root) {
|
|
|
105
100
|
* @param {boolean} [options.dryRun] Plan and report without writing anything.
|
|
106
101
|
* @returns {{ output: string, agents: string[], dryRun: boolean }}
|
|
107
102
|
*/
|
|
108
|
-
export function installCopilot({ root = DEFAULT_ROOT, output, dryRun = false } = {}) {
|
|
103
|
+
export async function installCopilot({ root = DEFAULT_ROOT, output, dryRun = false } = {}) {
|
|
109
104
|
const out = output ?? join(root, "agents");
|
|
110
|
-
const files = buildAgents(root);
|
|
105
|
+
const files = await buildAgents(root);
|
|
111
106
|
const summary = {
|
|
112
107
|
output: out,
|
|
113
108
|
agents: files.map((f) => f.name),
|
|
@@ -120,9 +115,9 @@ export function installCopilot({ root = DEFAULT_ROOT, output, dryRun = false } =
|
|
|
120
115
|
return summary;
|
|
121
116
|
}
|
|
122
117
|
|
|
123
|
-
|
|
118
|
+
await mkdir(out, { recursive: true });
|
|
124
119
|
for (const { file, content } of files) {
|
|
125
|
-
|
|
120
|
+
await writeFile(join(out, file), content);
|
|
126
121
|
}
|
|
127
122
|
console.log(`Generated ${files.length} Copilot agent file(s): ${summary.agents.join(", ")}`);
|
|
128
123
|
return summary;
|
package/bin/installers/pi.mjs
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
//
|
|
5
5
|
// <output>/agents/<slug>.agent.md composed agents (default ~/.pi/agent/agents)
|
|
6
6
|
// <output>/skills/<group>/<name>/ token-substituted skill copies (default ~/.pi/agent/skills)
|
|
7
|
+
// <output>/settings.json merged pi settings — CLI owns `packages` (default ~/.pi/agent/settings.json)
|
|
8
|
+
// <output>/keybindings.json generated pi keybindings (default ~/.pi/agent/keybindings.json)
|
|
7
9
|
//
|
|
8
10
|
// Composition per the token-map.json contract:
|
|
9
11
|
// 1. {{SECTION:name}} is resolved from the agent's pi/<name>.md section file
|
|
10
12
|
// (shared composer, scripts/lib/compose-agent.mjs).
|
|
11
|
-
// 2. {{TOOL:key}} is substituted with the pi column
|
|
12
|
-
//
|
|
13
|
+
// 2. {{TOOL:key}} is substituted with the pi column (pi maps ask_user →
|
|
14
|
+
// ask_questions and skill → read); any (future) null pi mapping drops the
|
|
15
|
+
// token from prose with a warning.
|
|
13
16
|
// 3. {{PATH:key}} named keys resolve through the paths table; bare relative
|
|
14
17
|
// paths resolve against the plugin root (default: token-map.json's pi value,
|
|
15
18
|
// overridable with --plugin-root for checkout/symlinked installs).
|
|
@@ -18,17 +21,26 @@
|
|
|
18
21
|
// Skill markdown files get the same TOOL/PATH substitution so pi never serves
|
|
19
22
|
// literal token syntax; other skill files are copied verbatim.
|
|
20
23
|
//
|
|
21
|
-
// Skills install into ~/.pi/agent/skills (pi's user-global skill dir), which
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
+
// Skills install into ~/.pi/agent/skills (pi's user-global skill dir), which is
|
|
25
|
+
// the sole pi skill source: the checkout's raw package skills are not loaded
|
|
26
|
+
// locally because pi/settings.json registers agent-cortex with an object-form
|
|
27
|
+
// packages filter { source, "skills": [] } (extensions stay enabled). The
|
|
28
|
+
// filter exists so pi never sees the raw, un-substituted package skills and
|
|
29
|
+
// reports a name collision per skill (pi warns on collisions, user dir wins).
|
|
30
|
+
// Consumers who install the npm package without that filter still rely on pi's
|
|
31
|
+
// shadowing order (user dir loads before package skills). Re-run this installer
|
|
32
|
+
// after any skill edit so the substituted copy in the user dir stays in sync.
|
|
24
33
|
//
|
|
25
34
|
// Zero dependencies so it runs on the CI Node and local Node alike.
|
|
26
35
|
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
36
|
+
import { readFile, writeFile, mkdir, copyFile, readdir, lstat, rename, rm } from "node:fs/promises";
|
|
37
|
+
import { createHash } from "node:crypto";
|
|
38
|
+
import { join, dirname, basename, relative } from "node:path";
|
|
29
39
|
import { fileURLToPath } from "node:url";
|
|
30
40
|
import { homedir } from "node:os";
|
|
31
41
|
import { composeAgent, loadTokenMap, substituteTokens, translateToolList } from "../../scripts/lib/compose-agent.mjs";
|
|
42
|
+
import { loadRequiredPackages, provisionPiPackages } from "../../lib/pi-packages.mjs";
|
|
43
|
+
import { isDirectory, isFile } from "../../scripts/lib/fs.mjs";
|
|
32
44
|
|
|
33
45
|
const PI = "pi";
|
|
34
46
|
|
|
@@ -37,26 +49,20 @@ const PI = "pi";
|
|
|
37
49
|
// extended before this installer can trust it.
|
|
38
50
|
const CONTRACT_VERSION = 1;
|
|
39
51
|
|
|
40
|
-
const
|
|
52
|
+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
41
53
|
const DEFAULT_OUTPUT = join(homedir(), ".pi", "agent");
|
|
42
54
|
|
|
43
|
-
const
|
|
55
|
+
const SETTINGS_TEMPLATE = "pi/settings.json";
|
|
56
|
+
const KEYBINDINGS_TEMPLATE = "pi/keybindings.json";
|
|
44
57
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
58
|
+
// pi parses settings.json / keybindings.json with a bare JSON.parse (see pi's
|
|
59
|
+
// SettingsManager / KeybindingsManager), so JSONC comments are not an option —
|
|
60
|
+
// a `//` line would make the file unreadable. The GENERATED marker therefore
|
|
61
|
+
// lives in a top-level "//" key, the conventional JSON comment stand-in, which
|
|
62
|
+
// pi ignores on load and preserves on write.
|
|
63
|
+
const GENERATED_KEY = "//";
|
|
52
64
|
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
return statSync(p).isFile();
|
|
56
|
-
} catch {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
65
|
+
const DEFAULT_WARN = (msg) => console.warn(`[pi-installer] ${msg}`);
|
|
60
66
|
|
|
61
67
|
/**
|
|
62
68
|
* Install the agent-cortex pi harness into the pi runtime.
|
|
@@ -69,12 +75,22 @@ function isFile(p) {
|
|
|
69
75
|
* @param {boolean} [options.dryRun] Compute and report without writing anything
|
|
70
76
|
* @param {string} [options.pluginRoot] Override plugin_root used for {{PATH:...}}
|
|
71
77
|
* resolution (default: token-map pi value)
|
|
78
|
+
* @param {boolean} [options.provisionPackages] Install the third-party pi packages
|
|
79
|
+
* declared in the package manifest (package.json
|
|
80
|
+
* pi.packages) into the pi user scope — e.g.
|
|
81
|
+
* pi-questions (ask_questions) and pi-web-access
|
|
82
|
+
* (fetch_content). Off by default; `agent-cortex
|
|
83
|
+
* install pi` turns it on for a real install.
|
|
84
|
+
* @param {(source: string) => Promise<{ok: boolean, error: string | null}>} [options.runInstall]
|
|
85
|
+
* Installer for one package source (default: pi install)
|
|
72
86
|
* @param {(msg: string) => void} [options.warn] Warning sink, also collected in `warnings`
|
|
73
87
|
* @returns {{ agents: {name:string, filePath:string}[], skills: {skills:number, md:number, files:number, dir:string},
|
|
88
|
+
* packages: {planned:string[], installed:string[], failed:{source:string, error:string}[]} | null,
|
|
89
|
+
* settings: ManagedFileResult|null, keybindings: ManagedFileResult|null,
|
|
74
90
|
* warnings: string[], dryRun: boolean, agentsDir: string, skillsDir: string }}
|
|
75
91
|
*/
|
|
76
|
-
export function installPi(options = {}) {
|
|
77
|
-
const root = options.root ??
|
|
92
|
+
export async function installPi(options = {}) {
|
|
93
|
+
const root = options.root ?? PACKAGE_ROOT;
|
|
78
94
|
const output = options.output ?? DEFAULT_OUTPUT;
|
|
79
95
|
const dryRun = options.dryRun ?? false;
|
|
80
96
|
const warnings = [];
|
|
@@ -83,7 +99,7 @@ export function installPi(options = {}) {
|
|
|
83
99
|
(options.warn ?? DEFAULT_WARN)(msg);
|
|
84
100
|
};
|
|
85
101
|
|
|
86
|
-
const tokenMap = loadTokenMap(root);
|
|
102
|
+
const tokenMap = await loadTokenMap(root);
|
|
87
103
|
if (typeof tokenMap.version === "number" && tokenMap.version > CONTRACT_VERSION) {
|
|
88
104
|
throw new Error(
|
|
89
105
|
`token-map.json version ${tokenMap.version} is newer than the contract version ${CONTRACT_VERSION} this installer implements — upgrade agent-cortex`,
|
|
@@ -94,26 +110,42 @@ export function installPi(options = {}) {
|
|
|
94
110
|
const agentsDir = join(output, "agents");
|
|
95
111
|
const skillsDir = join(output, "skills");
|
|
96
112
|
|
|
97
|
-
const agents = installAgents({ root, agentsDir, dryRun, pluginRoot, tokenMap, warn });
|
|
98
|
-
const skills = installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, warn });
|
|
113
|
+
const agents = await installAgents({ root, agentsDir, dryRun, pluginRoot, tokenMap, warn });
|
|
114
|
+
const skills = await installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, warn });
|
|
115
|
+
const config = await installConfig({ root, output, dryRun, warn });
|
|
116
|
+
|
|
117
|
+
// Third-party packages give the composed agents their pi tools (ask_questions
|
|
118
|
+
// from pi-questions, fetch_content from pi-web-access). They are declared in
|
|
119
|
+
// the package manifest and only provisioned on a real install.
|
|
120
|
+
const packages = options.provisionPackages
|
|
121
|
+
? await provisionPiPackages({
|
|
122
|
+
required: await loadRequiredPackages(root),
|
|
123
|
+
piRoot: output,
|
|
124
|
+
dryRun,
|
|
125
|
+
runInstall: options.runInstall,
|
|
126
|
+
warn,
|
|
127
|
+
})
|
|
128
|
+
: null;
|
|
99
129
|
|
|
100
|
-
return { agents, skills, warnings, dryRun, agentsDir, skillsDir };
|
|
130
|
+
return { agents, skills, packages, ...config, warnings, dryRun, agentsDir, skillsDir };
|
|
101
131
|
}
|
|
102
132
|
|
|
103
133
|
// ─── Agents ──────────────────────────────────────────────────────────────────
|
|
104
134
|
|
|
105
|
-
function installAgents({ root, agentsDir, dryRun, pluginRoot, tokenMap, warn }) {
|
|
135
|
+
async function installAgents({ root, agentsDir, dryRun, pluginRoot, tokenMap, warn }) {
|
|
106
136
|
const written = [];
|
|
107
137
|
const agentsSrc = join(root, "agents");
|
|
108
|
-
for (const entry of
|
|
138
|
+
for (const entry of (await readdir(agentsSrc, { withFileTypes: true })).sort((a, b) =>
|
|
139
|
+
a.name.localeCompare(b.name),
|
|
140
|
+
)) {
|
|
109
141
|
if (!entry.isDirectory()) continue; // skip generated *.agent.md flat files
|
|
110
142
|
const dir = join(agentsSrc, entry.name);
|
|
111
|
-
if (!isFile(join(dir, "agent.md"))) continue; // not a composable agent dir
|
|
112
|
-
if (!isDirectory(join(dir, PI)) || !isFile(join(dir, PI, "frontmatter.json"))) {
|
|
143
|
+
if (!(await isFile(join(dir, "agent.md")))) continue; // not a composable agent dir
|
|
144
|
+
if (!(await isDirectory(join(dir, PI))) || !(await isFile(join(dir, PI, "frontmatter.json")))) {
|
|
113
145
|
throw new Error(`agent "${entry.name}": composable directory without pi/frontmatter.json — define the pi harness or exclude the agent`);
|
|
114
146
|
}
|
|
115
147
|
|
|
116
|
-
const fm = composeAgent(root, entry.name, PI, {
|
|
148
|
+
const fm = await composeAgent(root, entry.name, PI, {
|
|
117
149
|
dropNullTools: true,
|
|
118
150
|
pluginRoot,
|
|
119
151
|
resolveRelativePaths: true,
|
|
@@ -123,8 +155,8 @@ function installAgents({ root, agentsDir, dryRun, pluginRoot, tokenMap, warn })
|
|
|
123
155
|
const content = renderPiAgent(entry.name, fm, tools);
|
|
124
156
|
const filePath = join(agentsDir, `${entry.name}.agent.md`);
|
|
125
157
|
if (!dryRun) {
|
|
126
|
-
|
|
127
|
-
|
|
158
|
+
await mkdir(agentsDir, { recursive: true });
|
|
159
|
+
await writeFile(filePath, content);
|
|
128
160
|
}
|
|
129
161
|
written.push({ name: entry.name, filePath });
|
|
130
162
|
}
|
|
@@ -147,24 +179,24 @@ function renderPiAgent(name, fm, tools) {
|
|
|
147
179
|
|
|
148
180
|
// ─── Skills ──────────────────────────────────────────────────────────────────
|
|
149
181
|
|
|
150
|
-
function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, warn }) {
|
|
182
|
+
async function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, warn }) {
|
|
151
183
|
const skillsSrc = join(root, "skills");
|
|
152
|
-
if (!isDirectory(skillsSrc)) return { skills: 0, md: 0, files: 0, dir: skillsDir };
|
|
184
|
+
if (!(await isDirectory(skillsSrc))) return { skills: 0, md: 0, files: 0, dir: skillsDir };
|
|
153
185
|
|
|
154
186
|
const transform = (content) =>
|
|
155
187
|
substituteTokens(content, PI, tokenMap, { dropNullTools: true, pluginRoot, resolveRelativePaths: true, warn });
|
|
156
188
|
|
|
157
|
-
if (!dryRun)
|
|
189
|
+
if (!dryRun) await mkdir(skillsDir, { recursive: true });
|
|
158
190
|
let skills = 0;
|
|
159
191
|
let md = 0;
|
|
160
192
|
let files = 0;
|
|
161
|
-
for (const group of
|
|
193
|
+
for (const group of (await readdir(skillsSrc)).sort()) {
|
|
162
194
|
const groupDir = join(skillsSrc, group);
|
|
163
|
-
if (!isDirectory(groupDir)) continue;
|
|
164
|
-
for (const name of
|
|
165
|
-
if (!isFile(join(groupDir, name, "SKILL.md"))) continue;
|
|
195
|
+
if (!(await isDirectory(groupDir))) continue;
|
|
196
|
+
for (const name of (await readdir(groupDir)).sort()) {
|
|
197
|
+
if (!(await isFile(join(groupDir, name, "SKILL.md")))) continue;
|
|
166
198
|
skills += 1;
|
|
167
|
-
const stats = copyTree(join(groupDir, name), join(skillsDir, group, name), transform, dryRun);
|
|
199
|
+
const stats = await copyTree(join(groupDir, name), join(skillsDir, group, name), transform, dryRun);
|
|
168
200
|
md += stats.md;
|
|
169
201
|
files += stats.files;
|
|
170
202
|
}
|
|
@@ -173,25 +205,235 @@ function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, warn })
|
|
|
173
205
|
}
|
|
174
206
|
|
|
175
207
|
/** Recursively copy a tree; transform .md file contents, copy everything else verbatim. */
|
|
176
|
-
function copyTree(src, dest, transform, dryRun) {
|
|
208
|
+
async function copyTree(src, dest, transform, dryRun) {
|
|
177
209
|
let md = 0;
|
|
178
210
|
let files = 0;
|
|
179
|
-
const entries =
|
|
211
|
+
const entries = await readdir(src, { withFileTypes: true });
|
|
180
212
|
for (const entry of entries) {
|
|
181
213
|
const from = join(src, entry.name);
|
|
182
214
|
const to = join(dest, entry.name);
|
|
183
215
|
if (entry.isDirectory()) {
|
|
184
|
-
const stats = copyTree(from, to, transform, dryRun);
|
|
216
|
+
const stats = await copyTree(from, to, transform, dryRun);
|
|
185
217
|
md += stats.md;
|
|
186
218
|
files += stats.files;
|
|
187
219
|
} else if (entry.isFile()) {
|
|
188
220
|
files += 1;
|
|
189
221
|
if (entry.name.endsWith(".md")) md += 1;
|
|
190
222
|
if (dryRun) continue;
|
|
191
|
-
|
|
192
|
-
if (entry.name.endsWith(".md"))
|
|
193
|
-
else
|
|
223
|
+
await mkdir(dest, { recursive: true });
|
|
224
|
+
if (entry.name.endsWith(".md")) await writeFile(to, transform(await readFile(from, "utf-8")));
|
|
225
|
+
else await copyFile(from, to);
|
|
194
226
|
}
|
|
195
227
|
}
|
|
196
228
|
return { md, files };
|
|
197
|
-
}
|
|
229
|
+
}
|
|
230
|
+
// ─── Pi config files ─────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Materialise the pi config files into the agent dir.
|
|
234
|
+
*
|
|
235
|
+
* `settings.json` is merged: the committed template supplies defaults for keys
|
|
236
|
+
* the live file lacks, while every value already present in the live file wins,
|
|
237
|
+
* so personal config (and anything pi itself wrote) survives re-install. The
|
|
238
|
+
* one key the CLI owns outright is `packages`: it is rewritten from the
|
|
239
|
+
* template with the repo path entry resolved against the target settings file.
|
|
240
|
+
*
|
|
241
|
+
* `keybindings.json` follows a checksum rule: a missing file or a legacy
|
|
242
|
+
* symlink is materialised from the template, an unmodified install is refreshed
|
|
243
|
+
* when the template changes, and a file edited after install is left untouched.
|
|
244
|
+
*/
|
|
245
|
+
async function installConfig({ root, output, dryRun, warn }) {
|
|
246
|
+
return {
|
|
247
|
+
settings: await installSettings({ root, output, dryRun, warn }),
|
|
248
|
+
keybindings: await installKeybindings({ root, output, dryRun, warn }),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function installSettings({ root, output, dryRun, warn }) {
|
|
253
|
+
const template = await readTemplateJson(join(root, SETTINGS_TEMPLATE), warn);
|
|
254
|
+
if (template === null) return null;
|
|
255
|
+
|
|
256
|
+
const target = join(output, "settings.json");
|
|
257
|
+
const merged = mergeSettings(template, await readJsonObject(target, warn));
|
|
258
|
+
merged.packages = resolvePackages(template.packages, root, dirname(target));
|
|
259
|
+
delete merged[GENERATED_KEY];
|
|
260
|
+
|
|
261
|
+
const content = `${JSON.stringify(
|
|
262
|
+
{ [GENERATED_KEY]: generatedMarker(SETTINGS_TEMPLATE), ...merged },
|
|
263
|
+
null,
|
|
264
|
+
2,
|
|
265
|
+
)}\n`;
|
|
266
|
+
return writeManagedJson({ target, content, dryRun });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function installKeybindings({ root, output, dryRun, warn }) {
|
|
270
|
+
const template = await readTemplateJson(join(root, KEYBINDINGS_TEMPLATE), warn);
|
|
271
|
+
if (template === null) return null;
|
|
272
|
+
|
|
273
|
+
const target = join(output, "keybindings.json");
|
|
274
|
+
const content = `${JSON.stringify(
|
|
275
|
+
{ [GENERATED_KEY]: generatedMarker(KEYBINDINGS_TEMPLATE, checksum(template)), ...template },
|
|
276
|
+
null,
|
|
277
|
+
2,
|
|
278
|
+
)}\n`;
|
|
279
|
+
|
|
280
|
+
const current = await readFileMaybe(target);
|
|
281
|
+
if (current !== null && !(await isSymlink(target)) && current !== content) {
|
|
282
|
+
const parsed = tryParseJsonObject(current);
|
|
283
|
+
// An install we wrote carries the checksum it recorded; a body that no
|
|
284
|
+
// longer matches was edited by hand and must not be clobbered. A body
|
|
285
|
+
// byte-identical to the template is a legacy copy and safe to refresh.
|
|
286
|
+
const recorded = parsed === null ? null : readRecordedChecksum(parsed[GENERATED_KEY]);
|
|
287
|
+
const unmodified = parsed !== null && (checksum(parsed) === checksum(template) || recorded === checksum(parsed));
|
|
288
|
+
if (!unmodified) {
|
|
289
|
+
warn(`${target} was modified after install — leaving it untouched (delete it to re-adopt the template)`);
|
|
290
|
+
return { path: target, action: "skipped", removedSymlink: false };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return writeManagedJson({ target, content, dryRun });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Human-readable marker written as the `//` key of every generated config file. */
|
|
297
|
+
function generatedMarker(from, templateChecksum) {
|
|
298
|
+
const base = `GENERATED from ${from} by \`agent-cortex install pi\` — DO NOT EDIT; re-run the installer to update.`;
|
|
299
|
+
return templateChecksum ? `${base} template-checksum:${templateChecksum}` : base;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** SHA-256 of a config body with the generated marker stripped, so it is stable across writes. */
|
|
303
|
+
function checksum(obj) {
|
|
304
|
+
const { [GENERATED_KEY]: _marker, ...body } = obj;
|
|
305
|
+
return createHash("sha256").update(JSON.stringify(body, null, 2)).digest("hex");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function readRecordedChecksum(marker) {
|
|
309
|
+
if (typeof marker !== "string") return null;
|
|
310
|
+
const match = /template-checksum:([0-9a-f]{64})/.exec(marker);
|
|
311
|
+
return match ? match[1] : null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Merge the committed template over the live file. Template keys fill in
|
|
316
|
+
* defaults, live values win (deeply), and keys the template does not know about
|
|
317
|
+
* are preserved — re-installing never discards personal or pi-written config.
|
|
318
|
+
*/
|
|
319
|
+
function mergeSettings(template, existing) {
|
|
320
|
+
const merged = {};
|
|
321
|
+
for (const key of Object.keys(template)) {
|
|
322
|
+
const templateValue = template[key];
|
|
323
|
+
const existingValue = existing[key];
|
|
324
|
+
if (isPlainObject(templateValue) && isPlainObject(existingValue)) {
|
|
325
|
+
merged[key] = mergeSettings(templateValue, existingValue);
|
|
326
|
+
} else {
|
|
327
|
+
merged[key] = key in existing ? existingValue : templateValue;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
for (const key of Object.keys(existing)) {
|
|
331
|
+
if (!(key in merged)) merged[key] = existing[key];
|
|
332
|
+
}
|
|
333
|
+
return merged;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Build the CLI-managed packages list: the template entries with the repo path
|
|
338
|
+
* package resolved relative to the target settings file. pi resolves user-scope
|
|
339
|
+
* local package paths against its agent dir, so this keeps the checkout (or npm
|
|
340
|
+
* install) registered wherever the settings file lands.
|
|
341
|
+
*/
|
|
342
|
+
function resolvePackages(packages, root, settingsDir) {
|
|
343
|
+
if (!Array.isArray(packages)) return packages;
|
|
344
|
+
const repoSource = relative(settingsDir, root) || ".";
|
|
345
|
+
let resolved = false;
|
|
346
|
+
const out = packages.map((entry) => {
|
|
347
|
+
const source = typeof entry === "string" ? entry : entry?.source;
|
|
348
|
+
if (resolved || typeof source !== "string" || source.startsWith("npm:")) return entry;
|
|
349
|
+
resolved = true;
|
|
350
|
+
return typeof entry === "string" ? repoSource : { ...entry, source: repoSource };
|
|
351
|
+
});
|
|
352
|
+
if (!resolved) out.unshift({ source: repoSource, skills: [] });
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Write a managed config file. A legacy symlink is replaced with a real file by
|
|
358
|
+
* writing a sibling temp file and renaming it over the target — rename replaces
|
|
359
|
+
* the link itself, so the repo template is never written through. Returns the
|
|
360
|
+
* action taken so the CLI can report the plan (dry-run included).
|
|
361
|
+
*/
|
|
362
|
+
async function writeManagedJson({ target, content, dryRun }) {
|
|
363
|
+
const symlink = await isSymlink(target);
|
|
364
|
+
const current = await readFileMaybe(target);
|
|
365
|
+
|
|
366
|
+
if (dryRun) {
|
|
367
|
+
return {
|
|
368
|
+
path: target,
|
|
369
|
+
action: symlink ? "would-remove-symlink" : current === content ? "unchanged" : "would-write",
|
|
370
|
+
removedSymlink: symlink,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (!symlink && current === content) {
|
|
374
|
+
return { path: target, action: "unchanged", removedSymlink: false };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
await mkdir(dirname(target), { recursive: true });
|
|
378
|
+
const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
|
|
379
|
+
try {
|
|
380
|
+
await writeFile(tmp, content);
|
|
381
|
+
await rename(tmp, target);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
await rm(tmp, { force: true });
|
|
384
|
+
throw err;
|
|
385
|
+
}
|
|
386
|
+
return { path: target, action: symlink ? "removed-symlink" : "written", removedSymlink: symlink };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function readTemplateJson(filePath, warn) {
|
|
390
|
+
const raw = await readFileMaybe(filePath);
|
|
391
|
+
if (raw === null) {
|
|
392
|
+
warn(`no ${filePath} template in the package — skipping`);
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
const parsed = tryParseJsonObject(raw);
|
|
396
|
+
if (parsed === null) throw new Error(`${filePath} is not a JSON object`);
|
|
397
|
+
return parsed;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function readJsonObject(filePath, warn) {
|
|
401
|
+
const raw = await readFileMaybe(filePath);
|
|
402
|
+
if (raw === null) return {};
|
|
403
|
+
const parsed = tryParseJsonObject(raw);
|
|
404
|
+
if (parsed === null) {
|
|
405
|
+
warn(`${filePath} is not a JSON object — ignoring its contents`);
|
|
406
|
+
return {};
|
|
407
|
+
}
|
|
408
|
+
return parsed;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function tryParseJsonObject(raw) {
|
|
412
|
+
try {
|
|
413
|
+
const parsed = JSON.parse(raw);
|
|
414
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
415
|
+
} catch {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isPlainObject(value) {
|
|
421
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function readFileMaybe(filePath) {
|
|
425
|
+
try {
|
|
426
|
+
return await readFile(filePath, "utf-8");
|
|
427
|
+
} catch (err) {
|
|
428
|
+
if (err.code === "ENOENT") return null;
|
|
429
|
+
throw err;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function isSymlink(filePath) {
|
|
434
|
+
try {
|
|
435
|
+
return (await lstat(filePath)).isSymbolicLink();
|
|
436
|
+
} catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# Claude Code statusLine: shows model name and context-window usage as a small
|
|
3
3
|
# colored progress bar, with used% (color-coded) and used/total token counts
|
|
4
|
-
# in compact form, e.g.:
|
|
5
|
-
# Sonnet 5 ~/src [████░░░░░░] 17% 13k / 1M
|
|
4
|
+
# in compact form, followed by the approximate session cost, e.g.:
|
|
5
|
+
# Sonnet 5 ~/src [████░░░░░░] 17% 13k / 1M $0.42
|
|
6
6
|
|
|
7
7
|
input=$(cat)
|
|
8
8
|
|
|
@@ -10,11 +10,33 @@ model=$(echo "$input" | jq -r '.model.display_name')
|
|
|
10
10
|
used=$(echo "$input" | jq -r '.context_window.used_percentage // empty')
|
|
11
11
|
used_tokens=$(echo "$input" | jq -r '.context_window.total_input_tokens // empty')
|
|
12
12
|
total_tokens=$(echo "$input" | jq -r '.context_window.context_window_size // empty')
|
|
13
|
+
cost_usd=$(echo "$input" | jq -r '.cost.total_cost_usd // empty')
|
|
13
14
|
effort=$(echo "$input" | jq -r '.effort.level // empty')
|
|
14
15
|
agent_name=$(echo "$input" | jq -r '.agent.name // empty')
|
|
15
16
|
cwd=$(echo "$input" | jq -r '.workspace.current_dir // .cwd // empty')
|
|
16
17
|
cwd_display="${cwd/#$HOME/~}"
|
|
17
18
|
|
|
19
|
+
# Prefer the configured autocompaction threshold (from Claude Code settings)
|
|
20
|
+
# as the denominator for context usage, falling back to the model's max
|
|
21
|
+
# context window when no autocompaction limit is configured/available.
|
|
22
|
+
settings_file="$HOME/.claude/settings.json"
|
|
23
|
+
if [ -f "$settings_file" ]; then
|
|
24
|
+
autocompact_enabled=$(jq -r '.autoCompactEnabled // false' "$settings_file" 2>/dev/null)
|
|
25
|
+
autocompact_window=$(jq -r '.autoCompactWindow // empty' "$settings_file" 2>/dev/null)
|
|
26
|
+
if [ "$autocompact_enabled" = "true" ] && [ -n "$autocompact_window" ] && [ "$autocompact_window" != "null" ]; then
|
|
27
|
+
if [ "$autocompact_window" -gt 0 ] 2>/dev/null; then
|
|
28
|
+
total_tokens="$autocompact_window"
|
|
29
|
+
fi
|
|
30
|
+
fi
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# Recompute the used percentage against whichever denominator was selected
|
|
34
|
+
# above (autocompaction limit or model max), rather than trusting the
|
|
35
|
+
# pre-calculated used_percentage which is always relative to the model max.
|
|
36
|
+
if [ -n "$used_tokens" ] && [ -n "$total_tokens" ] && [ "$total_tokens" -gt 0 ] 2>/dev/null; then
|
|
37
|
+
used=$(awk -v u="$used_tokens" -v t="$total_tokens" 'BEGIN { printf "%.2f", (u / t) * 100 }')
|
|
38
|
+
fi
|
|
39
|
+
|
|
18
40
|
# Collapse long paths to their first two and last two components, e.g.
|
|
19
41
|
# ~/src/jaybeeuu/thing/agent-cortex -> ~/src/.../thing/agent-cortex
|
|
20
42
|
# Only applied when there are more than 4 total path components (so short
|
|
@@ -91,6 +113,12 @@ format_tokens() {
|
|
|
91
113
|
}'
|
|
92
114
|
}
|
|
93
115
|
|
|
116
|
+
# Approximate session cost, appended after the context-usage segment, e.g. " $0.42"
|
|
117
|
+
cost_str=""
|
|
118
|
+
if [ -n "$cost_usd" ] && [ "$cost_usd" != "null" ]; then
|
|
119
|
+
cost_str=" ${GREEN}\$$(awk -v c="$cost_usd" 'BEGIN { printf "%.2f", c }')${RESET}"
|
|
120
|
+
fi
|
|
121
|
+
|
|
94
122
|
if [ -n "$used" ]; then
|
|
95
123
|
used_int=$(printf '%.0f' "$used")
|
|
96
124
|
[ "$used_int" -lt 0 ] && used_int=0
|
|
@@ -118,7 +146,7 @@ if [ -n "$used" ]; then
|
|
|
118
146
|
tokens_str=" $(format_tokens "$used_tokens") / $(format_tokens "$total_tokens")"
|
|
119
147
|
fi
|
|
120
148
|
|
|
121
|
-
printf "${CYAN}%s${RESET}%b%b%b ${WHITE}[${bar_color}%s${WHITE}%s${RESET}${WHITE}]${RESET} ${bar_color}%s%%${RESET}%s" "$model" "$effort_str" "$agent_str" "$cwd_str" "$bar" "$bar_empty" "$used_int" "$tokens_str"
|
|
149
|
+
printf "${CYAN}%s${RESET}%b%b%b ${WHITE}[${bar_color}%s${WHITE}%s${RESET}${WHITE}]${RESET} ${bar_color}%s%%${RESET}%s%b" "$model" "$effort_str" "$agent_str" "$cwd_str" "$bar" "$bar_empty" "$used_int" "$tokens_str" "$cost_str"
|
|
122
150
|
else
|
|
123
|
-
printf "${CYAN}%s${RESET}%b%b%b ${WHITE}[context: n/a]${RESET}" "$model" "$effort_str" "$agent_str" "$cwd_str"
|
|
151
|
+
printf "${CYAN}%s${RESET}%b%b%b ${WHITE}[context: n/a]${RESET}%b" "$model" "$effort_str" "$agent_str" "$cwd_str" "$cost_str"
|
|
124
152
|
fi
|
package/docs/claude-hooks.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
The pi extensions in `extensions/` were audited for Claude Code equivalents.
|
|
4
4
|
Implemented ports live in `hooks/claude/hooks.json` (copied verbatim into the
|
|
5
|
-
|
|
6
|
-
`hooks/claude/scripts/` and are bundled into `
|
|
5
|
+
materialised plugin as its `hooks.json`); support scripts live in
|
|
6
|
+
`hooks/claude/scripts/` and are bundled into the plugin's `hooks/scripts/` by
|
|
7
7
|
`bin/installers/claude.mjs` (referenced from hooks.json via `$CLAUDE_PLUGIN_ROOT`).
|
|
8
8
|
|
|
9
9
|
Claude Code's hook model differs from PI's: hooks are shell commands fed a JSON
|
|
@@ -16,6 +16,7 @@ UI surface. Each decision below is grounded in the Claude Code hooks reference.
|
|
|
16
16
|
|---|---|---|
|
|
17
17
|
| `session-start.ts` | `SessionStart` hook commands | **Port** (extended) |
|
|
18
18
|
| `auto-name.ts` | — | **Reject** — built-in |
|
|
19
|
+
| `agent-guardrails/` | — | **Reject** — pi-only (no system-prompt hook) |
|
|
19
20
|
| `notify/` | `Notification` hook + `scripts/notify.mjs` | **Port** |
|
|
20
21
|
| `skill-stats/` | — | **Reject** — no equivalent |
|
|
21
22
|
| `subagent/` | — | **Reject** — native |
|
|
@@ -76,6 +77,17 @@ events), and `elicitation_*` (dialog trivia). Skipped from the pi original: the
|
|
|
76
77
|
response summary (Claude's notification message is already the summary) and the tmux
|
|
77
78
|
session:window.pane title (unavailable to hooks; the plugin title suffices).
|
|
78
79
|
|
|
80
|
+
## `agent-guardrails/` → Reject (pi-only)
|
|
81
|
+
|
|
82
|
+
pi appends behavioural circuit-breakers (retry limits, check-in triggers,
|
|
83
|
+
atomic-change discipline) to the system prompt once per user prompt via
|
|
84
|
+
`before_agent_start` — which fires before the agent loop, so the block applies to
|
|
85
|
+
every turn of that run. Claude Code has no hook that can modify the system prompt;
|
|
86
|
+
its nearest surface, `SessionStart` `additionalContext`, injects conversation
|
|
87
|
+
context rather than system instructions. The rules are harness-agnostic prose, so
|
|
88
|
+
a future port could append them to the existing ported SessionStart hook — out of
|
|
89
|
+
scope for this pi-first slice.
|
|
90
|
+
|
|
79
91
|
## `skill-stats/` → Reject (no equivalent)
|
|
80
92
|
|
|
81
93
|
pi tracks skills loaded into the system prompt, `/skill:` invocations, SKILL.md
|
|
@@ -100,7 +112,7 @@ for little signal. Rejected; Claude Code ships its own usage analytics
|
|
|
100
112
|
## `subagent/` → Reject (native)
|
|
101
113
|
|
|
102
114
|
pi's `subagent` tool spawns isolated `pi --mode json` processes. Claude Code has
|
|
103
|
-
native subagents — the plugin already ships
|
|
115
|
+
native subagents — the plugin already ships `~/.agent-cortex/claude/agents/*.md` (including
|
|
104
116
|
`ralph`, `plan`, `strategy`, `ralph-plan`) executed via the built-in Agent/Task
|
|
105
117
|
tools with per-session context isolation — so a hook-based workaround would be
|
|
106
118
|
strictly worse.
|