@jaybeeuu/agent-cortex 2.4.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +24 -5
- package/bin/installers/claude.mjs +108 -5
- package/bin/installers/pi.mjs +169 -14
- package/package.json +1 -1
- package/skills/planning/create-task/templates/review.md +13 -8
- package/skills/review/review-security/SKILL.md +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 7a6288b: Align the review REPORT contract on the run-pipeline-stage playbook shape: the review template
|
|
8
|
+
and the `review-security` skill now emit `OUTCOME: SUCCESS|BLOCKED` with `BLOCKING_ISSUES`,
|
|
9
|
+
instead of the unparsed `REVIEW_OUTCOME`/`CHANGES_REQUESTED` keys.
|
|
10
|
+
- 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.
|
|
11
|
+
- 00fbab3: Record the dev loop in `docs/dev-loop.md`: the three-command refresh of the installed runtime
|
|
12
|
+
(`pnpm add -g …@latest`, then `agent-cortex install pi` and `agent-cortex install claude`, with
|
|
13
|
+
`--dry-run` for the plan and a pi restart (or `/reload-plugins` in Claude Code) to apply), the
|
|
14
|
+
installed layout under `~/.agent-cortex`, and how to try uncommitted work without overwriting it —
|
|
15
|
+
stage the checkout with `install pi --output <scratch dir>` and load it with `pi -ne -e
|
|
16
|
+
<scratch dir>`, which writes no settings and needs no undo. The page also states the boundary plainly: the checkout is what is
|
|
17
|
+
developed, `~/.agent-cortex` is what is run, and a path inside `~/src` is never a package source,
|
|
18
|
+
with the two historical violations named as anti-patterns. `README.md` links it from the pi
|
|
19
|
+
staging section and the Claude one-session section, and its Claude plugin section now states the
|
|
20
|
+
correct skill count (34, not 29).
|
|
21
|
+
- dda3a31: `agent-cortex install pi` now reports the two ways a machine ends up in a mixed state, instead of
|
|
22
|
+
leaving them to surface later as missing tools or conflict errors.
|
|
23
|
+
|
|
24
|
+
A configured package source whose path no longer exists is named with the command that drops it.
|
|
25
|
+
Detection is an existence check, so it no longer depends on reading the source `package.json` —
|
|
26
|
+
which is exactly what failed on a pruned worktree path, letting the entry survive a whole install
|
|
27
|
+
and every `pi remove` afterwards. The report gives `pi remove <resolved path>`: pi matches a local
|
|
28
|
+
source by resolved path, and a relative argument resolves against the current directory rather
|
|
29
|
+
than the settings file, so the obvious `pi remove <as configured>` misses. The installer does not
|
|
30
|
+
remove the entry itself — a missing path may be temporarily absent.
|
|
31
|
+
|
|
32
|
+
A pi package the staged package bundles and the user also registers standalone is reported with
|
|
33
|
+
both copies, the copy that takes precedence (load order decides, and on pi 1.x the first-listed
|
|
34
|
+
source wins), and the `pi remove` command that resolves it. The staged package always ships its own copy, so the
|
|
35
|
+
standalone entry is the one to drop; the installer does not remove packages the user configured.
|
|
36
|
+
|
|
37
|
+
`LEGACY_ORPHAN_SKILLS` no longer lists `productivity/migration`: it ships again, so the package root
|
|
38
|
+
attributes its copy, and the entry now follows its own instruction to be dropped once that holds.
|
|
39
|
+
|
|
3
40
|
## 2.4.0
|
|
4
41
|
|
|
5
42
|
### 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`.
|
|
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
|
-
**
|
|
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`
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1058
|
-
|
|
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
|
}
|
package/bin/installers/pi.mjs
CHANGED
|
@@ -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
|
|
63
|
-
//
|
|
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)
|
|
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
|
-
|
|
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
|
|
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"
|
|
743
|
-
* whose
|
|
744
|
-
*
|
|
745
|
-
*
|
|
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
|
@@ -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 `
|
|
20
|
-
|
|
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:
|
|
31
|
+
STAGE_COMPLETED: review
|
|
30
32
|
SUMMARY: <2–3 sentence summary of what you reviewed>
|
|
31
33
|
FILES_CHANGED: none
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
|