continuous-improvement 3.23.0 → 3.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/CHANGELOG.md +37 -0
- package/QUICKSTART.md +19 -20
- package/README.md +71 -18
- package/SKILL.md +4 -0
- package/bin/check-invariant-count.mjs +144 -0
- package/bin/check-routing-targets.mjs +74 -4
- package/bin/check-test-count.mjs +135 -0
- package/bin/companion-preference-status.mjs +2 -5
- package/bin/generate-plugin-manifests.mjs +21 -2
- package/bin/harvest-friction.mjs +10 -8
- package/bin/install.mjs +4 -14
- package/bin/mcp-server.mjs +4 -9
- package/bin/observe.mjs +3 -3
- package/bin/reconcile-instinct-hashes.mjs +226 -0
- package/bin/refresh-third-party.mjs +180 -10
- package/commands/discipline.md +5 -2
- package/commands/reconcile.md +1 -1
- package/commands/superpowers.md +2 -2
- package/commands/verify-install.md +8 -3
- package/hooks/companion-preference.mjs +3 -10
- package/hooks/config-guard.mjs +94 -0
- package/hooks/gateguard.mjs +39 -39
- package/hooks/goal-drift-stop.mjs +2 -2
- package/hooks/query-cost-nudge.mjs +2 -2
- package/hooks/recall-briefing.mjs +2 -2
- package/hooks/route-prompt.mjs +2 -5
- package/hooks/session.mjs +2 -2
- package/hooks/workflow-distill.mjs +2 -2
- package/lib/config-guard-gate.mjs +243 -0
- package/lib/destructive-bash.mjs +216 -0
- package/lib/gateguard-state.mjs +5 -1
- package/lib/plugin-metadata.mjs +12 -1
- package/lib/skill-catalog.mjs +169 -0
- package/llms.txt +12 -1
- package/package.json +5 -3
- package/plugins/beginner.json +1 -1
- package/plugins/continuous-improvement/.claude-plugin/marketplace.json +2 -2
- package/plugins/continuous-improvement/.claude-plugin/plugin.json +2 -2
- package/plugins/continuous-improvement/README.md +1 -2
- package/plugins/continuous-improvement/bin/mcp-server.mjs +4 -9
- package/plugins/continuous-improvement/bin/observe.mjs +3 -3
- package/plugins/continuous-improvement/commands/discipline.md +5 -2
- package/plugins/continuous-improvement/commands/reconcile.md +1 -1
- package/plugins/continuous-improvement/commands/superpowers.md +2 -2
- package/plugins/continuous-improvement/commands/verify-install.md +8 -3
- package/plugins/continuous-improvement/hooks/companion-preference.mjs +3 -10
- package/plugins/continuous-improvement/hooks/config-guard.mjs +94 -0
- package/plugins/continuous-improvement/hooks/gateguard.mjs +39 -39
- package/plugins/continuous-improvement/hooks/goal-drift-stop.mjs +2 -2
- package/plugins/continuous-improvement/hooks/hooks.json +10 -0
- package/plugins/continuous-improvement/hooks/query-cost-nudge.mjs +2 -2
- package/plugins/continuous-improvement/hooks/recall-briefing.mjs +2 -2
- package/plugins/continuous-improvement/hooks/route-prompt.mjs +2 -5
- package/plugins/continuous-improvement/hooks/session.mjs +2 -2
- package/plugins/continuous-improvement/hooks/workflow-distill.mjs +2 -2
- package/plugins/continuous-improvement/lib/config-guard-gate.mjs +243 -0
- package/plugins/continuous-improvement/lib/destructive-bash.mjs +216 -0
- package/plugins/continuous-improvement/lib/gateguard-state.mjs +5 -1
- package/plugins/continuous-improvement/lib/plugin-metadata.mjs +12 -1
- package/plugins/continuous-improvement/scripts/route-recommendation.routes.json +2 -2
- package/plugins/continuous-improvement/skills/README.md +0 -1
- package/plugins/continuous-improvement/skills/continuous-improvement/SKILL.md +4 -0
- package/plugins/continuous-improvement/skills/deploy-receipt/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/gateguard/SKILL.md +17 -2
- package/plugins/continuous-improvement/skills/proceed-with-the-recommendation/SKILL.md +2 -2
- package/plugins/continuous-improvement/skills/reconcile/SKILL.md +0 -1
- package/plugins/continuous-improvement/skills/superpowers/SKILL.md +5 -6
- package/plugins/expert.json +1 -1
- package/scripts/route-recommendation.routes.json +2 -2
- package/skills/README.md +1 -2
- package/skills/deploy-receipt.md +1 -1
- package/skills/gateguard.md +17 -2
- package/skills/proceed-with-the-recommendation.md +2 -2
- package/skills/reconcile.md +0 -1
- package/skills/superpowers.md +5 -6
- package/plugins/continuous-improvement/skills/safety-guard/SKILL.md +0 -77
- package/skills/safety-guard.md +0 -77
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Merge instinct buckets that are the same project under different path
|
|
4
|
+
* spellings (C:/ vs c:/ vs C:\). observe/gateguard now hash the canonical
|
|
5
|
+
* root; this CLI copies history into that hash and leaves an alias marker
|
|
6
|
+
* on the old dir. Idempotent. Never deletes a directory.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node bin/reconcile-instinct-hashes.mjs --dry-run
|
|
10
|
+
* node bin/reconcile-instinct-hashes.mjs --apply
|
|
11
|
+
* node bin/reconcile-instinct-hashes.mjs --apply --only 3ef4426c6e15 --only 137f2f54ec70
|
|
12
|
+
*/
|
|
13
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { argv, exit } from "node:process";
|
|
16
|
+
import { canonicalizeProjectRoot, hashProjectRoot, resolveInstinctsRoot } from "../lib/gateguard-state.mjs";
|
|
17
|
+
function countJsonl(path) {
|
|
18
|
+
if (!existsSync(path))
|
|
19
|
+
return 0;
|
|
20
|
+
return readFileSync(path, "utf8").split(/\n/).filter((line) => line.trim() !== "").length;
|
|
21
|
+
}
|
|
22
|
+
function isoNow() {
|
|
23
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
24
|
+
}
|
|
25
|
+
export function discoverGroups(instinctsRoot) {
|
|
26
|
+
const groups = new Map();
|
|
27
|
+
let entries;
|
|
28
|
+
try {
|
|
29
|
+
entries = readdirSync(instinctsRoot);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
for (const name of entries) {
|
|
35
|
+
if (name === "global")
|
|
36
|
+
continue;
|
|
37
|
+
const dir = join(instinctsRoot, name);
|
|
38
|
+
try {
|
|
39
|
+
if (!statSync(dir).isDirectory())
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (existsSync(join(dir, "alias.json")) && !existsSync(join(dir, "observations.jsonl"))) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const projectPath = join(dir, "project.json");
|
|
49
|
+
if (!existsSync(projectPath))
|
|
50
|
+
continue;
|
|
51
|
+
let project;
|
|
52
|
+
try {
|
|
53
|
+
project = JSON.parse(readFileSync(projectPath, "utf8"));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!project.root)
|
|
59
|
+
continue;
|
|
60
|
+
const canonicalRoot = canonicalizeProjectRoot(project.root);
|
|
61
|
+
const canonicalHash = hashProjectRoot(canonicalRoot);
|
|
62
|
+
const bucket = {
|
|
63
|
+
hash: name,
|
|
64
|
+
root: project.root,
|
|
65
|
+
name: project.name ?? name,
|
|
66
|
+
rows: countJsonl(join(dir, "observations.jsonl")),
|
|
67
|
+
};
|
|
68
|
+
const existing = groups.get(canonicalHash);
|
|
69
|
+
if (existing) {
|
|
70
|
+
existing.members.push(bucket);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
groups.set(canonicalHash, {
|
|
74
|
+
canonicalHash,
|
|
75
|
+
canonicalRoot,
|
|
76
|
+
name: project.name ?? name,
|
|
77
|
+
members: [bucket],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return [...groups.values()].filter((group) => group.members.some((member) => member.hash !== group.canonicalHash && member.rows > 0));
|
|
82
|
+
}
|
|
83
|
+
function parseTs(line) {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(line);
|
|
86
|
+
const value = Date.parse(parsed.ts ?? "");
|
|
87
|
+
return Number.isFinite(value) ? value : 0;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function mergeJsonl(sources) {
|
|
94
|
+
const rows = [];
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
for (const file of sources) {
|
|
97
|
+
if (!existsSync(file))
|
|
98
|
+
continue;
|
|
99
|
+
for (const line of readFileSync(file, "utf8").split(/\n/)) {
|
|
100
|
+
const trimmed = line.trim();
|
|
101
|
+
if (!trimmed || seen.has(trimmed))
|
|
102
|
+
continue;
|
|
103
|
+
seen.add(trimmed);
|
|
104
|
+
rows.push(trimmed);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
rows.sort((a, b) => parseTs(a) - parseTs(b) || a.localeCompare(b));
|
|
108
|
+
return rows.length === 0 ? "" : `${rows.join("\n")}\n`;
|
|
109
|
+
}
|
|
110
|
+
export function selectGroups(groups, only) {
|
|
111
|
+
if (only.length === 0)
|
|
112
|
+
return groups;
|
|
113
|
+
const wanted = new Set(only);
|
|
114
|
+
return groups.filter((group) => wanted.has(group.canonicalHash) || group.members.some((member) => wanted.has(member.hash)));
|
|
115
|
+
}
|
|
116
|
+
export function applyGroup(instinctsRoot, group) {
|
|
117
|
+
const destDir = join(instinctsRoot, group.canonicalHash);
|
|
118
|
+
mkdirSync(destDir, { recursive: true });
|
|
119
|
+
const destObs = join(destDir, "observations.jsonl");
|
|
120
|
+
const sources = group.members
|
|
121
|
+
.map((member) => join(instinctsRoot, member.hash, "observations.jsonl"))
|
|
122
|
+
.filter((path) => existsSync(path));
|
|
123
|
+
const merged = mergeJsonl(sources);
|
|
124
|
+
writeFileSync(destObs, merged, "utf8");
|
|
125
|
+
let createdAt = isoNow();
|
|
126
|
+
const destProject = join(destDir, "project.json");
|
|
127
|
+
if (existsSync(destProject)) {
|
|
128
|
+
try {
|
|
129
|
+
const existing = JSON.parse(readFileSync(destProject, "utf8"));
|
|
130
|
+
if (existing.created_at)
|
|
131
|
+
createdAt = existing.created_at;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// rewrite below
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
writeFileSync(destProject, `${JSON.stringify({
|
|
138
|
+
id: group.canonicalHash,
|
|
139
|
+
name: group.name,
|
|
140
|
+
root: group.canonicalRoot,
|
|
141
|
+
created_at: createdAt,
|
|
142
|
+
})}\n`, "utf8");
|
|
143
|
+
const aliases = [];
|
|
144
|
+
for (const member of group.members) {
|
|
145
|
+
if (member.hash === group.canonicalHash)
|
|
146
|
+
continue;
|
|
147
|
+
const srcDir = join(instinctsRoot, member.hash);
|
|
148
|
+
try {
|
|
149
|
+
for (const file of readdirSync(srcDir)) {
|
|
150
|
+
if (!file.endsWith(".yaml"))
|
|
151
|
+
continue;
|
|
152
|
+
const dest = join(destDir, file);
|
|
153
|
+
if (!existsSync(dest))
|
|
154
|
+
copyFileSync(join(srcDir, file), dest);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// missing dir is non-fatal
|
|
159
|
+
}
|
|
160
|
+
const srcObs = join(srcDir, "observations.jsonl");
|
|
161
|
+
if (existsSync(srcObs)) {
|
|
162
|
+
renameSync(srcObs, join(srcDir, `observations.migrated-to-${group.canonicalHash}.jsonl`));
|
|
163
|
+
}
|
|
164
|
+
writeFileSync(join(srcDir, "alias.json"), `${JSON.stringify({
|
|
165
|
+
canonical: group.canonicalHash,
|
|
166
|
+
canonical_root: group.canonicalRoot,
|
|
167
|
+
migrated_at: isoNow(),
|
|
168
|
+
rows: member.rows,
|
|
169
|
+
})}\n`, "utf8");
|
|
170
|
+
aliases.push(member.hash);
|
|
171
|
+
}
|
|
172
|
+
const copiedRows = merged === "" ? 0 : merged.trim().split("\n").length;
|
|
173
|
+
return { copiedRows, aliases };
|
|
174
|
+
}
|
|
175
|
+
function printPlan(groups) {
|
|
176
|
+
if (groups.length === 0) {
|
|
177
|
+
console.log("No alias observation buckets to merge.");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
console.log(`Alias groups: ${groups.length}`);
|
|
181
|
+
for (const group of groups) {
|
|
182
|
+
console.log(` ${group.name} → ${group.canonicalHash} (${group.canonicalRoot})`);
|
|
183
|
+
for (const member of group.members) {
|
|
184
|
+
const mark = member.hash === group.canonicalHash ? "canonical" : "alias";
|
|
185
|
+
console.log(` ${member.hash} ${member.rows} rows ${mark} root=${member.root}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function parseOnly(args) {
|
|
190
|
+
const only = [];
|
|
191
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
192
|
+
if (args[i] === "--only" && args[i + 1]) {
|
|
193
|
+
only.push(args[i + 1]);
|
|
194
|
+
i += 1;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return only;
|
|
198
|
+
}
|
|
199
|
+
function main() {
|
|
200
|
+
const args = argv.slice(2);
|
|
201
|
+
const apply = args.includes("--apply");
|
|
202
|
+
const dryRun = args.includes("--dry-run") || !apply;
|
|
203
|
+
const instinctsRoot = resolveInstinctsRoot();
|
|
204
|
+
const groups = selectGroups(discoverGroups(instinctsRoot), parseOnly(args));
|
|
205
|
+
printPlan(groups);
|
|
206
|
+
if (dryRun) {
|
|
207
|
+
if (groups.length > 0)
|
|
208
|
+
console.log("\nRe-run with --apply to merge.");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
for (const group of groups) {
|
|
212
|
+
const result = applyGroup(instinctsRoot, group);
|
|
213
|
+
console.log(`merged ${result.copiedRows} rows into ${group.canonicalHash}; aliases ${result.aliases.join(",") || "(none)"}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const invokedDirectly = argv[1]?.endsWith("reconcile-instinct-hashes.mjs");
|
|
217
|
+
if (invokedDirectly) {
|
|
218
|
+
try {
|
|
219
|
+
main();
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
223
|
+
console.error(message);
|
|
224
|
+
exit(1);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
* wipe the local path, recreate it, copy the selective surface
|
|
23
23
|
* verbatim, strip every CLAUDE.md inside the snapshot, print a diff
|
|
24
24
|
* stat. Aborts if the local snapshot path has uncommitted changes
|
|
25
|
-
* unless --force is passed
|
|
25
|
+
* unless --force is passed, and aborts BEFORE the wipe if upstream
|
|
26
|
+
* removed a skill this repo still routes to, unless --allow-stale-refs
|
|
27
|
+
* is passed. The two flags are separate on purpose: a dirty-tree
|
|
28
|
+
* refresh must not silently disable the stale-reference gate.
|
|
26
29
|
*
|
|
27
30
|
* node bin/refresh-third-party.mjs --all
|
|
28
31
|
* node bin/refresh-third-party.mjs --all --check
|
|
@@ -38,8 +41,9 @@
|
|
|
38
41
|
*/
|
|
39
42
|
import { spawnSync } from "node:child_process";
|
|
40
43
|
import { cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
44
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
41
45
|
import { tmpdir } from "node:os";
|
|
42
|
-
import { dirname, join, resolve } from "node:path";
|
|
46
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
43
47
|
import { argv, exit, stderr, stdout } from "node:process";
|
|
44
48
|
import { fileURLToPath } from "node:url";
|
|
45
49
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
@@ -56,6 +60,7 @@ const SNAPSHOTS = [
|
|
|
56
60
|
manifestHeading: "oh-my-claudecode",
|
|
57
61
|
upstream: "https://github.com/Yeachan-Heo/oh-my-claudecode.git",
|
|
58
62
|
localPath: "third-party/oh-my-claudecode",
|
|
63
|
+
routingPrefix: "oh-my-claudecode",
|
|
59
64
|
selectiveDirs: [
|
|
60
65
|
"agents",
|
|
61
66
|
"skills",
|
|
@@ -94,6 +99,7 @@ const SNAPSHOTS = [
|
|
|
94
99
|
manifestHeading: "obra/superpowers",
|
|
95
100
|
upstream: "https://github.com/obra/superpowers.git",
|
|
96
101
|
localPath: "third-party/superpowers",
|
|
102
|
+
routingPrefix: "superpowers",
|
|
97
103
|
selectiveDirs: ["skills", "hooks", "docs", "assets", ".claude-plugin"],
|
|
98
104
|
selectiveFiles: [
|
|
99
105
|
"LICENSE",
|
|
@@ -116,7 +122,7 @@ function usage() {
|
|
|
116
122
|
"Usage:",
|
|
117
123
|
" node bin/refresh-third-party.mjs --list",
|
|
118
124
|
" node bin/refresh-third-party.mjs <name> --check",
|
|
119
|
-
" node bin/refresh-third-party.mjs <name> [--force]",
|
|
125
|
+
" node bin/refresh-third-party.mjs <name> [--force] [--allow-stale-refs]",
|
|
120
126
|
" node bin/refresh-third-party.mjs --all [--check] [--force]",
|
|
121
127
|
" node bin/refresh-third-party.mjs --help",
|
|
122
128
|
"",
|
|
@@ -198,6 +204,7 @@ function localPathDirty(relPath) {
|
|
|
198
204
|
}
|
|
199
205
|
async function deleteClaudeMdRecursive(root) {
|
|
200
206
|
// Node-only equivalent of `find <root> -name CLAUDE.md -type f -delete`.
|
|
207
|
+
// (helpers for our own in-snapshot annotations are defined below)
|
|
201
208
|
const { readdir } = await import("node:fs/promises");
|
|
202
209
|
const stack = [root];
|
|
203
210
|
let deleted = 0;
|
|
@@ -239,7 +246,130 @@ async function checkOne(snapshot) {
|
|
|
239
246
|
await rm(dir, { recursive: true, force: true });
|
|
240
247
|
}
|
|
241
248
|
}
|
|
242
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Flat-source files and directories that can name a companion skill. Deliberately
|
|
251
|
+
* excludes `third-party/` — scanning the snapshot being replaced would match the
|
|
252
|
+
* very copy we are about to overwrite and report every removal as still-referenced.
|
|
253
|
+
* The `plugins/` tree is excluded too: it is a build mirror of these files.
|
|
254
|
+
*/
|
|
255
|
+
export const REFERENCE_SOURCES = [
|
|
256
|
+
"optional-companions.json",
|
|
257
|
+
"skills",
|
|
258
|
+
"commands",
|
|
259
|
+
"scripts",
|
|
260
|
+
join("src", "hooks"),
|
|
261
|
+
];
|
|
262
|
+
/** Skill directory names inside a snapshot, sorted. Empty if there is no skills/ dir. */
|
|
263
|
+
export function listSkillDirs(snapshotRoot) {
|
|
264
|
+
try {
|
|
265
|
+
return readdirSync(join(snapshotRoot, "skills"), { withFileTypes: true })
|
|
266
|
+
.filter((e) => e.isDirectory())
|
|
267
|
+
.map((e) => e.name)
|
|
268
|
+
.sort();
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** Skills the old snapshot had that the incoming one does not. */
|
|
275
|
+
export function findRemovedSkills(oldSkills, newSkills) {
|
|
276
|
+
const incoming = new Set(newSkills);
|
|
277
|
+
return oldSkills.filter((s) => !incoming.has(s));
|
|
278
|
+
}
|
|
279
|
+
function walkFiles(abs, out) {
|
|
280
|
+
let entries;
|
|
281
|
+
try {
|
|
282
|
+
entries = readdirSync(abs, { withFileTypes: true });
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
for (const e of entries) {
|
|
288
|
+
const full = join(abs, e.name);
|
|
289
|
+
if (e.isDirectory())
|
|
290
|
+
walkFiles(full, out);
|
|
291
|
+
else
|
|
292
|
+
out.push(full);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Removed skills that our own source still routes to, with the files naming them.
|
|
297
|
+
*
|
|
298
|
+
* This is the check that turns a refresh into a loud failure instead of a silent
|
|
299
|
+
* one: when OMC 5.x deleted `ultrawork`, the snapshot became honest while our
|
|
300
|
+
* routing table kept pointing at it, and every gate stayed green. Matching is on
|
|
301
|
+
* the prefixed `<plugin>:<skill>` form with a boundary, so a bare English word
|
|
302
|
+
* ("release", "review") in prose is not a false positive and `omc:ultrawork-plus`
|
|
303
|
+
* does not match `omc:ultrawork`.
|
|
304
|
+
*/
|
|
305
|
+
export function findStaleReferences(repoRoot, routingPrefix, removedSkills) {
|
|
306
|
+
if (removedSkills.length === 0)
|
|
307
|
+
return [];
|
|
308
|
+
const files = [];
|
|
309
|
+
for (const rel of REFERENCE_SOURCES) {
|
|
310
|
+
const abs = join(repoRoot, rel);
|
|
311
|
+
let isDir = false;
|
|
312
|
+
try {
|
|
313
|
+
isDir = statSync(abs).isDirectory();
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (isDir)
|
|
319
|
+
walkFiles(abs, files);
|
|
320
|
+
else
|
|
321
|
+
files.push(abs);
|
|
322
|
+
}
|
|
323
|
+
const stale = [];
|
|
324
|
+
for (const skill of removedSkills) {
|
|
325
|
+
const re = new RegExp(`${escapeRegExp(routingPrefix)}:${escapeRegExp(skill)}(?![A-Za-z0-9_-])`);
|
|
326
|
+
const hits = [];
|
|
327
|
+
for (const file of files) {
|
|
328
|
+
let text;
|
|
329
|
+
try {
|
|
330
|
+
text = readFileSync(file, "utf8");
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (re.test(text))
|
|
336
|
+
hits.push(relative(repoRoot, file));
|
|
337
|
+
}
|
|
338
|
+
if (hits.length > 0)
|
|
339
|
+
stale.push({ skill, files: hits.sort() });
|
|
340
|
+
}
|
|
341
|
+
return stale;
|
|
342
|
+
}
|
|
343
|
+
function escapeRegExp(s) {
|
|
344
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Files inside a snapshot directory that we author, not upstream. The refresh
|
|
348
|
+
* wipes the directory wholesale, so these have to be carried across it by hand.
|
|
349
|
+
* `OUR_NOTES.md` is the drift radar the vendoring contract requires;
|
|
350
|
+
* `.fork-only-skills.txt` is the allowlist the Skills Drift Check subtracts.
|
|
351
|
+
*/
|
|
352
|
+
export const OUR_FILES = ["OUR_NOTES.md", ".fork-only-skills.txt"];
|
|
353
|
+
/** Read whichever of OUR_FILES exist under `localAbs`. Absent files are skipped. */
|
|
354
|
+
export async function readOurFiles(localAbs) {
|
|
355
|
+
const saved = new Map();
|
|
356
|
+
for (const name of OUR_FILES) {
|
|
357
|
+
try {
|
|
358
|
+
saved.set(name, await readFile(join(localAbs, name)));
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// Not every snapshot carries every one of these; absence is normal.
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return saved;
|
|
365
|
+
}
|
|
366
|
+
/** Write the captured files back under `localAbs`. Returns how many were restored. */
|
|
367
|
+
export async function restoreOurFiles(localAbs, saved) {
|
|
368
|
+
for (const [name, body] of saved)
|
|
369
|
+
await writeFile(join(localAbs, name), body);
|
|
370
|
+
return saved.size;
|
|
371
|
+
}
|
|
372
|
+
async function refreshOne(snapshot, { force, allowStaleRefs }) {
|
|
243
373
|
const pinned = await readPinnedSha(snapshot);
|
|
244
374
|
const localAbs = join(REPO_ROOT, snapshot.localPath);
|
|
245
375
|
if (!force && (await pathExists(localAbs))) {
|
|
@@ -254,6 +384,31 @@ async function refreshOne(snapshot, { force }) {
|
|
|
254
384
|
throw new Error(`[${snapshot.name}] upstream HEAD ${headSha} != pinned ${pinned}; ` +
|
|
255
385
|
`bump the pinned SHA in third-party/MANIFEST.md first`);
|
|
256
386
|
}
|
|
387
|
+
// Refuse to land a snapshot that would make our own routing table lie.
|
|
388
|
+
// When OMC 5.x deleted `ultrawork`, refreshing made the snapshot honest while
|
|
389
|
+
// optional-companions.json, two skills/superpowers.md tables and the
|
|
390
|
+
// companion-preference OVERRIDES map kept naming it — and every gate stayed
|
|
391
|
+
// green, because nothing compared the two. Checked BEFORE the wipe, so a
|
|
392
|
+
// failure leaves the snapshot untouched and the operator retargets first.
|
|
393
|
+
const removedSkills = findRemovedSkills(listSkillDirs(localAbs), listSkillDirs(dir));
|
|
394
|
+
const staleRefs = findStaleReferences(REPO_ROOT, snapshot.routingPrefix, removedSkills);
|
|
395
|
+
if (staleRefs.length > 0) {
|
|
396
|
+
const detail = staleRefs
|
|
397
|
+
.map((r) => ` ${snapshot.routingPrefix}:${r.skill}\n ${r.files.join("\n ")}`)
|
|
398
|
+
.join("\n");
|
|
399
|
+
const message = `[${snapshot.name}] upstream removed ${staleRefs.length} skill(s) this repo still routes to:\n${detail}\n` +
|
|
400
|
+
` Retarget or drop these references first, then re-run the refresh. ` +
|
|
401
|
+
`Pass --allow-stale-refs to land the snapshot anyway and fix them afterwards.`;
|
|
402
|
+
if (!allowStaleRefs)
|
|
403
|
+
throw new Error(message);
|
|
404
|
+
err(` WARNING: ${message}`);
|
|
405
|
+
}
|
|
406
|
+
// Our own annotations live inside the snapshot directory, so the wipe below
|
|
407
|
+
// destroys them along with the upstream copy. That silently deleted
|
|
408
|
+
// OUR_NOTES.md on both the superpowers (#304) and oh-my-claudecode (#308)
|
|
409
|
+
// refreshes — the drift radar, removed by the tool whose drift it records.
|
|
410
|
+
// Capture before the wipe, put back after the copy.
|
|
411
|
+
const ourFiles = await readOurFiles(localAbs);
|
|
257
412
|
// Wipe + recreate destination.
|
|
258
413
|
await rm(localAbs, { recursive: true, force: true });
|
|
259
414
|
await mkdir(localAbs, { recursive: true });
|
|
@@ -279,6 +434,10 @@ async function refreshOne(snapshot, { force }) {
|
|
|
279
434
|
}
|
|
280
435
|
// Strip every CLAUDE.md (auto-loads as session context).
|
|
281
436
|
const stripped = await deleteClaudeMdRecursive(localAbs);
|
|
437
|
+
// Put our annotations back. After the CLAUDE.md strip, so a snapshot that
|
|
438
|
+
// ever carries an OUR_* named CLAUDE.md is not re-deleted; before the diff
|
|
439
|
+
// stat, so the printed diff reflects what actually landed on disk.
|
|
440
|
+
const preserved = await restoreOurFiles(localAbs, ourFiles);
|
|
282
441
|
// Defense in depth: forcibly delete excludePostCopy paths from the
|
|
283
442
|
// local snapshot, regardless of whether they were copied. Guards
|
|
284
443
|
// against silent regressions if selectiveDirs is later edited to
|
|
@@ -322,7 +481,7 @@ async function refreshOne(snapshot, { force }) {
|
|
|
322
481
|
log(` upstream : ${snapshot.upstream}`);
|
|
323
482
|
log(` pinned SHA : ${pinned}`);
|
|
324
483
|
log(` upstream HEAD: ${headSha}`);
|
|
325
|
-
log(` status : updated (${copiedDirs} dirs, ${copiedFiles} files, ${stripped} CLAUDE.md stripped, ${excluded} excluded paths removed, ${patchedKeys} json keys patched)`);
|
|
484
|
+
log(` status : updated (${copiedDirs} dirs, ${copiedFiles} files, ${stripped} CLAUDE.md stripped, ${preserved} of ours preserved, ${excluded} excluded paths removed, ${patchedKeys} json keys patched)`);
|
|
326
485
|
if (String(diffStat.stdout).trim()) {
|
|
327
486
|
log(" diff --stat :");
|
|
328
487
|
for (const line of String(diffStat.stdout).trimEnd().split(/\r?\n/)) {
|
|
@@ -368,6 +527,10 @@ async function main() {
|
|
|
368
527
|
exit(0);
|
|
369
528
|
}
|
|
370
529
|
const force = args.includes("--force");
|
|
530
|
+
// Deliberately NOT --force: that means "my tree is dirty, proceed", and a
|
|
531
|
+
// routine dirty-tree refresh must not silently switch off the stale-reference
|
|
532
|
+
// gate as a side effect.
|
|
533
|
+
const allowStaleRefs = args.includes("--allow-stale-refs");
|
|
371
534
|
const check = args.includes("--check");
|
|
372
535
|
const all = args.includes("--all");
|
|
373
536
|
const positional = args.filter((a) => !a.startsWith("--"));
|
|
@@ -397,7 +560,7 @@ async function main() {
|
|
|
397
560
|
allUpToDate = false;
|
|
398
561
|
}
|
|
399
562
|
else {
|
|
400
|
-
await refreshOne(snap, { force });
|
|
563
|
+
await refreshOne(snap, { force, allowStaleRefs });
|
|
401
564
|
}
|
|
402
565
|
}
|
|
403
566
|
catch (e) {
|
|
@@ -412,7 +575,14 @@ async function main() {
|
|
|
412
575
|
exit(1);
|
|
413
576
|
exit(0);
|
|
414
577
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
578
|
+
// Only run the CLI when invoked as one. Without this, importing the module to
|
|
579
|
+
// test its helpers would start a refresh. Same shape as the other bin/ scripts:
|
|
580
|
+
// the endsWith fallback covers Windows, where argv[1] is a backslash path and
|
|
581
|
+
// never equals the forward-slash file:// URL.
|
|
582
|
+
const invokedDirectly = argv[1] !== undefined && import.meta.url.endsWith(argv[1].replace(/\\/g, "/"));
|
|
583
|
+
if (invokedDirectly || argv[1]?.endsWith("refresh-third-party.mjs")) {
|
|
584
|
+
main().catch((e) => {
|
|
585
|
+
err(`fatal: ${e.stack || e.message}`);
|
|
586
|
+
exit(1);
|
|
587
|
+
});
|
|
588
|
+
}
|
package/commands/discipline.md
CHANGED
|
@@ -19,6 +19,8 @@ Print this card and check yourself against each law.
|
|
|
19
19
|
| 6 | **Iterate One Change** | Am I changing one thing at a time? | "And also..." |
|
|
20
20
|
| 7 | **Learn From Every Session** | Did I capture this as an instinct? | "Next time I'll..." |
|
|
21
21
|
|
|
22
|
+
Read the seven as three moments around every act. **Before** (Laws 1, 2): set the terms. **During** (Laws 3, 6): watch yourself. **After** (Laws 4, 5, 7): settle the account and carry it forward. Every check is an audit you run on yourself; every red flag is you hoping instead. The sentence this comes from, and its sources: `docs/philosophy.md`.
|
|
23
|
+
|
|
22
24
|
## Operator Stakes
|
|
23
25
|
|
|
24
26
|
The Laws above are the *how*. These five principles are the *why*: code ships from your account, the incident lands on your pager, the bill hits your budget. Each one pairs with the Law that prevents it from going wrong.
|
|
@@ -31,7 +33,7 @@ The Laws above are the *how*. These five principles are the *why*: code ships fr
|
|
|
31
33
|
| 4 | **Problem framing** | Builds the websocket chat the ticket asked for | Finds out users wanted faster support replies, not chat | 1 |
|
|
32
34
|
| 5 | **Constraints management** | Calls the $0.02/image model on every upload | Does the math, adds client-side validation + caching + cheaper triage model | 2 |
|
|
33
35
|
|
|
34
|
-
Code is a liability, not an asset. Speed without these five turns into someone else's incident at 3am — except the someone is you.
|
|
36
|
+
Code is a liability, not an asset. Speed without these five turns into someone else's incident at 3am — except the someone is you. The other half of the why is not fear: the session ends and the context is gone, so the only work that survives is what you verified and wrote down for the one who comes after, whether that is tomorrow's session or the engineer who inherits the repo.
|
|
35
37
|
|
|
36
38
|
## Goal-Driven Execution maps onto the Laws
|
|
37
39
|
|
|
@@ -61,5 +63,6 @@ Before saying "Done", verify ALL:
|
|
|
61
63
|
- [ ] I checked the **actual** result (not assumed)
|
|
62
64
|
- [ ] Build passes
|
|
63
65
|
- [ ] I can explain the change in one sentence
|
|
66
|
+
- [ ] For each item above I checked, not hoped
|
|
64
67
|
|
|
65
|
-
If you're skipping a step, that's the step you need most.
|
|
68
|
+
If you're skipping a step, that's the step you need most. The step you skip is the one you are hoping through.
|
package/commands/reconcile.md
CHANGED
|
@@ -95,7 +95,7 @@ git branch -d <type>/<slug> # delete the merged feature branch (safe
|
|
|
95
95
|
## Pairs with
|
|
96
96
|
|
|
97
97
|
- **`reconcile`** skill — the discipline this command runs.
|
|
98
|
-
- **`gateguard`**
|
|
98
|
+
- **`gateguard`** — the runtime gate for mutating tool calls and destructive shell.
|
|
99
99
|
- **`recall`** — recall whether the same git op failed here before.
|
|
100
100
|
- **`audit`** — the loop that often produces the fix `/reconcile` then ships.
|
|
101
101
|
- **`/ship`** — the TDD-gated single-defect variant; `commit-commands:commit-push-pr` is the external-plugin equivalent of the commit → PR tail.
|
package/commands/superpowers.md
CHANGED
|
@@ -13,11 +13,11 @@ The 7 Laws define *what* discipline must be applied. `/superpowers` decides *whi
|
|
|
13
13
|
|
|
14
14
|
| Source | Where it lives | Examples of what it routes to |
|
|
15
15
|
|---|---|---|
|
|
16
|
-
| `continuous-improvement` (this plugin) | bundled — always present | `gateguard` (Law 1), `tdd-workflow` (Law 3+4), `verification-loop` (Law 4), `wild-risa-balance` (Law 2), `
|
|
16
|
+
| `continuous-improvement` (this plugin) | bundled — always present | `gateguard` (Law 1), `tdd-workflow` (Law 3+4), `verification-loop` (Law 4), `wild-risa-balance` (Law 2), `proceed-with-the-recommendation` (all 7), `ralph` (Law 6), `workspace-surface-audit` (Law 1) |
|
|
17
17
|
| `obra/superpowers` (Jesse Vincent) | vendored at `third-party/superpowers/`, pinned SHA `f2cbfbe` (v5.1.0) | `superpowers:brainstorming`, `:writing-plans`, `:executing-plans`, `:test-driven-development`, `:systematic-debugging`, `:requesting-code-review`, `:receiving-code-review`, `:verification-before-completion`, `:dispatching-parallel-agents`, `:using-git-worktrees`, `:finishing-a-development-branch`, `:subagent-driven-development`, `:writing-skills`, `:using-superpowers` |
|
|
18
18
|
| `addyosmani/agent-skills` | vendored at `third-party/addy-agent-skills/`, pinned SHA `742dca5` (v1.0.0) | `spec-driven-development`, `source-driven-development`, `context-engineering`, `idea-refine`, `incremental-implementation`, `code-review-and-quality`, `code-simplification`, `security-and-hardening`, `debugging-and-error-recovery`, `performance-optimization`, `api-and-interface-design`, `frontend-ui-engineering`, `browser-testing-with-devtools`, `ci-cd-and-automation`, `deprecation-and-migration`, `documentation-and-adrs`, `git-workflow-and-versioning`, `planning-and-task-breakdown`, `shipping-and-launch` |
|
|
19
19
|
| `ruflo-swarm` (ruvnet) | vendored at `third-party/ruflo-swarm/`, pinned SHA `addb5cd` (v0.2.0) | `swarm-init`, `monitor-stream`; `swarm_*` and `agent_*` MCP tools; `/swarm`, `/watch` |
|
|
20
|
-
| `oh-my-claudecode` (Yeachan-Heo) | vendored at `third-party/oh-my-claudecode/`, pinned SHA `
|
|
20
|
+
| `oh-my-claudecode` (Yeachan-Heo) | vendored at `third-party/oh-my-claudecode/`, pinned SHA `4820f56` (v5.3.0) | 37 skills + 19 agents — `release`, `ultragoal`, `launch`, `team`, `trace`, `visual-verdict`, `debug`, `review`, `research`, `deep-interview`, `autopilot`, `autoresearch`, plus a separate `ralph` (overlaps with our `/ralph` — see "Distinct variants" below) |
|
|
21
21
|
|
|
22
22
|
PM coverage (product-management skills) lives **outside** this marketplace. If you need it, install [`phuryn/pm-skills`](https://github.com/phuryn/pm-skills) separately via Claude Code's host marketplace; the dispatcher names it as a routing target without pre-resolving the namespace. See [docs/THIRD_PARTY.md § Routing in /superpowers](../docs/THIRD_PARTY.md#routing-in-superpowers).
|
|
23
23
|
|
|
@@ -29,9 +29,14 @@ with the text `probe`) **without presenting any research first**.
|
|
|
29
29
|
|
|
30
30
|
- If the hook **blocks** the write with a fact-list reason — the runtime layer is
|
|
31
31
|
wired. Record `gateguard: ✓`. Do not retry the write; the block is the pass.
|
|
32
|
-
- If the write **goes through** with no pause — the hook did not load
|
|
33
|
-
`
|
|
34
|
-
|
|
32
|
+
- If the write **goes through** with no pause — either the hook did not load, or
|
|
33
|
+
`CI_GATEGUARD_EXCLUDE` is set to a fragment that matches the probe path (a
|
|
34
|
+
catch-all such as `/` or `.` matches every path and switches the file gate off;
|
|
35
|
+
the hook prints a one-line stderr notice when an exclusion fires). Run
|
|
36
|
+
`echo "$CI_GATEGUARD_EXCLUDE"` first. If it is empty, record
|
|
37
|
+
`gateguard: ✗ (hooks/gateguard.mjs not wired — see README → Troubleshooting install)`;
|
|
38
|
+
if it is set, record `gateguard: ✗ (excluded by CI_GATEGUARD_EXCLUDE=<value>; unset it or
|
|
39
|
+
narrow the fragment)`. Delete the probe file if it was created.
|
|
35
40
|
|
|
36
41
|
## Check 3 — observation capture recording
|
|
37
42
|
|
|
@@ -30,10 +30,10 @@
|
|
|
30
30
|
* suite or a follow-up audit walks the table against this map.
|
|
31
31
|
*/
|
|
32
32
|
import { execFileSync } from "node:child_process";
|
|
33
|
-
import { createHash } from "node:crypto";
|
|
34
33
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
35
34
|
import { homedir } from "node:os";
|
|
36
35
|
import { join } from "node:path";
|
|
36
|
+
import { hashProjectRoot } from "../lib/gateguard-state.mjs";
|
|
37
37
|
const OVERRIDES = {
|
|
38
38
|
"tdd-workflow": {
|
|
39
39
|
companion: "superpowers:test-driven-development",
|
|
@@ -48,11 +48,7 @@ const OVERRIDES = {
|
|
|
48
48
|
plugin: "agent-skills",
|
|
49
49
|
},
|
|
50
50
|
ralph: {
|
|
51
|
-
companion: "oh-my-claudecode:
|
|
52
|
-
plugin: "oh-my-claudecode",
|
|
53
|
-
},
|
|
54
|
-
"learn-eval": {
|
|
55
|
-
companion: "oh-my-claudecode:retrospective",
|
|
51
|
+
companion: "oh-my-claudecode:ultragoal",
|
|
56
52
|
plugin: "oh-my-claudecode",
|
|
57
53
|
},
|
|
58
54
|
};
|
|
@@ -125,10 +121,7 @@ function resolveProjectRoot() {
|
|
|
125
121
|
return "global";
|
|
126
122
|
}
|
|
127
123
|
function telemetryPath(home) {
|
|
128
|
-
const hash =
|
|
129
|
-
.update(resolveProjectRoot())
|
|
130
|
-
.digest("hex")
|
|
131
|
-
.slice(0, 12);
|
|
124
|
+
const hash = hashProjectRoot(resolveProjectRoot());
|
|
132
125
|
return join(home, ".claude", "instincts", hash, "companion-preference.jsonl");
|
|
133
126
|
}
|
|
134
127
|
/**
|