@jaybeeuu/agent-cortex 2.4.0 → 2.4.2

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 CHANGED
@@ -1,5 +1,75 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 015ede5: Stop the Claude install from dropping `{{TOOL:read_agent}}` and `{{TOOL:wait_for_agents}}`
8
+ into dangling prose. Both tokens are null on the claude column (Claude reports a subagent's
9
+ result inline, so there is no separate read or wait step), so the installer replaced them with
10
+ an empty string and warned three times — leaving `Read the completed agent's output with and
11
+ parse…` and two similar broken sentences in the installed `ralph` and `design-an-interface`
12
+ skills. Those sentences are now harness-neutral, so both installs read correctly and the
13
+ installer emits no null-mapping warning. A regression guard fails the installer test if a
14
+ shipped skill names a tool the claude column cannot map.
15
+ - 199cefe: Point the shipped pi settings at models that still exist. `tinyModel.model` was
16
+ `opencode/mimo-v2.5-free`, which the provider catalogue has dropped, so the
17
+ setting resolved to nothing and pi fell back to another free model. That fallback
18
+ returns `429 FreeUsageLimitError` once the free quota is spent, and the 429 JSON
19
+ became the entire output of the background call that runs auto-naming and
20
+ notifications — so a subagent run died at session start while the agent itself was
21
+ on a paid model. `tinyModel.model` is now `opencode-go/deepseek-v4.1-flash`.
22
+ `enabledModels` also drops three ids that do not resolve — `opencode-go/deepseek-v4-flash`,
23
+ `opencode/deepseek-v4-flash` and `opencode/mimo-v2.5-free` — which made pi print a
24
+ `No models match pattern` warning on every startup.
25
+ - 6b6080b: Stop the test suite from leaking temp workspaces into `/tmp`. Each test file that
26
+ creates workspaces under the system temp dir now shares one temp root that a
27
+ file-level `after()` hook removes, so the removal happens whether the tests
28
+ passed, failed, or threw before reaching their own cleanup — previously a single
29
+ green run of `test/installer.test.mjs` left 21 workspaces behind (7.4 MB apparent,
30
+ 13 MB on disk: each is a materialised plugin tree of skills, agents and hooks),
31
+ plus one from `test/cli.test.mjs`'s `install claude --output` case. Repeated runs
32
+ could fill a small tmpfs and make unrelated tests fail with ENOSPC. A regression
33
+ guard runs a deliberately failing test file and asserts the run leaves nothing
34
+ behind.
35
+
36
+ ## 2.4.1
37
+
38
+ ### Patch Changes
39
+
40
+ - 7a6288b: Align the review REPORT contract on the run-pipeline-stage playbook shape: the review template
41
+ and the `review-security` skill now emit `OUTCOME: SUCCESS|BLOCKED` with `BLOCKING_ISSUES`,
42
+ instead of the unparsed `REVIEW_OUTCOME`/`CHANGES_REQUESTED` keys.
43
+ - 58d6627: Make the generated Claude plugin manifest valid: `repository` is always emitted as a string, normalising npm's object form to its `url` (falling back to `directory`, and omitted when neither exists) instead of copying the object through. An object there made Claude Code reject the whole plugin, hiding every skill and agent. The installer now also re-reads `claude plugin list --json` after registering and fails with Claude's validation error when the plugin reports failed to load, instead of reporting success. When that list cannot be read, or does not list the plugin, the installer warns that the load state is unconfirmed and omits its success message.
44
+ - 00fbab3: Record the dev loop in `docs/dev-loop.md`: the three-command refresh of the installed runtime
45
+ (`pnpm add -g …@latest`, then `agent-cortex install pi` and `agent-cortex install claude`, with
46
+ `--dry-run` for the plan and a pi restart (or `/reload-plugins` in Claude Code) to apply), the
47
+ installed layout under `~/.agent-cortex`, and how to try uncommitted work without overwriting it —
48
+ stage the checkout with `install pi --output <scratch dir>` and load it with `pi -ne -e
49
+ <scratch dir>`, which writes no settings and needs no undo. The page also states the boundary plainly: the checkout is what is
50
+ developed, `~/.agent-cortex` is what is run, and a path inside `~/src` is never a package source,
51
+ with the two historical violations named as anti-patterns. `README.md` links it from the pi
52
+ staging section and the Claude one-session section, and its Claude plugin section now states the
53
+ correct skill count (34, not 29).
54
+ - dda3a31: `agent-cortex install pi` now reports the two ways a machine ends up in a mixed state, instead of
55
+ leaving them to surface later as missing tools or conflict errors.
56
+
57
+ A configured package source whose path no longer exists is named with the command that drops it.
58
+ Detection is an existence check, so it no longer depends on reading the source `package.json` —
59
+ which is exactly what failed on a pruned worktree path, letting the entry survive a whole install
60
+ and every `pi remove` afterwards. The report gives `pi remove <resolved path>`: pi matches a local
61
+ source by resolved path, and a relative argument resolves against the current directory rather
62
+ than the settings file, so the obvious `pi remove <as configured>` misses. The installer does not
63
+ remove the entry itself — a missing path may be temporarily absent.
64
+
65
+ A pi package the staged package bundles and the user also registers standalone is reported with
66
+ both copies, the copy that takes precedence (load order decides, and on pi 1.x the first-listed
67
+ source wins), and the `pi remove` command that resolves it. The staged package always ships its own copy, so the
68
+ standalone entry is the one to drop; the installer does not remove packages the user configured.
69
+
70
+ `LEGACY_ORPHAN_SKILLS` no longer lists `productivity/migration`: it ships again, so the package root
71
+ attributes its copy, and the entry now follows its own instruction to be dropped once that holds.
72
+
3
73
  ## 2.4.0
