continuous-improvement 3.20.4 → 3.22.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 +23 -0
- package/QUICKSTART.md +1 -1
- package/README.md +7 -6
- package/bin/check-reconcile-parity.mjs +168 -0
- package/bin/generate-plugin-manifests.mjs +2 -0
- package/bin/install.mjs +30 -69
- package/bin/reconcile.mjs +259 -0
- package/commands/production-readiness-review.md +5 -4
- package/commands/reconcile.md +30 -6
- package/commands/simplicity-review.md +35 -0
- package/commands/verify-install.md +2 -2
- package/hooks/session.mjs +85 -0
- package/lib/git-state.mjs +411 -0
- package/lib/plugin-metadata.mjs +18 -20
- package/llms.txt +1 -1
- package/package.json +6 -4
- 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/bin/reconcile.mjs +259 -0
- package/plugins/continuous-improvement/commands/production-readiness-review.md +5 -4
- package/plugins/continuous-improvement/commands/reconcile.md +30 -6
- package/plugins/continuous-improvement/commands/simplicity-review.md +35 -0
- package/plugins/continuous-improvement/commands/verify-install.md +2 -2
- package/plugins/continuous-improvement/hooks/hooks.json +15 -16
- package/plugins/continuous-improvement/hooks/session.mjs +85 -0
- package/plugins/continuous-improvement/lib/git-state.mjs +411 -0
- package/plugins/continuous-improvement/lib/plugin-metadata.mjs +18 -20
- package/plugins/continuous-improvement/skills/README.md +1 -0
- package/plugins/continuous-improvement/skills/proceed-with-the-recommendation/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/reconcile/SKILL.md +62 -12
- package/plugins/continuous-improvement/skills/simplicity-review/SKILL.md +80 -0
- package/plugins/expert.json +1 -1
- package/skills/proceed-with-the-recommendation.md +1 -0
- package/skills/reconcile.md +62 -12
- package/skills/simplicity-review.md +80 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-state: pure classifiers for the `reconcile` ground-truth contract.
|
|
3
|
+
*
|
|
4
|
+
* No I/O. `src/bin/reconcile.mts` does the actual `git` spawning and feeds raw
|
|
5
|
+
* stdout plus exit codes in here; keeping the logic pure lets the unit tests
|
|
6
|
+
* cover every boundary (no configured upstream, detached HEAD, linked worktree,
|
|
7
|
+
* failed remote probe, autocrlf phantom drift) without building a repo per case.
|
|
8
|
+
*
|
|
9
|
+
* This module is also the single source of truth for the ground-truth command
|
|
10
|
+
* set: `GROUND_TRUTH_PROBES` is what `bin/reconcile.mjs` runs and what
|
|
11
|
+
* `bin/check-reconcile-parity.mjs` asserts the reconcile skill and command docs
|
|
12
|
+
* still document. Prose that drifts from this array fails `verify:all`.
|
|
13
|
+
*
|
|
14
|
+
* Every classifier fails closed. "We could not tell" is reported as unknown or
|
|
15
|
+
* unverified, never as clean, even, absent, or landed.
|
|
16
|
+
*/
|
|
17
|
+
/** Branch names treated as protected unless the caller overrides the list. */
|
|
18
|
+
export const DEFAULT_PROTECTED_BRANCHES = [
|
|
19
|
+
"main",
|
|
20
|
+
"master",
|
|
21
|
+
"release",
|
|
22
|
+
"production",
|
|
23
|
+
];
|
|
24
|
+
/**
|
|
25
|
+
* The ground-truth command set.
|
|
26
|
+
*
|
|
27
|
+
* Every entry is portable: plain `git` argv with no shell, no `ls`, no
|
|
28
|
+
* coreutils, and no literal `.git/` path — inside a linked worktree `.git` is a
|
|
29
|
+
* *file*, so a `.git/`-relative probe silently reports nothing. Ordering is the
|
|
30
|
+
* order the runner executes and the renderer prints.
|
|
31
|
+
*/
|
|
32
|
+
export const GROUND_TRUTH_PROBES = [
|
|
33
|
+
{
|
|
34
|
+
id: "repo-root",
|
|
35
|
+
args: ["rev-parse", "--show-toplevel"],
|
|
36
|
+
purpose: "Confirm we are inside a work tree and learn its root.",
|
|
37
|
+
tolerateFailure: false,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "head-sha",
|
|
41
|
+
args: ["rev-parse", "HEAD"],
|
|
42
|
+
purpose: "Pin the exact commit every later claim is relative to.",
|
|
43
|
+
tolerateFailure: false,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "head-branch",
|
|
47
|
+
args: ["symbolic-ref", "--quiet", "--short", "HEAD"],
|
|
48
|
+
purpose: "Branch name, or a non-zero exit that proves a detached HEAD.",
|
|
49
|
+
tolerateFailure: true,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "upstream-ref",
|
|
53
|
+
args: ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
|
54
|
+
purpose: "Configured upstream, or a non-zero exit that proves there is none.",
|
|
55
|
+
tolerateFailure: true,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "upstream-counts",
|
|
59
|
+
args: ["rev-list", "--left-right", "--count", "@{u}...HEAD"],
|
|
60
|
+
purpose: "Behind/ahead counts; only meaningful once an upstream exists.",
|
|
61
|
+
tolerateFailure: true,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "status-porcelain",
|
|
65
|
+
args: ["status", "--porcelain=v1"],
|
|
66
|
+
purpose: "Reported working-tree changes — inflated by autocrlf on Windows.",
|
|
67
|
+
tolerateFailure: false,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "content-drift",
|
|
71
|
+
args: ["diff", "--name-only", "--ignore-all-space"],
|
|
72
|
+
purpose: "Real content drift — the number to trust when autocrlf is on.",
|
|
73
|
+
tolerateFailure: false,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "stash-list",
|
|
77
|
+
args: ["stash", "list"],
|
|
78
|
+
purpose: "Stashes an earlier session may have left holding real work.",
|
|
79
|
+
tolerateFailure: false,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "worktree-list",
|
|
83
|
+
args: ["worktree", "list", "--porcelain"],
|
|
84
|
+
purpose: "Sibling worktrees another session may be writing to.",
|
|
85
|
+
tolerateFailure: false,
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
/**
|
|
89
|
+
* In-progress operation markers, addressed via `git rev-parse --git-path` so
|
|
90
|
+
* they resolve correctly inside a linked worktree, where `.git` is a file and
|
|
91
|
+
* the real marker lives under `.git/worktrees/<name>/`.
|
|
92
|
+
*/
|
|
93
|
+
export const IN_PROGRESS_MARKERS = [
|
|
94
|
+
{ id: "merge", gitPath: "MERGE_HEAD", label: "merge in progress" },
|
|
95
|
+
{ id: "rebase-merge", gitPath: "rebase-merge", label: "interactive rebase in progress" },
|
|
96
|
+
{ id: "rebase-apply", gitPath: "rebase-apply", label: "rebase/am in progress" },
|
|
97
|
+
{ id: "cherry-pick", gitPath: "CHERRY_PICK_HEAD", label: "cherry-pick in progress" },
|
|
98
|
+
{ id: "revert", gitPath: "REVERT_HEAD", label: "revert in progress" },
|
|
99
|
+
{ id: "bisect", gitPath: "BISECT_LOG", label: "bisect in progress" },
|
|
100
|
+
];
|
|
101
|
+
const SHA = /^[0-9a-f]{7,64}$/i;
|
|
102
|
+
const SAFE_REF = /^[A-Za-z0-9._/-]+$/;
|
|
103
|
+
/** Split git output on either line ending — git on Windows emits CRLF. */
|
|
104
|
+
function toLines(raw) {
|
|
105
|
+
return raw.split(/\r?\n/);
|
|
106
|
+
}
|
|
107
|
+
function isSha(value) {
|
|
108
|
+
return SHA.test(value);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* True when `name` is safe to interpolate into a path, an argv entry, or a
|
|
112
|
+
* comparison that authorizes a mutation.
|
|
113
|
+
*
|
|
114
|
+
* Fails closed: null, empty, surrounding whitespace, shell metacharacters,
|
|
115
|
+
* `..`, refspec syntax (`@{`), a leading `-`, a trailing `/` or `.lock`, and
|
|
116
|
+
* anything over 255 characters all return false rather than being sanitized
|
|
117
|
+
* into something that then looks valid.
|
|
118
|
+
*/
|
|
119
|
+
export function isSafeRefName(name) {
|
|
120
|
+
if (typeof name !== "string")
|
|
121
|
+
return false;
|
|
122
|
+
if (name.length === 0 || name.length > 255)
|
|
123
|
+
return false;
|
|
124
|
+
if (name.trim() !== name)
|
|
125
|
+
return false;
|
|
126
|
+
if (name.startsWith("-"))
|
|
127
|
+
return false;
|
|
128
|
+
if (name.endsWith("/") || name.endsWith(".lock"))
|
|
129
|
+
return false;
|
|
130
|
+
if (name.includes("..") || name.includes("@{"))
|
|
131
|
+
return false;
|
|
132
|
+
return SAFE_REF.test(name);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Classify HEAD from `git symbolic-ref --quiet --short HEAD`.
|
|
136
|
+
*
|
|
137
|
+
* A detached HEAD makes that command exit non-zero with empty output. This
|
|
138
|
+
* module deliberately never uses `git branch --show-current`, which returns an
|
|
139
|
+
* empty string with exit 0 and so cannot be told apart from a successful read.
|
|
140
|
+
* Returns `{ kind: "unknown", branch: null }` when output is present but is not
|
|
141
|
+
* a usable ref name, so an unparseable branch never reads as a match against an
|
|
142
|
+
* expected one.
|
|
143
|
+
*/
|
|
144
|
+
export function classifyHead(rawBranch, exitCode = 0) {
|
|
145
|
+
const value = typeof rawBranch === "string" ? rawBranch.trim() : "";
|
|
146
|
+
if (exitCode !== 0 || value.length === 0) {
|
|
147
|
+
return { kind: "detached", branch: null };
|
|
148
|
+
}
|
|
149
|
+
if (!isSafeRefName(value))
|
|
150
|
+
return { kind: "unknown", branch: null };
|
|
151
|
+
return { kind: "branch", branch: value };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Parse `git rev-list --left-right --count @{u}...HEAD` output, which is
|
|
155
|
+
* `"<behind>\t<ahead>"`.
|
|
156
|
+
*
|
|
157
|
+
* Returns null for empty, malformed, negative, or non-integer output. Callers
|
|
158
|
+
* must treat null as "unknown" — never as zero.
|
|
159
|
+
*/
|
|
160
|
+
export function parseRevListCounts(raw) {
|
|
161
|
+
if (typeof raw !== "string")
|
|
162
|
+
return null;
|
|
163
|
+
const first = toLines(raw).find((line) => line.trim().length > 0);
|
|
164
|
+
if (first === undefined)
|
|
165
|
+
return null;
|
|
166
|
+
const parts = first.trim().split(/\s+/);
|
|
167
|
+
if (parts.length !== 2)
|
|
168
|
+
return null;
|
|
169
|
+
const behind = Number(parts[0]);
|
|
170
|
+
const ahead = Number(parts[1]);
|
|
171
|
+
if (!Number.isInteger(behind) || behind < 0)
|
|
172
|
+
return null;
|
|
173
|
+
if (!Number.isInteger(ahead) || ahead < 0)
|
|
174
|
+
return null;
|
|
175
|
+
return { behind, ahead };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Map counts to an upstream relation.
|
|
179
|
+
*
|
|
180
|
+
* Fails closed: no upstream returns `"no-upstream"`, and an upstream that does
|
|
181
|
+
* exist but whose counts would not parse returns `"unknown"` — never `"even"`.
|
|
182
|
+
*/
|
|
183
|
+
export function classifyUpstream(counts, hasUpstream) {
|
|
184
|
+
if (!hasUpstream)
|
|
185
|
+
return "no-upstream";
|
|
186
|
+
if (counts === null)
|
|
187
|
+
return "unknown";
|
|
188
|
+
const { behind, ahead } = counts;
|
|
189
|
+
if (behind === 0 && ahead === 0)
|
|
190
|
+
return "even";
|
|
191
|
+
if (behind > 0 && ahead > 0)
|
|
192
|
+
return "diverged";
|
|
193
|
+
return ahead > 0 ? "ahead" : "behind";
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Collect a label for every in-progress operation whose marker is present.
|
|
197
|
+
*
|
|
198
|
+
* `present` is keyed by `InProgressMarker.id`. A marker missing from the map is
|
|
199
|
+
* treated as *unprobed*, not absent, and reported as `"<id>: unprobed"` — a
|
|
200
|
+
* probe that never ran must not masquerade as a clean tree. Returns `[]` only
|
|
201
|
+
* when every known marker was probed and none was present.
|
|
202
|
+
*/
|
|
203
|
+
export function classifyInProgress(present) {
|
|
204
|
+
const found = [];
|
|
205
|
+
for (const marker of IN_PROGRESS_MARKERS) {
|
|
206
|
+
const state = present[marker.id];
|
|
207
|
+
if (state === undefined) {
|
|
208
|
+
found.push(`${marker.id}: unprobed`);
|
|
209
|
+
}
|
|
210
|
+
else if (state) {
|
|
211
|
+
found.push(marker.label);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return found;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* True when `branch` must not be pushed to or committed onto directly.
|
|
218
|
+
*
|
|
219
|
+
* Fails closed: null, an unparseable name, and a detached HEAD all return true,
|
|
220
|
+
* because an unknown branch is not proof of safety. Supports one trailing `/*`
|
|
221
|
+
* wildcard per pattern (`release/*`).
|
|
222
|
+
*/
|
|
223
|
+
export function isProtectedBranch(branch, patterns = DEFAULT_PROTECTED_BRANCHES) {
|
|
224
|
+
if (!isSafeRefName(branch))
|
|
225
|
+
return true;
|
|
226
|
+
const name = branch;
|
|
227
|
+
return patterns.some((pattern) => pattern.endsWith("/*")
|
|
228
|
+
? name.startsWith(pattern.slice(0, -1))
|
|
229
|
+
: name === pattern);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Decide whether a push actually landed, from `git ls-remote` output.
|
|
233
|
+
*
|
|
234
|
+
* Three outcomes, never collapsed into two: a non-zero exit is `"unverified"`
|
|
235
|
+
* (the network or the remote failed, so absence of the ref is unproven and the
|
|
236
|
+
* push may well have landed), an empty successful probe is `"not-landed"` (the
|
|
237
|
+
* ref is genuinely absent), and a present ref is compared by SHA. An
|
|
238
|
+
* unparseable local HEAD or unparseable remote output is `"unverified"`.
|
|
239
|
+
*/
|
|
240
|
+
export function verifyPushLanded(input) {
|
|
241
|
+
const local = (input.localHead ?? "").trim();
|
|
242
|
+
if (!isSha(local)) {
|
|
243
|
+
return { verdict: "unverified", reason: "local HEAD is not a usable sha" };
|
|
244
|
+
}
|
|
245
|
+
if (input.lsRemoteExitCode !== 0) {
|
|
246
|
+
return {
|
|
247
|
+
verdict: "unverified",
|
|
248
|
+
reason: `git ls-remote exited ${input.lsRemoteExitCode}; absence of the ref is unproven`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
const raw = (input.lsRemoteStdout ?? "").trim();
|
|
252
|
+
if (raw.length === 0) {
|
|
253
|
+
return { verdict: "not-landed", reason: "remote ref does not exist" };
|
|
254
|
+
}
|
|
255
|
+
const remoteSha = ((toLines(raw)[0] ?? "").trim().split(/\s+/)[0] ?? "");
|
|
256
|
+
if (!isSha(remoteSha)) {
|
|
257
|
+
return { verdict: "unverified", reason: "could not parse a sha from ls-remote output" };
|
|
258
|
+
}
|
|
259
|
+
if (remoteSha.toLowerCase() === local.toLowerCase()) {
|
|
260
|
+
return {
|
|
261
|
+
verdict: "landed",
|
|
262
|
+
reason: `remote tip matches local HEAD ${local.slice(0, 12)}`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
verdict: "not-landed",
|
|
267
|
+
reason: `remote tip ${remoteSha.slice(0, 12)} differs from local HEAD ${local.slice(0, 12)}`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Reconcile `git status --porcelain` output against real content drift.
|
|
272
|
+
*
|
|
273
|
+
* On an `autocrlf=true` tree status reports line-ending-only modifications that
|
|
274
|
+
* carry no content change, which is why the reconcile skill says to trust
|
|
275
|
+
* `git diff` instead. A `contentDrift` of null means the drift probe never ran;
|
|
276
|
+
* it is reported as null rather than assumed to be zero.
|
|
277
|
+
*/
|
|
278
|
+
export function accountDirty(statusStdout, diffNamesStdout) {
|
|
279
|
+
const count = (raw) => typeof raw === "string"
|
|
280
|
+
? toLines(raw).filter((line) => line.trim().length > 0).length
|
|
281
|
+
: null;
|
|
282
|
+
const reported = count(statusStdout) ?? 0;
|
|
283
|
+
const contentDrift = count(diffNamesStdout);
|
|
284
|
+
return {
|
|
285
|
+
reported,
|
|
286
|
+
contentDrift,
|
|
287
|
+
phantomSuspected: contentDrift !== null && reported > contentDrift,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* True when the repo moved under us since `before` was captured — the check to
|
|
292
|
+
* run immediately before each mutation on a host with concurrent writers.
|
|
293
|
+
*
|
|
294
|
+
* Fails closed: a missing or unparseable sha on either side counts as shifted,
|
|
295
|
+
* because "we could not tell" must never read as "nothing moved".
|
|
296
|
+
*/
|
|
297
|
+
export function baselineShifted(before, after) {
|
|
298
|
+
if (before === null || after === null)
|
|
299
|
+
return true;
|
|
300
|
+
const beforeHead = (before.head ?? "").trim();
|
|
301
|
+
const afterHead = (after.head ?? "").trim();
|
|
302
|
+
if (!isSha(beforeHead) || !isSha(afterHead))
|
|
303
|
+
return true;
|
|
304
|
+
if (beforeHead.toLowerCase() !== afterHead.toLowerCase())
|
|
305
|
+
return true;
|
|
306
|
+
return (before.branch ?? null) !== (after.branch ?? null);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Turn a probed state into the ordered finding list the resolved-state block
|
|
310
|
+
* renders. Every finding carries a severity; `blocker` means do not mutate.
|
|
311
|
+
*/
|
|
312
|
+
export function assessGitState(input) {
|
|
313
|
+
const findings = [];
|
|
314
|
+
const protectedList = input.protectedBranches ?? DEFAULT_PROTECTED_BRANCHES;
|
|
315
|
+
if (input.head.kind === "branch") {
|
|
316
|
+
const onProtected = isProtectedBranch(input.head.branch, protectedList);
|
|
317
|
+
findings.push({
|
|
318
|
+
id: "head",
|
|
319
|
+
label: "HEAD",
|
|
320
|
+
detail: onProtected
|
|
321
|
+
? `on protected branch ${input.head.branch} — branch before mutating`
|
|
322
|
+
: `on ${input.head.branch}`,
|
|
323
|
+
severity: onProtected ? "warn" : "ok",
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
else if (input.head.kind === "detached") {
|
|
327
|
+
findings.push({
|
|
328
|
+
id: "head",
|
|
329
|
+
label: "HEAD",
|
|
330
|
+
detail: "detached — no branch to commit onto, push, or name in a PR",
|
|
331
|
+
severity: "blocker",
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
findings.push({
|
|
336
|
+
id: "head",
|
|
337
|
+
label: "HEAD",
|
|
338
|
+
detail: "branch name unparseable — treat repo identity as unknown",
|
|
339
|
+
severity: "blocker",
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
const relation = classifyUpstream(input.counts, input.upstreamRef !== null);
|
|
343
|
+
const upstreamRows = {
|
|
344
|
+
even: { detail: `even with ${input.upstreamRef}`, severity: "ok" },
|
|
345
|
+
ahead: {
|
|
346
|
+
detail: `${input.counts?.ahead ?? "?"} ahead of ${input.upstreamRef}`,
|
|
347
|
+
severity: "ok",
|
|
348
|
+
},
|
|
349
|
+
behind: {
|
|
350
|
+
detail: `${input.counts?.behind ?? "?"} behind ${input.upstreamRef} — pull --ff-only first`,
|
|
351
|
+
severity: "warn",
|
|
352
|
+
},
|
|
353
|
+
diverged: {
|
|
354
|
+
detail: `diverged from ${input.upstreamRef} ` +
|
|
355
|
+
`(${input.counts?.behind ?? "?"} behind / ${input.counts?.ahead ?? "?"} ahead) — ` +
|
|
356
|
+
"rebase or merge deliberately, never blind --force",
|
|
357
|
+
severity: "warn",
|
|
358
|
+
},
|
|
359
|
+
"no-upstream": {
|
|
360
|
+
detail: "no configured upstream — compare against origin/<default> explicitly before classifying",
|
|
361
|
+
severity: "warn",
|
|
362
|
+
},
|
|
363
|
+
unknown: {
|
|
364
|
+
detail: "upstream exists but counts did not parse — relationship unknown, do not assume even",
|
|
365
|
+
severity: "blocker",
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
findings.push({ id: "upstream", label: "upstream", ...upstreamRows[relation] });
|
|
369
|
+
const ops = classifyInProgress(Object.fromEntries(IN_PROGRESS_MARKERS.map((marker) => [marker.id, input.inProgress.includes(marker.id)])));
|
|
370
|
+
findings.push({
|
|
371
|
+
id: "in-progress",
|
|
372
|
+
label: "in-progress op",
|
|
373
|
+
detail: ops.length === 0 ? "none" : `${ops.join(", ")} — another actor may own this tree`,
|
|
374
|
+
severity: ops.length === 0 ? "ok" : "blocker",
|
|
375
|
+
});
|
|
376
|
+
const { reported, contentDrift, phantomSuspected } = input.dirty;
|
|
377
|
+
findings.push({
|
|
378
|
+
id: "dirty",
|
|
379
|
+
label: "working tree",
|
|
380
|
+
detail: contentDrift === null
|
|
381
|
+
? `${reported} path(s) reported by status; content drift unprobed`
|
|
382
|
+
: phantomSuspected
|
|
383
|
+
? `${reported} reported by status but only ${contentDrift} with content drift — ` +
|
|
384
|
+
"stage by explicit filename, never git add -A"
|
|
385
|
+
: `${contentDrift} path(s) with content drift`,
|
|
386
|
+
severity: contentDrift === null ? "warn" : "ok",
|
|
387
|
+
});
|
|
388
|
+
return findings;
|
|
389
|
+
}
|
|
390
|
+
/** True when any finding blocks mutation. */
|
|
391
|
+
export function hasBlockers(findings) {
|
|
392
|
+
return findings.some((finding) => finding.severity === "blocker");
|
|
393
|
+
}
|
|
394
|
+
const SEVERITY_TAG = {
|
|
395
|
+
ok: "ok",
|
|
396
|
+
warn: "WARN",
|
|
397
|
+
blocker: "BLOCKER",
|
|
398
|
+
};
|
|
399
|
+
/**
|
|
400
|
+
* Render the resolved-state block an operator reads before mutating. Returns a
|
|
401
|
+
* single "state unknown" line when no probe produced a finding, so an empty
|
|
402
|
+
* result never renders as a clean tree.
|
|
403
|
+
*/
|
|
404
|
+
export function renderResolvedState(findings) {
|
|
405
|
+
if (findings.length === 0) {
|
|
406
|
+
return "reconcile (resolved): no probes ran — state unknown";
|
|
407
|
+
}
|
|
408
|
+
const width = Math.max(...findings.map((finding) => finding.label.length));
|
|
409
|
+
const rows = findings.map((finding) => ` ${finding.label.padEnd(width)} ${SEVERITY_TAG[finding.severity].padEnd(7)} ${finding.detail}`);
|
|
410
|
+
return ["reconcile (resolved):", ...rows].join("\n");
|
|
411
|
+
}
|
|
@@ -26,7 +26,7 @@ const KEYWORDS = [
|
|
|
26
26
|
"transcript-linter",
|
|
27
27
|
];
|
|
28
28
|
const CLAUDE_PLUGIN_CATEGORY = "productivity";
|
|
29
|
-
const SHARED_PLUGIN_DESCRIPTION = "The persistent-memory and runtime-discipline layer for Claude Code. It remembers the corrections you already gave, grounds every edit in real facts before it lands, and — through the Mulahazah engine — turns each fix into a reusable instinct, so a lesson learned once is applied automatically next time with no re-teaching. Built on the 7 Laws of AI Agent Discipline (research, plan, verify, reflect, learn) and shipped as
|
|
29
|
+
const SHARED_PLUGIN_DESCRIPTION = "The persistent-memory and runtime-discipline layer for Claude Code. It remembers the corrections you already gave, grounds every edit in real facts before it lands, and — through the Mulahazah engine — turns each fix into a reusable instinct, so a lesson learned once is applied automatically next time with no re-teaching. Built on the 7 Laws of AI Agent Discipline (research, plan, verify, reflect, learn) and shipped as 28 bundled skills, instinct-aware hooks, an MCP toolset for recall and reflection, and a GitHub Action transcript linter that feeds real work history back into sharper instincts.";
|
|
30
30
|
// Four vendored upstream companions registered alongside the CI plugin.
|
|
31
31
|
// Each entry points at a pinned-SHA snapshot under third-party/<name>/.
|
|
32
32
|
// See third-party/MANIFEST.md for refresh recipes and per-snapshot
|
|
@@ -457,76 +457,74 @@ export function getClaudePluginManifest() {
|
|
|
457
457
|
};
|
|
458
458
|
}
|
|
459
459
|
export function getPluginHooksConfig() {
|
|
460
|
+
// Cold Node startup on loaded Windows hosts has exceeded five seconds.
|
|
461
|
+
const hookTimeoutSeconds = 30;
|
|
460
462
|
const gateguardCommand = {
|
|
461
463
|
type: "command",
|
|
462
464
|
command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gateguard.mjs\"",
|
|
463
|
-
timeout:
|
|
465
|
+
timeout: hookTimeoutSeconds,
|
|
464
466
|
};
|
|
465
467
|
const companionPreferenceCommand = {
|
|
466
468
|
type: "command",
|
|
467
469
|
command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/companion-preference.mjs\"",
|
|
468
|
-
timeout:
|
|
470
|
+
timeout: hookTimeoutSeconds,
|
|
469
471
|
};
|
|
470
472
|
const hookPackCommand = {
|
|
471
473
|
type: "command",
|
|
472
474
|
command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-pack.mjs\"",
|
|
473
|
-
timeout:
|
|
475
|
+
timeout: hookTimeoutSeconds,
|
|
474
476
|
};
|
|
475
477
|
const observeCommand = {
|
|
476
478
|
type: "command",
|
|
477
|
-
command: "
|
|
478
|
-
timeout:
|
|
479
|
+
command: "node \"${CLAUDE_PLUGIN_ROOT}/bin/observe.mjs\"",
|
|
480
|
+
timeout: hookTimeoutSeconds,
|
|
479
481
|
};
|
|
480
482
|
const sessionCommand = {
|
|
481
483
|
type: "command",
|
|
482
|
-
command: "
|
|
483
|
-
timeout:
|
|
484
|
+
command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session.mjs\"",
|
|
485
|
+
timeout: hookTimeoutSeconds,
|
|
484
486
|
};
|
|
485
487
|
const threeSectionCloseCommand = {
|
|
486
488
|
type: "command",
|
|
487
489
|
command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/three-section-close.mjs\"",
|
|
488
|
-
timeout:
|
|
490
|
+
timeout: hookTimeoutSeconds,
|
|
489
491
|
};
|
|
490
492
|
const goalDriftStopCommand = {
|
|
491
493
|
type: "command",
|
|
492
494
|
command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/goal-drift-stop.mjs\"",
|
|
493
|
-
timeout:
|
|
495
|
+
timeout: hookTimeoutSeconds,
|
|
494
496
|
};
|
|
495
497
|
const workflowDistillCommand = {
|
|
496
498
|
type: "command",
|
|
497
499
|
command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/workflow-distill.mjs\"",
|
|
498
|
-
timeout:
|
|
500
|
+
timeout: hookTimeoutSeconds,
|
|
499
501
|
};
|
|
500
502
|
const typecheckStopCommand = {
|
|
501
503
|
type: "command",
|
|
502
504
|
command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/typecheck-stop.mjs\"",
|
|
503
|
-
|
|
504
|
-
// (off by default) and near-zero cost when off / no TS file changed; on an
|
|
505
|
-
// internal timeout it fails open (allow) rather than blocking.
|
|
506
|
-
timeout: 30,
|
|
505
|
+
timeout: hookTimeoutSeconds,
|
|
507
506
|
};
|
|
508
507
|
const queryCostNudgeCommand = {
|
|
509
508
|
type: "command",
|
|
510
509
|
command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/query-cost-nudge.mjs\"",
|
|
511
|
-
timeout:
|
|
510
|
+
timeout: hookTimeoutSeconds,
|
|
512
511
|
};
|
|
513
512
|
const routePromptCommand = {
|
|
514
513
|
type: "command",
|
|
515
514
|
command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/route-prompt.mjs\"",
|
|
516
|
-
timeout:
|
|
515
|
+
timeout: hookTimeoutSeconds,
|
|
517
516
|
};
|
|
518
517
|
const recallBriefingCommand = {
|
|
519
518
|
type: "command",
|
|
520
519
|
command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/recall-briefing.mjs\"",
|
|
521
|
-
timeout:
|
|
520
|
+
timeout: hookTimeoutSeconds,
|
|
522
521
|
};
|
|
523
522
|
return {
|
|
524
|
-
description: "Gateguard fact-forcing PreToolUse, companion-preference enforcement, observation, session lifecycle, 3-section-close discipline, goal-drift Stop gate, opt-in workflow-distill Stop nudge, opt-in typecheck Stop gate, opt-in query-cost Stop nudge, and UserPromptSubmit lazy-routing plus opt-in proactive recall-briefing hooks for continuous-improvement.",
|
|
525
523
|
hooks: {
|
|
526
524
|
// gateguard runs FIRST on PreToolUse so its block decision short-circuits
|
|
527
525
|
// before companion-preference sees the call. companion-preference runs
|
|
528
526
|
// second on Skill tool calls; it is a no-op under ci-first (the default)
|
|
529
|
-
// and never blocks under companions-first.
|
|
527
|
+
// and never blocks under companions-first. The observer only runs on
|
|
530
528
|
// PostToolUse: gateguard-blocked calls are intentionally not observed so
|
|
531
529
|
// PreToolUse stays at two subprocesses on the hot path. route-prompt
|
|
532
530
|
// fires on UserPromptSubmit and emits a system-reminder when a prompt
|
|
@@ -34,6 +34,7 @@ skill set on disk.
|
|
|
34
34
|
- `recovery-classification` — Enforces Law 4 (Verify Before Reporting) of the 7 Laws of AI Agent Discipline. After any failure in the verification ladder or auto-loop, classify the failure class before retrying — provider, tool-schema, deterministic-policy, git, worktree, runtime — so retry-vs-pause-vs-self-heal-vs-stop is an intentional decision, not a generic 'try again'.
|
|
35
35
|
- `roast` — Enforces Law 1 (Research Before Executing) of the 7 Laws of AI Agent Discipline. Convene a 5-persona adversarial council (Contrarian, Expansionist, Logician, Researcher, Buyer) that attacks an idea from every angle, then a Judge returns one GO / RESHAPE / KILL verdict plus the cheapest 48-hour test to de-risk it — so you pressure-test an idea before sinking time into building the wrong thing.
|
|
36
36
|
- `safety-guard` — Enforces Law 3 (One Thing at a Time) of the 7 Laws of AI Agent Discipline by scoping edits to a directory and blocking destructive shell commands. Use this skill to prevent destructive operations when working on production systems or running agents autonomously.
|
|
37
|
+
- `simplicity-review` — Enforces Law 4 (Verify Before Reporting) of the 7 Laws of AI Agent Discipline. Reviews the current diff for over-engineering (code that could reuse an existing file, a stdlib or native feature, or fewer lines) and reports trim findings without touching code, so 'it works' is never mistaken for 'it is the minimum that works'.
|
|
37
38
|
- `skill-distillation` — Enforces Law 7 (Learn From Every Session) of the 7 Laws of AI Agent Discipline. Distills repeated successful tool sequences into reusable draft instincts, so a pattern that worked three times becomes a captured recipe instead of being re-derived from scratch every session.
|
|
38
39
|
- `state-reconciliation` — Enforces Law 4 (Verify Before Reporting) of the 7 Laws of AI Agent Discipline. Pre-dispatch invariant: reconcile DB-vs-disk-vs-memory state before any unit runs, so a stale flag, missing artifact, or out-of-sync row never re-dispatches a unit that already completed or never started.
|
|
39
40
|
- `strategic-compact` — Enforces Law 5 (Reflect After Every Session) of the 7 Laws of AI Agent Discipline at phase boundaries. Suggests manual context compaction at logical intervals to preserve context through task phases rather than arbitrary auto-compaction.
|
|
@@ -213,6 +213,7 @@ Rows whose **Preferred skill** is not bundled with the `continuous-improvement`
|
|
|
213
213
|
| Fix bug / investigate failure | `superpowers:systematic-debugging` | Hypothesis → add logs/tests → reproduce → smallest fix → verify with the failing repro. (Reference behavior — does not require `superpowers:systematic-debugging`.) |
|
|
214
214
|
| Write tests / add coverage | `superpowers:test-driven-development` or `tdd-workflow` | RED (failing test) → GREEN (minimal code) → REFACTOR; one test, one behavior |
|
|
215
215
|
| Refactor / dead code cleanup | `simplify` | Find dupes/unused exports, delete in place, re-run type check and smallest test. (Reference behavior — does not require `simplify`.) |
|
|
216
|
+
| Over-engineering check on a diff | `simplicity-review` (bundled) | Walk the reuse ladder (existing file, stdlib, native feature, one line) over the current diff and report GO/TRIM without editing; apply trims, then re-run the smallest check. Never flags input validation, data-loss handling, security, or accessibility. |
|
|
216
217
|
| Security review / auth audit | `security-review` | Scan for hardcoded secrets, unsanitized input, missing authz, SQL string concat, open CORS. (Reference behavior — does not require `security-review`.) |
|
|
217
218
|
| Code review before merge | `superpowers:requesting-code-review` or `code-review` | Read diff top-to-bottom, flag CRITICAL / HIGH / MEDIUM. (Reference behavior — does not require `code-review`.) |
|
|
218
219
|
| Verify before shipping | `superpowers:verification-before-completion` | Smallest check that proves correctness: typecheck + one test + one curl. (Reference behavior — does not require `superpowers:verification-before-completion`.) |
|
|
@@ -20,27 +20,65 @@ Law 1 says research before executing. The most expensive skipped research is the
|
|
|
20
20
|
|
|
21
21
|
## Establish Ground Truth First
|
|
22
22
|
|
|
23
|
-
Read before you write.
|
|
23
|
+
Read before you write. One command runs the whole pass and prints the resolved-state block:
|
|
24
24
|
|
|
25
25
|
```
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
npx ci-reconcile # resolved-state block; exit 1 if anything blocks a mutation
|
|
27
|
+
npx ci-reconcile --json # the same state, machine-readable
|
|
28
|
+
npx ci-reconcile --explain # print the probe set and why each probe runs
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Exit codes: `0` nothing blocks, `1` at least one blocker, `2` not a git repository. The probe set is defined once in `src/lib/git-state.mts`, and `npm run verify:reconcile-parity` fails if this document drifts from it — the list below is the list the runner executes.
|
|
32
|
+
|
|
33
|
+
Run the pass by hand when the runner is not installed:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
git rev-parse --show-toplevel # inside a work tree, and where
|
|
37
|
+
git rev-parse HEAD # the sha every later claim is relative to
|
|
38
|
+
git symbolic-ref --quiet --short HEAD # branch name; NON-ZERO EXIT = detached HEAD
|
|
39
|
+
git rev-parse --abbrev-ref --symbolic-full-name @{u} # upstream, or non-zero = none configured
|
|
40
|
+
git rev-list --left-right --count @{u}...HEAD # behind/ahead — only after the line above succeeded
|
|
41
|
+
git status --porcelain=v1 # reported changes (inflated by autocrlf)
|
|
42
|
+
git diff --name-only --ignore-all-space # real content drift — the number to trust
|
|
29
43
|
git stash list
|
|
30
|
-
git worktree list
|
|
31
|
-
|
|
44
|
+
git worktree list --porcelain
|
|
45
|
+
git rev-parse --git-path MERGE_HEAD # in-progress op: test the RESOLVED path for existence
|
|
32
46
|
```
|
|
33
47
|
|
|
34
|
-
|
|
48
|
+
Four boundaries make the obvious commands lie. Each was reproduced against real git; do not simplify them back.
|
|
49
|
+
|
|
50
|
+
- **No configured upstream.** Asking `git rev-list` for counts against `@{u}` exits **128** with `fatal: no upstream configured` — it does not return zeros. Probe for the upstream first and ask for counts only once it resolved. With no upstream, compare against `origin/<default>` explicitly; never read the failure as "even".
|
|
51
|
+
- **Detached HEAD.** The `--show-current` form of `git branch` prints an empty string and exits **0**, so a detached HEAD is indistinguishable from a successful read. `git symbolic-ref --quiet --short HEAD` exits non-zero instead, which is checkable. A detached HEAD blocks: there is no branch to commit onto, push, or name in a PR.
|
|
52
|
+
- **Linked worktrees.** Inside a worktree `.git` is a *file*, not a directory, so listing a `.git/`-relative path for `MERGE_HEAD` fails with "Not a directory" and exit **2** — byte-identical to the "no operation in progress" result on a clean tree. A real conflicted merge therefore reads as clean. Resolve the marker with `git rev-parse --git-path MERGE_HEAD` and test *that* path; it is correct in a main checkout and in a worktree alike. Same for `rebase-merge`, `rebase-apply`, `CHERRY_PICK_HEAD`, `REVERT_HEAD`, `BISECT_LOG`.
|
|
53
|
+
- **Windows `autocrlf=true`.** `git status` reports phantom line-ending-only modifications. Trust `git diff --stat` / `git diff --name-only --ignore-all-space` for real content drift. Never stage with `git add -A` / `git add .` on such a tree — stage by explicit filename. The runner prints both numbers so the gap is visible instead of assumed.
|
|
54
|
+
|
|
55
|
+
## Compatibility
|
|
56
|
+
|
|
57
|
+
The ground-truth pass has to work wherever the agent runs, not only in Bash. `ci-reconcile` spawns `git` argv directly with no shell, so it needs no `bash`, no coreutils, and no `.git/`-relative path.
|
|
58
|
+
|
|
59
|
+
| Surface | PowerShell / cmd | Git Bash / WSL | POSIX shell | Linked worktree | Detached HEAD | No upstream |
|
|
60
|
+
|---|---|---|---|---|---|---|
|
|
61
|
+
| `ci-reconcile` (Node) | yes | yes | yes | correct | blocks | warns |
|
|
62
|
+
| `scripts/git-state-snapshot.sh` | needs Git Bash | yes | yes | root/branch only | reports `detached` | reports `none` |
|
|
63
|
+
| Hand-run probe list above | yes | yes | yes | correct | non-zero exit | non-zero exit |
|
|
64
|
+
|
|
65
|
+
Smoke-test on the OS you actually ship on. Both surfaces emit the same `{head, upstream, dirty, root, branch}` envelope — `ci-reconcile --snapshot` adds `contentDrift` and `inProgress` — and a test pins that parity so the two cannot drift apart silently.
|
|
35
66
|
|
|
36
67
|
## Detect a Concurrent Writer
|
|
37
68
|
|
|
38
69
|
When another session/loop may be active, do not assume the tree is yours:
|
|
39
70
|
|
|
40
|
-
- An in-progress `MERGE_HEAD` / `rebase-merge` you did not start means another actor is mid-operation. Do not "help" by editing conflicted files — wait, or hand off.
|
|
41
|
-
- Re-read the
|
|
42
|
-
|
|
43
|
-
|
|
71
|
+
- An in-progress `MERGE_HEAD` / `rebase-merge` you did not start means another actor is mid-operation. Do not "help" by editing conflicted files — wait, or hand off. The runner reports this as a blocker; the retired `.git/`-relative probe could not see it inside a worktree at all.
|
|
72
|
+
- **Re-read HEAD and the branch immediately before every mutation, not once per session.** Capture a baseline, then compare right before you commit, push, or rebase:
|
|
73
|
+
```
|
|
74
|
+
npx ci-reconcile --snapshot > .git/reconcile-baseline.json # or any scratch path
|
|
75
|
+
# ... do work ...
|
|
76
|
+
npx ci-reconcile --snapshot # compare head + branch against the baseline
|
|
77
|
+
```
|
|
78
|
+
If either field moved, another writer got there first — re-survey from the top instead of committing onto an unexpected base. A missing or unparseable field counts as *shifted*; "we could not tell" is never "nothing moved".
|
|
79
|
+
- More than one entry in `git worktree list --porcelain` means a sibling checkout exists that another session may be writing to. The runner flags this.
|
|
80
|
+
- If the git index keeps changing while you are idle, a writer is active. Pause and surface it rather than racing.
|
|
81
|
+
- If `gateguard` is installed, its Parallel-Actor Gate already captured this baseline on the session's first mutation by running `bash "${CLAUDE_PLUGIN_ROOT}/scripts/git-state-snapshot.sh"` (source: `scripts/git-state-snapshot.sh`) and divergence-checks every later mutation — `reconcile` complements that gate, it does not replace it. That shell snapshot needs Git Bash and derives its `dirty` count from `git status`, which overstates drift on an `autocrlf` tree; `ci-reconcile --snapshot` is the same envelope without either limitation. Without gateguard, run one of them yourself.
|
|
44
82
|
|
|
45
83
|
## Classify, Then Act
|
|
46
84
|
|
|
@@ -99,12 +137,24 @@ Once ground truth is known and the halt gates are clear, carry the work to an op
|
|
|
99
137
|
|
|
100
138
|
A push that printed no error is still a claim. Confirm:
|
|
101
139
|
|
|
140
|
+
```
|
|
141
|
+
npx ci-reconcile --verify-push <branch> # exit 0 only when the remote tip equals local HEAD
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
or by hand:
|
|
145
|
+
|
|
102
146
|
```
|
|
103
147
|
git rev-parse HEAD
|
|
104
148
|
git ls-remote origin refs/heads/<branch> # remote tip must equal local HEAD
|
|
105
149
|
```
|
|
106
150
|
|
|
107
|
-
|
|
151
|
+
There are **three** outcomes here, not two, and collapsing them is how a false report gets made:
|
|
152
|
+
|
|
153
|
+
- **landed** — the probe succeeded and the remote tip equals local HEAD.
|
|
154
|
+
- **not-landed** — the probe succeeded and the ref is absent, or points at a different sha. The push really did not land.
|
|
155
|
+
- **unverified** — `git ls-remote` itself failed (network, auth, remote down). This is *not* evidence the push failed; it is evidence you do not know. Retry the probe. Never report success, and never report failure, from a probe that did not run.
|
|
156
|
+
|
|
157
|
+
Report only what the probe proved.
|
|
108
158
|
|
|
109
159
|
## Sync the Default Branch After the PR Merges
|
|
110
160
|
|