@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.
Files changed (91) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +1 -1
  2. package/.github/workflows/ci.yml +47 -14
  3. package/AGENTS.md +35 -8
  4. package/CHANGELOG.md +60 -0
  5. package/README.md +174 -97
  6. package/agents/README.md +14 -11
  7. package/bin/agent-cortex.mjs +55 -10
  8. package/bin/installers/claude.mjs +674 -174
  9. package/bin/installers/copilot.mjs +13 -18
  10. package/bin/installers/pi.mjs +293 -51
  11. package/claude/settings.json +4 -0
  12. package/{claude → claude-extras}/scripts/statusline-command.sh +32 -4
  13. package/docs/claude-hooks.md +15 -3
  14. package/docs/ideas/ban-content-assertion-tests.md +73 -0
  15. package/docs/ideas/bead-progress-notes-for-session-resumption.md +75 -0
  16. package/docs/ideas/classify-beads-via-deterministic-script-with-agent-fallback.md +70 -0
  17. package/docs/ideas/ctx-alias-version-and-update-commands.md +65 -0
  18. package/docs/ideas/ctx-feedback-command-for-capturing-ideas-from-any-machine.md +67 -0
  19. package/docs/ideas/deployment-beads-for-post-merge-pipelines.md +36 -0
  20. package/docs/ideas/design-and-delivery-as-separate-bead-trees/design-and-delivery-as-separate-bead-trees.md +99 -0
  21. package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +33 -0
  22. package/docs/ideas/extension-manifests.md +55 -14
  23. package/docs/ideas/improve-pi-system-prompt.md +5 -2
  24. package/docs/ideas/map-every-cortex-tool-to-a-real-pi-equivalent.md +95 -0
  25. package/docs/ideas/record-idea-implementation-questions.md +68 -0
  26. package/docs/ideas/self-flushing-session.md +72 -0
  27. package/docs/ideas/state-in-beads-not-md.md +69 -0
  28. package/docs/ideas/task-session-context.local.md +95 -0
  29. package/docs/ideas/test-data-creation-helpers.md +92 -0
  30. package/docs/ideas/test-planning-stage-bead-in-feature-planning.md +68 -0
  31. package/docs/ideas/worktree-housekeeping-for-closed-beads.md +39 -0
  32. package/docs/technical-direction/harness-config.md +161 -0
  33. package/docs/technical-direction/migration-execution-labels.md +60 -23
  34. package/extensions/agent-guardrails/README.md +45 -0
  35. package/extensions/agent-guardrails/guardrails.test.ts +42 -0
  36. package/extensions/agent-guardrails/guardrails.ts +66 -0
  37. package/extensions/agent-guardrails/index.ts +22 -0
  38. package/extensions/agent-guardrails/node_modules/.bin/pi +21 -0
  39. package/extensions/agent-guardrails/node_modules/.bin/tsc +21 -0
  40. package/extensions/agent-guardrails/node_modules/.bin/tsserver +21 -0
  41. package/extensions/agent-guardrails/node_modules/.bin/tsx +21 -0
  42. package/extensions/agent-guardrails/package.json +19 -0
  43. package/extensions/agent-guardrails/tsconfig.json +12 -0
  44. package/extensions/agent-modes/README.md +12 -5
  45. package/extensions/agent-modes/discover.test.ts +30 -7
  46. package/extensions/agent-modes/discover.ts +1 -1
  47. package/extensions/agent-modes/index.test.ts +208 -0
  48. package/extensions/agent-modes/index.ts +23 -6
  49. package/extensions/agent-modes/package.json +1 -1
  50. package/extensions/skill-stats/index.test.ts +3 -2
  51. package/lib/cli.mjs +30 -12
  52. package/lib/pi-packages.mjs +169 -0
  53. package/package.json +8 -4
  54. package/pi/settings.json +23 -12
  55. package/plugin.json +1 -1
  56. package/scripts/build-copilot-agents.mjs +1 -1
  57. package/scripts/check-no-sync.mjs +49 -0
  58. package/scripts/lib/compose-agent.mjs +32 -14
  59. package/scripts/lib/fs.mjs +31 -0
  60. package/skills/engineering/grill-with-docs/SKILL.md +1 -1
  61. package/skills/planning/classify-bead/REFERENCE.md +85 -0
  62. package/skills/planning/classify-bead/SKILL.md +32 -36
  63. package/skills/planning/classify-bead/scripts/classify-bead.mjs +292 -0
  64. package/skills/planning/create-task/SKILL.md +19 -10
  65. package/skills/planning/write-a-prd/SKILL.md +2 -2
  66. package/skills/style/style-code/REFERENCE.md +32 -2
  67. package/skills/style/style-code/SKILL.md +5 -21
  68. package/skills/style/style-tests/SKILL.md +69 -17
  69. package/skills/workflow/hitl-collab/SKILL.md +1 -1
  70. package/skills/workflow/run-pipeline-stage/SKILL.md +13 -1
  71. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +12 -0
  72. package/test/classify-bead.test.mjs +277 -0
  73. package/test/claude-installer.test.mjs +199 -139
  74. package/test/cli.test.mjs +95 -41
  75. package/test/copilot-installer.test.mjs +92 -82
  76. package/test/fs.test.mjs +76 -0
  77. package/test/helpers/fake-claude.mjs +152 -0
  78. package/test/installer.test.mjs +542 -57
  79. package/test/pi-installer.test.mjs +457 -102
  80. package/test/pi-packages.test.mjs +295 -0
  81. package/token-map.README.md +23 -12
  82. package/token-map.json +8 -7
  83. package/.claude-plugin/marketplace.json +0 -16
  84. package/claude/.claude-plugin/plugin.json +0 -11
  85. package/claude/agents/plan.md +0 -58
  86. package/claude/agents/ralph-plan.md +0 -121
  87. package/claude/agents/ralph.md +0 -193
  88. package/claude/agents/strategy.md +0 -111
  89. package/claude/hooks/scripts/notify.mjs +0 -54
  90. package/claude/hooks.json +0 -38
  91. /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 { writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from "node:fs";
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 = readdirSync(join(root, "agents"), { withFileTypes: true }).sort((a, b) => byName(a.name, b.name));
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 (!existsSync(join(dir, "agent.md"))) continue; // not a composable agent dir
82
- if (!isDirectory(join(dir, "copilot")) || !existsSync(join(dir, "copilot", "frontmatter.json"))) {
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
- mkdirSync(out, { recursive: true });
118
+ await mkdir(out, { recursive: true });
124
119
  for (const { file, content } of files) {
125
- writeFileSync(join(out, file), content);
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;
@@ -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; a null mapping drops the
12
- // token from prose with a warning (pi has no ask_user/skill tool).
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 pi
22
- // loads before package skills, so the substituted copies shadow the raw package
23
- // skills of the same name (pi dedupes by name, user wins).
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 { readFileSync, writeFileSync, mkdirSync, copyFileSync, readdirSync, statSync } from "node:fs";
28
- import { join, dirname } from "node:path";
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 REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
52
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
41
53
  const DEFAULT_OUTPUT = join(homedir(), ".pi", "agent");
42
54
 
43
- const DEFAULT_WARN = (msg) => console.warn(`[pi-installer] ${msg}`);
55
+ const SETTINGS_TEMPLATE = "pi/settings.json";
56
+ const KEYBINDINGS_TEMPLATE = "pi/keybindings.json";
44
57
 
45
- function isDirectory(p) {
46
- try {
47
- return statSync(p).isDirectory();
48
- } catch {
49
- return false;
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
- function isFile(p) {
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 ?? REPO_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 readdirSync(agentsSrc, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
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
- mkdirSync(agentsDir, { recursive: true });
127
- writeFileSync(filePath, content);
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) mkdirSync(skillsDir, { recursive: true });
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 readdirSync(skillsSrc).sort()) {
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 readdirSync(groupDir).sort()) {
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 = readdirSync(src, { withFileTypes: true });
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
- mkdirSync(dest, { recursive: true });
192
- if (entry.name.endsWith(".md")) writeFileSync(to, transform(readFileSync(from, "utf-8")));
193
- else copyFileSync(from, to);
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
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "enabledPlugins": {},
3
+ "extraKnownMarketplaces": {}
4
+ }
@@ -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
@@ -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
- generated plugin as `claude/hooks.json`); support scripts live in
6
- `hooks/claude/scripts/` and are bundled into `claude/hooks/scripts/` by
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 `claude/agents/*.md` (including
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.