4
74
 
5
75
  ### Minor Changes
package/README.md CHANGED
@@ -75,7 +75,10 @@ temp-dir install plus structural checks:
75
75
  - **Manifests** are generated too: the plugin's `.claude-plugin/plugin.json` (its `version`
76
76
  tracks `package.json`, so it never goes stale) and `hooks.json` (copied from the canonical
77
77
  `hooks/claude/hooks.json` source), plus any support files under `hooks/claude/` bundled into
78
- the plugin's `hooks/` so hook commands can reach them via `$CLAUDE_PLUGIN_ROOT`. Hand-authored
78
+ the plugin's `hooks/` so hook commands can reach them via `$CLAUDE_PLUGIN_ROOT`. Its
79
+ `repository` is always a string: `package.json`'s object form is reduced to its `url`, then
80
+ its `directory`, and dropped when neither exists. Claude rejects any other shape and drops
81
+ the whole plugin — every skill and agent in it — with that one field. Hand-authored
79
82
  extras — `.mcp.json` and `scripts/` — are copied into every install from the `claude-extras/`
80
83
  dir, which is their canonical store. See `docs/claude-hooks.md` for the
81
84
  extension→hook mapping and the rejections (auto-name, agent-guardrails, skill-stats, subagent, agent-modes).
@@ -229,10 +232,19 @@ A superseded raw-repo source that `pi remove` cannot drop fails the install
229
232
  reporting success would leave the two-source state registration exists to
230
233
  prevent. Nothing is installed on that path — remove the source by hand and re-run.
231
234
 
235
+ A source whose path no longer exists, and a package the staged bundle ships that
236
+ you also registered standalone, are reported and left configured: the installer
237
+ never removes an entry you added. Each report gives the `pi remove` command that
238
+ resolves it, with the resolved path, because `pi remove` resolves a relative
239
+ argument against the current directory.
240
+
232
241
  Re-run `agent-cortex install pi` after every `git pull` to refresh the staged
233
242
  package; registration is already in place, so that re-run changes nothing in
234
243
  pi's settings. Verify with `pi list` and `pi config` (restart pi to apply).
235
244
 
245
+ Refreshing the installed runtime after a release, and trying uncommitted work without
246
+ overwriting it, are recorded in [`docs/dev-loop.md`](docs/dev-loop.md).
247
+
236
248
  ### Desktop notifications
237
249
 
238
250
  Desktop notifications are handled by the local `extensions/notify/` extension
@@ -270,7 +282,7 @@ instructions file, leaving the rest of that file — and a `.bak` copy — intac
270
282
 
271
283
  A plain `agent-cortex install claude` **materialises** the plugin into the home install
272
284
  root `~/.agent-cortex/claude` — **4 agents** (`strategy`, `plan`, `ralph-plan`, `ralph`),
273
- **29 skills** (copied flat per skill, `{{TOOL:...}}` / `{{PATH:...}}` token-substituted — no
285
+ **34 skills** (copied flat per skill, `{{TOOL:...}}` / `{{PATH:...}}` token-substituted — no
274
286
  symlinks), `SessionStart` + `Notification` hooks, and 2 MCP servers — writes a marketplace
275
287
  manifest at `~/.agent-cortex/.claude-plugin/marketplace.json` exposing `./claude`, and
276
288
  registers it with Claude Code, and merges the committed `claude/settings.json` template into
@@ -306,8 +318,11 @@ claude --plugin-dir /tmp/agent-cortex-claude
306
318
  claude --plugin-dir /tmp/agent-cortex-claude plugin details agent-cortex
307
319
  ```
308
320
 
309
- `SKILL.md` edits are picked up live in that session; agent, hook, and MCP changes need
310
- `/reload-plugins`.
321
+ Edits to the generated copy of a `SKILL.md` are picked up live; re-run the generator after
322
+ editing the checkout. Agent, hook, and MCP changes need `/reload-plugins`.
323
+
324
+ Trying uncommitted work without overwriting the installed runtime is recorded in
325
+ [`docs/dev-loop.md`](docs/dev-loop.md).
311
326
 
312
327
  #### Install persistently (recommended)
313
328
 
@@ -321,7 +336,11 @@ exit code: `claude plugin marketplace list --json` picks add-vs-update and
321
336
  `claude plugin list --json` picks install-vs-update against the materialised version. A
322
337
  fresh install adds the marketplace and installs the plugin; a re-run is the update path
323
338
  (`marketplace update`, plus `plugin update` only when a newer version is materialised); a
324
- repeat install at the same version is a true no-op. It requires the `claude` plugin CLI
339
+ repeat install at the same version is a true no-op. Those commands exit 0 on a manifest Claude
340
+ rejects, so the installer re-reads `claude plugin list --json` after they run. A plugin that
341
+ list reports as failed to load fails the install with Claude's validation message. A list the
342
+ installer cannot read, or one that omits the plugin, warns that the load state is unconfirmed
343
+ and prints no success line. It requires the `claude` plugin CLI
325
344
  (Claude Code v2+): with a missing or pre-v2 CLI the installer warns and prints the manual
326
345
  commands below, exiting 0 — `--require-register` makes registration mandatory and fails
327
346
  non-zero when it can't run:
@@ -52,7 +52,12 @@
52
52
  // re-run is the update path, and a repeat install at the same version is a
53
53
  // true no-op. If the `claude` CLI is missing or predates the plugin subcommand
54
54
  // (v2+), the installer warns and prints the manual registration commands,
55
- // exiting non-zero only with --require-register.
55
+ // exiting non-zero only with --require-register. After the commands run,
56
+ // `claude plugin list --json` is re-read: a plugin Claude Code reports as failed
57
+ // to load fails the install with its validation error, because those commands
58
+ // exit 0 on a manifest they reject. When that list cannot be read, or omits the
59
+ // plugin, the load state is unconfirmed and the installer warns instead of
60
+ // claiming success.
56
61
  //
57
62
  // Zero dependencies so it runs on the CI Node and local Node alike.
58
63
 
@@ -253,6 +258,26 @@ async function copyTree(src, dest, transform, dryRun) {
253
258
  return { md, files };
254
259
  }
255
260
 
261
+ /**
262
+ * Normalise package.json's `repository` field into the string Claude Code's
263
+ * plugin manifest requires. npm permits a string or an object
264
+ * (`{ type, url, directory }`); Claude rejects anything but a string, and one
265
+ * invalid manifest makes the whole plugin fail to load, so the object form must
266
+ * never be copied through. Prefer `url`, fall back to `directory`, and omit the
267
+ * field when neither is usable rather than emitting junk.
268
+ * @param {unknown} repository parsed package.json `repository` value
269
+ * @returns {string | undefined}
270
+ */
271
+ function normaliseRepository(repository) {
272
+ if (typeof repository === "string") return repository.trim() === "" ? undefined : repository;
273
+ if (repository === null || typeof repository !== "object") return undefined;
274
+ const { url, directory } = /** @type {{ url?: unknown, directory?: unknown }} */ (repository);
275
+ for (const candidate of [url, directory]) {
276
+ if (typeof candidate === "string" && candidate.trim() !== "") return candidate;
277
+ }
278
+ return undefined;
279
+ }
280
+
256
281
  /**
257
282
  * plugin.json manifest; version tracks the package so it can never go stale.
258
283
  * @param {string} root
@@ -268,7 +293,9 @@ async function buildPluginJson(root) {
268
293
  description: "Personal Claude Code plugin with custom agents and skills",
269
294
  author: { name: "jaybeeuu" },
270
295
  license: pkg.license ?? "MIT",
271
- repository: pkg.repository,
296
+ // JSON.stringify drops an undefined value, so an unusable repository
297
+ // is omitted instead of emitted as junk.
298
+ repository: normaliseRepository(pkg.repository),
272
299
  hooks: "./hooks.json",
273
300
  },
274
301
  null,
@@ -940,6 +967,50 @@ async function installedPluginVersion(claudeBin, pluginId) {
940
967
  }
941
968
  }
942
969
 
970
+ /**
971
+ * Verdict of `claude plugin list --json` on whether `pluginId` actually loads.
972
+ * An entry carries `errors` when the materialised manifest could not be loaded
973
+ * (the CLI then also reports version "unknown") — a broken plugin that every
974
+ * install command still exits 0 on. Unreadable or unexpected output yields
975
+ * `unknown`, and a readable list that omits the plugin yields `not-listed`:
976
+ * both are no verdict, never a false failure.
977
+ * @typedef {{ verdict: "unknown" } | { verdict: "not-listed" } | { verdict: "loaded" } | { verdict: "failed", errors: string[] }} PluginLoadVerdict
978
+ */
979
+
980
+ /**
981
+ * The string members of `values`, in order.
982
+ * @param {unknown[]} values
983
+ * @returns {string[]}
984
+ */
985
+ function stringValues(values) {
986
+ /** @type {string[]} */
987
+ const strings = [];
988
+ for (const value of values) if (typeof value === "string") strings.push(value);
989
+ return strings;
990
+ }
991
+
992
+ /**
993
+ * @param {string} claudeBin
994
+ * @param {string} pluginId
995
+ * @returns {Promise<PluginLoadVerdict>}
996
+ */
997
+ async function pluginLoadVerdict(claudeBin, pluginId) {
998
+ const out = await queryClaudeCommand(claudeBin, ["plugin", "list", "--json"]);
999
+ if (out == null) return { verdict: "unknown" };
1000
+ /** @type {any} */
1001
+ let entries;
1002
+ try {
1003
+ entries = JSON.parse(out.trim() || "[]");
1004
+ } catch {
1005
+ return { verdict: "unknown" };
1006
+ }
1007
+ if (!Array.isArray(entries)) return { verdict: "unknown" };
1008
+ const entry = entries.find((/** @type {any} */ e) => e?.id === pluginId);
1009
+ if (entry === undefined) return { verdict: "not-listed" };
1010
+ const errors = Array.isArray(entry.errors) ? stringValues(/** @type {unknown[]} */ (entry.errors)) : [];
1011
+ return errors.length > 0 ? { verdict: "failed", errors } : { verdict: "loaded" };
1012
+ }
1013
+
943
1014
  /**
944
1015
  * Register the materialised plugin with Claude Code by driving the `claude
945
1016
  * plugin` CLI against the home install root as the marketplace source.
@@ -967,6 +1038,13 @@ async function installedPluginVersion(claudeBin, pluginId) {
967
1038
  * `requireRegister` is set — the default is warn-only because the plugin is
968
1039
  * already materialised by then; the file side of the install succeeded.
969
1040
  *
1041
+ * A plugin that `claude plugin list --json` reports as failed to load is never
1042
+ * reported as a success: the plugin commands exit 0 on an invalid manifest, so
1043
+ * the run throws with Claude's validation error (which names the manifest)
1044
+ * rather than leaving every skill and agent in the plugin silently unavailable.
1045
+ * When that list cannot be read, or does not list the plugin, the load state is
1046
+ * unconfirmed: the install warns and skips the success line.
1047
+ *
970
1048
  * @param {object} options
971
1049
  * @param {string} [options.root] Marketplace root carrying .claude-plugin/…
972
1050
  * (defaults to the repo containing this module)
@@ -1054,8 +1132,33 @@ export async function registerClaude({
1054
1132
  console.log(` ${claudeBin} ${args.join(" ")}`);
1055
1133
  await runClaudeCommand(claudeBin, args);
1056
1134
  }
1057
- console.log(
1058
- `Installed — verify with "claude plugin list" / "claude plugin details ${plugin}" (restart Claude Code to apply).`,
1059
- );
1135
+ // The commands exiting 0 does not mean the plugin loads: a manifest Claude
1136
+ // rejects still installs and updates cleanly. Claiming success there would
1137
+ // hide that every skill and agent in the plugin is unavailable.
1138
+ const load = await pluginLoadVerdict(claudeBin, pluginId);
1139
+ if (load.verdict === "failed") {
1140
+ throw new Error(
1141
+ `plugin "${pluginId}" is installed but Claude Code reports it as failed to load: ${load.errors.join("; ")} — ` +
1142
+ `every skill and agent in the plugin stays unavailable until the manifest loads; fix it and re-run`,
1143
+ );
1144
+ }
1145
+ // No verdict is not a success either: an unreadable list or a list that omits
1146
+ // the plugin leaves its load state unconfirmed, so warn instead of claiming
1147
+ // the plugin is installed and working.
1148
+ if (load.verdict === "unknown") {
1149
+ warn(
1150
+ `could not read "${claudeBin} plugin list --json", so the plugin's load state is unconfirmed — ` +
1151
+ `verify with "${claudeBin} plugin list" (restart Claude Code to apply)`,
1152
+ );
1153
+ } else if (load.verdict === "not-listed") {
1154
+ warn(
1155
+ `"${pluginId}" is not in "${claudeBin} plugin list --json" after registration, so it may not have registered — ` +
1156
+ `verify with "${claudeBin} plugin list" (restart Claude Code to apply)`,
1157
+ );
1158
+ } else {
1159
+ console.log(
1160
+ `Installed — verify with "claude plugin list" / "claude plugin details ${plugin}" (restart Claude Code to apply).`,
1161
+ );
1162
+ }
1060
1163
  return summary;
1061
1164
  }
@@ -59,8 +59,12 @@
59
59
  // pi's own user settings (settings.json, keybindings.json) are NOT written by
60
60
  // the installer: they are pi's config, not the package's, and `pi install` is
61
61
  // the command that owns them. They are READ (never written) to decide
62
- // install-vs-no-op and to find stale raw-repo sources; the committed pi/*.json
63
- // templates remain reference copies.
62
+ // install-vs-no-op, to find stale raw-repo sources, and to report two states
63
+ // the installer cannot resolve on its own: a configured source whose path no
64
+ // longer exists (it cannot be identified, so only `pi remove <resolved path>`
65
+ // can drop it), and a package the user also registers standalone that the
66
+ // staged package bundles (both copies load, and their tools collide). The
67
+ // committed pi/*.json templates remain reference copies.
64
68
  //
65
69
  // LEGACY SWEEP (sweepLegacyPiUserDirs): the pre-staged-model installer rendered
66
70
  // agents and skills into pi's own user directory (~/.pi/agent). Those copies are
@@ -198,13 +202,14 @@ const DEFAULT_WARN = (/** @type {string} */ msg) => console.warn(`[pi-installer]
198
202
 
199
203
  // Artifacts the pre-staged-model installer wrote into pi's own user directory
200
204
  // that the package root can no longer name on its own: the orphaned
201
- // `pipeline-worker` agent (an older render with no canonical source today) and
202
- // the `migration` skill (authored on a branch this epic does not carry).
205
+ // `pipeline-worker` agent (an older render with no canonical source today).
203
206
  // Everything else is attributed from the package root — see
204
207
  // sweepLegacyPiUserDirs. Drop an entry once it is either shipped again (the
205
- // root-derived rules then cover it) or gone for good.
208
+ // root-derived rules then cover it) or gone for good; `productivity/migration`
209
+ // was dropped that way once the skill shipped again.
206
210
  const LEGACY_ORPHAN_AGENTS = ["pipeline-worker"];
207
- const LEGACY_ORPHAN_SKILLS = ["productivity/migration"];
211
+ /** @type {string[]} Skills an older render shipped under a name the root no longer has. */
212
+ const LEGACY_ORPHAN_SKILLS = [];
208
213
 
209
214
  // A path record some install flows leave in pi's user directory, listing the
210
215
  // copies they wrote there. It describes the same tree the sweep removes, so it
@@ -582,6 +587,11 @@ async function readDependencyNames(dir) {
582
587
  * `git pull && agent-cortex install pi` is a true no-op)
583
588
  * a source resolves to a raw agent-cortex checkout (this package's own name)
584
589
  * → `pi remove` it first, so exactly one agent-cortex source stays
590
+ * a local source whose path does not exist → reported with the command that
591
+ * drops it (see reportDanglingSources)
592
+ * a package the staged package bundles is also configured standalone →
593
+ * reported with the copy that wins and the command that resolves it (see
594
+ * reportBundledCollisions)
585
595
  *
586
596
  * A superseded source that survives its removal fails the run: pi would load
587
597
  * both sources side by side, so reporting success would leave the very state
@@ -615,7 +625,19 @@ export async function registerPi({
615
625
  const stagedPath = resolve(output);
616
626
  const rawPackageName = (await readJsonObject(join(root, "package.json")))?.name;
617
627
  const sources = await readConfiguredPackages(settingsPath);
618
- const { registered, stale } = await classifySources({ sources, settingsPath, stagedPath, rawPackageName });
628
+ const { registered, stale, dangling } = await classifySources({ sources, settingsPath, stagedPath, rawPackageName });
629
+
630
+ // Both reports describe state this installer must not change on its own — a
631
+ // source it cannot identify, and a source the user configured deliberately —
632
+ // so they are reported on every path, including the no-op and dry-run ones.
633
+ reportDanglingSources(dangling, settingsPath, warn);
634
+ await reportBundledCollisions({
635
+ sources,
636
+ settingsPath,
637
+ stagedPath,
638
+ bundledPackages: await readBundledPackages(stagedPath),
639
+ warn,
640
+ });
619
641
 
620
642
  // The same sequence a human would run by hand, in the order the installer
621
643
  // runs it: drop the superseded sources, then register the staged package.
@@ -628,7 +650,7 @@ export async function registerPi({
628
650
  console.log(`Registering the staged pi package with pi (via ${piBin})…`);
629
651
 
630
652
  if (dryRun) {
631
- console.log(` would read: ${settingsPath} (install-vs-no-op and stale raw-repo sources)`);
653
+ console.log(` would read: ${settingsPath} (install-vs-no-op, stale raw-repo sources, unusable sources)`);
632
654
  for (const { path } of stale) console.log(` would run: ${piBin} remove ${path}`);
633
655
  if (registered) console.log(` already configured: ${stagedPath}`);
634
656
  else console.log(` would run: ${piBin} install ${stagedPath}`);
@@ -739,17 +761,23 @@ function resolveSourcePath(source, settingsDir) {
739
761
 
740
762
  /**
741
763
  * Split the configured local sources into "the staged package is already
742
- * registered" and "a raw agent-cortex checkout superseded by it". A source
743
- * whose directory is gone is left alone: a missing path cannot be told apart
744
- * from a temporarily absent one, and removing it could drop a package the user
745
- * still wants.
764
+ * registered", "a raw agent-cortex checkout superseded by it", and "a source
765
+ * whose path is not there".
766
+ *
767
+ * The third group is reported, never acted on: a missing path cannot be told
768
+ * apart from a temporarily absent one, and removing it could drop a package the
769
+ * user still wants. It also cannot be identified by name — reading the source
770
+ * package.json to recognise a raw agent-cortex checkout fails on a path that is
771
+ * not there — which is how such an entry survived a whole install unnoticed.
746
772
  * @param {{ sources: string[], settingsPath: string, stagedPath: string, rawPackageName: string | undefined }} options
747
- * @returns {Promise<{ registered: boolean, stale: { path: string }[] }>}
773
+ * @returns {Promise<{ registered: boolean, stale: { path: string }[], dangling: { source: string, path: string }[] }>}
748
774
  */
749
775
  async function classifySources({ sources, settingsPath, stagedPath, rawPackageName }) {
750
776
  const settingsDir = dirname(settingsPath);
751
777
  /** @type {{ path: string }[]} */
752
778
  const stale = [];
779
+ /** @type {{ source: string, path: string }[]} */
780
+ const dangling = [];
753
781
  let registered = false;
754
782
  for (const source of sources) {
755
783
  if (!isLocalSource(source)) continue;
@@ -758,10 +786,137 @@ async function classifySources({ sources, settingsPath, stagedPath, rawPackageNa
758
786
  registered = true;
759
787
  continue;
760
788
  }
789
+ // A local source may also be a single extension file, so absence means
790
+ // neither a directory nor a file is there.
791
+ if (!(await isDirectory(path)) && !(await isFile(path))) {
792
+ dangling.push({ source, path });
793
+ continue;
794
+ }
761
795
  if (rawPackageName === undefined) continue;
762
796
  if ((await readJsonObject(join(path, "package.json")))?.name === rawPackageName) stale.push({ path });
763
797
  }
764
- return { registered, stale };
798
+ return { registered, stale, dangling };
799
+ }
800
+
801
+ /**
802
+ * Report a configured local source whose path does not exist, with the command
803
+ * that drops it.
804
+ *
805
+ * The source is named twice — as configured and as its resolved path — because
806
+ * pi matches a local source by its resolved path: a relative `pi remove`
807
+ * argument resolves against the CURRENT directory, not against the directory
808
+ * holding settings.json (pi 1.x, probed against 1.4.0), so the obvious
809
+ * `pi remove <as configured>` misses from anywhere else and leaves the entry
810
+ * stuck.
811
+ * @param {{ source: string, path: string }[]} dangling
812
+ * @param {string} settingsPath
813
+ * @param {Warn} warn
814
+ */
815
+ function reportDanglingSources(dangling, settingsPath, warn) {
816
+ for (const { source, path } of dangling) {
817
+ warn(
818
+ `pi package source "${source}" cannot be identified: ${path} does not exist. ` +
819
+ `pi loads nothing from it and still lists it, so everything it was registered for is missing. ` +
820
+ `It is left configured in ${settingsPath} — a missing path may be temporarily absent. ` +
821
+ `Drop it with:\n pi remove ${path}\n` +
822
+ `(pass the resolved path: pi 1.x resolves a relative remove argument against the current directory, not ${settingsPath})`,
823
+ );
824
+ }
825
+ }
826
+
827
+ /**
828
+ * Names of the third-party pi packages the staged package ships its own copy
829
+ * of, read from its generated manifest.
830
+ *
831
+ * Bundling stamps one `node_modules/<name>/…` resource path per package that
832
+ * ships pi resources (see resolvePiPackages), so the manifest names exactly the
833
+ * set that can load a second time beside a standalone registration. A package
834
+ * without a `pi` manifest key is only a module and cannot collide.
835
+ * @param {string} stagedPath
836
+ * @returns {Promise<string[]>}
837
+ */
838
+ async function readBundledPackages(stagedPath) {
839
+ const manifest = await readJsonObject(join(stagedPath, "package.json"));
840
+ if (manifest === null) return [];
841
+ /** @type {Set<string>} */
842
+ const names = new Set();
843
+ for (const resource of [...asStringList(manifest.pi?.extensions), ...asStringList(manifest.pi?.skills)]) {
844
+ const match = /^node_modules\/((?:@[^/]+\/)?[^/]+)\//.exec(resource);
845
+ if (match !== null) names.add(match[1]);
846
+ }
847
+ return [...names];
848
+ }
849
+
850
+ /**
851
+ * Report every pi package the staged package bundles that the user also
852
+ * registers standalone.
853
+ *
854
+ * Staging copies each third-party pi package under the staged node_modules, so
855
+ * a standalone registration of the same package loads a second copy: pi keeps
856
+ * both — precedence follows load order on pi 1.x — and reports the overlap as
857
+ * tool-name, shortcut, and skill conflicts at startup, long after the install
858
+ * said it succeeded. The installer cannot drop the standalone entry (it is
859
+ * the user's configuration) and must not drop the bundle, so it says which
860
+ * copy wins and which command resolves it.
861
+ * @param {{ sources: string[], settingsPath: string, stagedPath: string,
862
+ * bundledPackages: string[], warn: Warn }} options
863
+ * @returns {Promise<void>}
864
+ */
865
+ async function reportBundledCollisions({ sources, settingsPath, stagedPath, bundledPackages, warn }) {
866
+ if (bundledPackages.length === 0) return;
867
+ const settingsDir = dirname(settingsPath);
868
+ const bundled = new Set(bundledPackages);
869
+ // pi 1.x loads configured sources in order, so the first one listed wins a
870
+ // shared tool name (probed against pi 1.4.0 — pi's behaviour, not a contract,
871
+ // so the message dates the claim); `-1` means the staged package is not
872
+ // configured at all, and `pi install` appends, so a standalone entry stays
873
+ // listed first.
874
+ const stagedIndex = sources.findIndex((source) => isLocalSource(source) && resolveSourcePath(source, settingsDir) === stagedPath);
875
+ for (const [index, source] of sources.entries()) {
876
+ const local = isLocalSource(source);
877
+ const path = local ? resolveSourcePath(source, settingsDir) : undefined;
878
+ if (path === stagedPath) continue;
879
+ const name = await configuredPackageName(source, settingsDir);
880
+ if (name === undefined || !bundled.has(name)) continue;
881
+
882
+ const bundledDir = join(stagedPath, "node_modules", name);
883
+ const standalone = path ?? source;
884
+ const winner =
885
+ stagedIndex === -1 || index < stagedIndex
886
+ ? `${standalone} takes precedence because it is listed first in ${settingsPath}`
887
+ : `the bundled copy at ${bundledDir} takes precedence because ${settingsPath} lists the staged package first`;
888
+ warn(
889
+ `pi loads "${name}" twice: standalone from ${standalone}, and bundled at ${bundledDir}. ` +
890
+ `The copies collide on tool names, extension shortcuts, and skills; on pi 1.x ${winner}. ` +
891
+ `The staged package always ships its own copy, so drop the standalone one with:\n` +
892
+ ` pi remove ${standalone}`,
893
+ );
894
+ }
895
+ }
896
+
897
+ /**
898
+ * The package name a configured source registers, or `undefined` when the
899
+ * installer cannot name it: an `npm:` spec by its name, a local source by the
900
+ * `name` in its package.json.
901
+ * @param {string} source
902
+ * @param {string} settingsDir
903
+ * @returns {Promise<string | undefined>}
904
+ */
905
+ async function configuredPackageName(source, settingsDir) {
906
+ if (source.trim().startsWith("npm:")) return npmPackageName(source);
907
+ if (!isLocalSource(source)) return undefined;
908
+ return (await readJsonObject(join(resolveSourcePath(source, settingsDir), "package.json")))?.name;
909
+ }
910
+
911
+ /**
912
+ * `npm:@scope/name@^1.2.3` → `@scope/name`.
913
+ * @param {string} source
914
+ * @returns {string}
915
+ */
916
+ function npmPackageName(source) {
917
+ const spec = source.trim().slice("npm:".length).trim();
918
+ const at = spec.indexOf("@", spec.startsWith("@") ? 1 : 0);
919
+ return at === -1 ? spec : spec.slice(0, at);
765
920
  }
766
921
 
767
922
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -55,7 +55,7 @@ description: "Generate multiple radically different interface designs for a modu
55
55
  4. Trade-offs of this approach
56
56
  ```
57
57
 
58
- Collect each agent's output with {{TOOL:read_agent}} when it finishes.
58
+ Collect each agent's output as it finishes.
59
59
 
60
60
  3. **Present designs.** Show each design sequentially — signature, usage examples, what it hides — so the user absorbs each approach before any comparison happens.
61
61
 
@@ -16,21 +16,26 @@ Log stage start, security scan result, review outcome, and stage complete (see P
16
16
 
17
17
  ## Instructions
18
18
  **First**, invoke the `review-security` skill and run the scan against the changes. If the
19
- verdict is FAIL, set `REVIEW_OUTCOME: CHANGES_REQUESTED` immediately and list each finding
20
- as a required change — do not proceed with the quality review.
19
+ verdict is FAIL, set `OUTCOME: BLOCKED` immediately and list each finding under
20
+ `BLOCKING_ISSUES` — do not proceed with the quality review.
21
21
 
22
22
  **Then**, review the implementation for correctness, quality, and alignment with the task
23
23
  description and project conventions. Only flag genuine correctness issues or clear deviations
24
24
  from stated requirements — not stylistic preferences.
25
25
 
26
- End your response with a ---REPORT--- block:
26
+ End your response with a ---REPORT--- block. The `run-pipeline-stage` skill's
27
+ `prompts/stage-runner.md` holds the authoritative REPORT contract — if this block and that
28
+ prompt differ, follow the prompt and correct this template.
27
29
  ---REPORT---
28
30
  BEAD_ID: <id>
29
- STAGE_COMPLETED: reviewing
31
+ STAGE_COMPLETED: review
30
32
  SUMMARY: <2–3 sentence summary of what you reviewed>
31
33
  FILES_CHANGED: none
32
- REVIEW_OUTCOME: <APPROVED|CHANGES_REQUESTED>
33
- CHANGES_REQUESTED: ← include only if REVIEW_OUTCOME is CHANGES_REQUESTED
34
- 1. <required change>
35
- 2. <required change>
34
+ COMMIT: <sha, or "none" if this stage added no commit>
35
+ PUSHED: <yes|no>
36
+ PR: none
37
+ OUTCOME: <SUCCESS|BLOCKED>
38
+ BLOCKING_ISSUES: ← include only if OUTCOME is BLOCKED; one numbered line per finding
39
+ 1. <path:line — what is wrong and what to fix>
40
+ 2. <path:line — what is wrong and what to fix>
36
41
  ---
@@ -54,7 +54,10 @@ bash "$(find ~ -path '*/agent-cortex/skills/review-security/scripts/install-gitl
54
54
 
55
55
  If the verdict is FAIL, report each finding as a required change. Do not proceed until secrets are removed.
56
56
 
57
- When running within a ralph review pipeline, the outcome is communicated through the `---REPORT---` block that ralph parses — set `REVIEW_OUTCOME: CHANGES_REQUESTED` in that block and list each finding under `CHANGES_REQUESTED` (see the review template at `skills/create-task/templates/review.md`).
57
+ When running within a ralph review pipeline, the outcome is communicated through the
58
+ `---REPORT---` block that ralph parses — set `OUTCOME: BLOCKED` in that block and list each
59
+ finding under `BLOCKING_ISSUES` with its file and line (see the review template in the
60
+ `create-task` skill's `templates/review.md`).
58
61
 
59
62
  ## Red Flags
60
63
 
@@ -41,7 +41,7 @@ State model: task state lives in beads (ready/in_progress via `bd`); the only lo
41
41
  - **Bead that already has its children** (a decomposed feature, an expanded task parent): skip it — it is not dispatchable itself, and its `stage:*` chore is dispatched on its own.
42
42
  `execution:hitl` beads are not in this query and are not ralph's to touch: record them for the Pending Human Action summary. That includes every feature still at its default `execution:hitl` label.
43
43
  7. Dispatch each unit from its parent's worktree, up to 5 in flight: a stage chore via the event loop, a childless feature via a decomposition worker (_Feature decomposition_), a chore-less task via an expansion worker (_Expansion guard_). Create branches and worktrees first (REFERENCE.md, _Feature Branches and Worktrees_).
44
- 8. Hand over to your harness's completion-detection mechanism: PI keeps a {{TOOL:wait_for_agents}} call outstanding over every in-flight agent ID, and Claude re-invokes you as each worker completes. If no **agent work** was dispatched — `state.json`'s `inflight` holds no entries, so no stage, decomposition or expansion worker is running — and `skills/workflow/ralph/scripts/pending-gates.mjs` reports a non-empty `pendingHumanAction` (an unblocked `lifecycle:feature-pr` gate, or an epic tagged `awaiting-epic-pr-merge`), go straight to HITL Pause (REFERENCE.md). A decomposition- or expansion-only init is in flight, not idle: keep the completion mechanism running and continue at the event loop.
44
+ 8. Hand over to your harness's completion-detection mechanism: PI keeps a blocking wait outstanding over every in-flight agent ID, and Claude re-invokes you as each worker completes. If no **agent work** was dispatched — `state.json`'s `inflight` holds no entries, so no stage, decomposition or expansion worker is running — and `skills/workflow/ralph/scripts/pending-gates.mjs` reports a non-empty `pendingHumanAction` (an unblocked `lifecycle:feature-pr` gate, or an epic tagged `awaiting-epic-pr-merge`), go straight to HITL Pause (REFERENCE.md). A decomposition- or expansion-only init is in flight, not idle: keep the completion mechanism running and continue at the event loop.
45
45
 
46
46
  ### 2. Expansion guard (stage chores are pre-created)
47
47
 
@@ -60,7 +60,7 @@ A feature carries `execution:hitl` by default, so this only runs when a human ha
60
60
  On each completion notification (a background agent finished):
61
61
 
62
62
  1. Poll that parent's log for new lines — `tail -n +<nextLine> .agent-cortex/ralph/ralph-<parent-id>.log` (logs are keyed by parent bead ID, so every stage of a feature shares one file) — and post only new key events (stage transitions, test/lint results), never empty updates.
63
- 2. Read the completed agent's output with {{TOOL:read_agent}} and parse its `---REPORT---` block for `BEAD_ID`, `STAGE_COMPLETED`, and the outcome. A **decomposition** (`kind: "decompose"`) or **expansion** (`kind: "expand"`) worker has no REPORT and no leaf bead to confirm. An **expansion worker** owns no bead — confirm the parent now has `stage:*` chores (`bd children <parent-id> --json | grep -c '"stage:'`) and skip to step 5 to dispatch its ready `stage:code` chore. A **decomposition worker** — confirm the feature now has children (`bd children <feature-id> --json` returns a non-empty list), record that nothing is dispatchable for it until a human closes the planning bead, and skip to step 5; never re-decompose. If either confirmation comes back empty, the worker failed: surface its output, record the parent for the shutdown summary, and do not re-dispatch.
63
+ 2. Read the completed agent's output and parse its `---REPORT---` block for `BEAD_ID`, `STAGE_COMPLETED`, and the outcome. A **decomposition** (`kind: "decompose"`) or **expansion** (`kind: "expand"`) worker has no REPORT and no leaf bead to confirm. An **expansion worker** owns no bead — confirm the parent now has `stage:*` chores (`bd children <parent-id> --json | grep -c '"stage:'`) and skip to step 5 to dispatch its ready `stage:code` chore. A **decomposition worker** — confirm the feature now has children (`bd children <feature-id> --json` returns a non-empty list), record that nothing is dispatchable for it until a human closes the planning bead, and skip to step 5; never re-decompose. If either confirmation comes back empty, the worker failed: surface its output, record the parent for the shutdown summary, and do not re-dispatch.
64
64
  3. Confirm the stage worker closed its chore bead: `bd show <bead-id>` should read `closed` with the outcome in its close reason. If it is still open the worker died mid-stage — read its notes and re-dispatch; never close it yourself.
65
65
  4. Route the parent per the dispatch table — nothing is created here: the pre-created chain already encodes the order, and the worker owns any remediation it needs.
66
66