continuous-improvement 3.21.0 → 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 +1 -1
- package/CHANGELOG.md +13 -0
- package/bin/check-reconcile-parity.mjs +168 -0
- package/bin/generate-plugin-manifests.mjs +2 -0
- package/bin/reconcile.mjs +259 -0
- package/commands/reconcile.md +30 -6
- package/lib/git-state.mjs +411 -0
- package/package.json +5 -3
- package/plugins/beginner.json +1 -1
- package/plugins/continuous-improvement/.claude-plugin/marketplace.json +1 -1
- package/plugins/continuous-improvement/.claude-plugin/plugin.json +1 -1
- package/plugins/continuous-improvement/bin/reconcile.mjs +259 -0
- package/plugins/continuous-improvement/commands/reconcile.md +30 -6
- package/plugins/continuous-improvement/lib/git-state.mjs +411 -0
- package/plugins/continuous-improvement/skills/reconcile/SKILL.md +62 -12
- package/plugins/expert.json +1 -1
- package/skills/reconcile.md +62 -12
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* reconcile (`ci-reconcile`).
|
|
4
|
+
*
|
|
5
|
+
* Run the `reconcile` skill's ground-truth pass and print the resolved-state
|
|
6
|
+
* block, so Law 1 is a command an agent runs rather than prose it claims to
|
|
7
|
+
* have followed.
|
|
8
|
+
*
|
|
9
|
+
* node bin/reconcile.mjs resolved-state block; exit 1 on a blocker
|
|
10
|
+
* node bin/reconcile.mjs --json the same state as JSON
|
|
11
|
+
* node bin/reconcile.mjs --snapshot one-line envelope, field-compatible
|
|
12
|
+
* with scripts/git-state-snapshot.sh
|
|
13
|
+
* node bin/reconcile.mjs --verify-push <br> prove a push landed on origin/<br>
|
|
14
|
+
* node bin/reconcile.mjs --explain print the probe set and why each runs
|
|
15
|
+
* node bin/reconcile.mjs --cwd <dir> run against another repo root
|
|
16
|
+
*
|
|
17
|
+
* Portability: every probe is `git` argv spawned with `shell: false`. No bash,
|
|
18
|
+
* no coreutils, no `.git/`-relative paths — so it behaves the same in PowerShell,
|
|
19
|
+
* cmd, Git Bash, WSL and a POSIX shell, and it stays correct inside a linked
|
|
20
|
+
* worktree where `.git` is a file rather than a directory.
|
|
21
|
+
*
|
|
22
|
+
* Exit codes:
|
|
23
|
+
* 0 — ground truth established, nothing blocks a mutation
|
|
24
|
+
* 1 — at least one blocker (detached HEAD, in-progress op, unknown upstream)
|
|
25
|
+
* 2 — not a git repository, or git is unavailable
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync } from "node:fs";
|
|
28
|
+
import { spawnSync } from "node:child_process";
|
|
29
|
+
import { argv, cwd, exit, stderr, stdout } from "node:process";
|
|
30
|
+
import { GROUND_TRUTH_PROBES, IN_PROGRESS_MARKERS, accountDirty, assessGitState, classifyHead, classifyUpstream, hasBlockers, isSafeRefName, parseRevListCounts, renderResolvedState, verifyPushLanded, } from "../lib/git-state.mjs";
|
|
31
|
+
const NOT_A_REPO = 2;
|
|
32
|
+
/** Spawn `git` with no shell. A missing git binary reads as a failed probe. */
|
|
33
|
+
function git(args, root) {
|
|
34
|
+
const result = spawnSync("git", [...args], {
|
|
35
|
+
cwd: root,
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
shell: false,
|
|
38
|
+
windowsHide: true,
|
|
39
|
+
});
|
|
40
|
+
if (result.error) {
|
|
41
|
+
return { stdout: "", stderr: result.error.message, code: 127 };
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
stdout: result.stdout ?? "",
|
|
45
|
+
stderr: result.stderr ?? "",
|
|
46
|
+
code: typeof result.status === "number" ? result.status : 1,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function parseArgs(args) {
|
|
50
|
+
const options = {
|
|
51
|
+
json: false,
|
|
52
|
+
snapshot: false,
|
|
53
|
+
explain: false,
|
|
54
|
+
verifyPush: null,
|
|
55
|
+
root: cwd(),
|
|
56
|
+
};
|
|
57
|
+
for (let i = 0; i < args.length; i++) {
|
|
58
|
+
const arg = args[i] ?? "";
|
|
59
|
+
if (arg === "--json")
|
|
60
|
+
options.json = true;
|
|
61
|
+
else if (arg === "--snapshot")
|
|
62
|
+
options.snapshot = true;
|
|
63
|
+
else if (arg === "--explain")
|
|
64
|
+
options.explain = true;
|
|
65
|
+
else if (arg === "--verify-push") {
|
|
66
|
+
const value = args[i + 1];
|
|
67
|
+
if (value === undefined || value.startsWith("--")) {
|
|
68
|
+
throw new Error("--verify-push requires a branch name");
|
|
69
|
+
}
|
|
70
|
+
if (!isSafeRefName(value)) {
|
|
71
|
+
throw new Error(`--verify-push branch is not a usable ref name: ${value}`);
|
|
72
|
+
}
|
|
73
|
+
options.verifyPush = value;
|
|
74
|
+
i++;
|
|
75
|
+
}
|
|
76
|
+
else if (arg === "--cwd") {
|
|
77
|
+
const value = args[i + 1];
|
|
78
|
+
if (value === undefined || value.startsWith("--")) {
|
|
79
|
+
throw new Error("--cwd requires a directory");
|
|
80
|
+
}
|
|
81
|
+
options.root = value;
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
else if (arg === "--help" || arg === "-h") {
|
|
85
|
+
options.explain = true;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return options;
|
|
92
|
+
}
|
|
93
|
+
/** Probe each in-progress marker through `--git-path`, which is worktree-correct. */
|
|
94
|
+
function probeInProgress(root) {
|
|
95
|
+
const present = [];
|
|
96
|
+
for (const marker of IN_PROGRESS_MARKERS) {
|
|
97
|
+
const resolved = git(["rev-parse", "--git-path", marker.gitPath], root);
|
|
98
|
+
// A failed resolve leaves the marker unprobed rather than absent; the
|
|
99
|
+
// classifier reports that explicitly instead of implying a clean tree.
|
|
100
|
+
if (resolved.code !== 0)
|
|
101
|
+
continue;
|
|
102
|
+
const path = resolved.stdout.trim();
|
|
103
|
+
if (path.length > 0 && existsSync(path))
|
|
104
|
+
present.push(marker.id);
|
|
105
|
+
}
|
|
106
|
+
return present;
|
|
107
|
+
}
|
|
108
|
+
function explain() {
|
|
109
|
+
const rows = GROUND_TRUTH_PROBES.map((probe) => ` git ${probe.args.join(" ")}\n ${probe.purpose}` +
|
|
110
|
+
(probe.tolerateFailure ? "\n (a non-zero exit is an answer, not a failure)" : ""));
|
|
111
|
+
const markers = IN_PROGRESS_MARKERS.map((marker) => ` git rev-parse --git-path ${marker.gitPath} # ${marker.label}`);
|
|
112
|
+
return [
|
|
113
|
+
"reconcile ground-truth probes (source: src/lib/git-state.mts):",
|
|
114
|
+
...rows,
|
|
115
|
+
"",
|
|
116
|
+
"in-progress operation markers:",
|
|
117
|
+
...markers,
|
|
118
|
+
].join("\n");
|
|
119
|
+
}
|
|
120
|
+
function main() {
|
|
121
|
+
let options;
|
|
122
|
+
try {
|
|
123
|
+
options = parseArgs(argv.slice(2));
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
127
|
+
exit(NOT_A_REPO);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (options.explain) {
|
|
131
|
+
stdout.write(`${explain()}\n`);
|
|
132
|
+
exit(0);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const root = git(["rev-parse", "--show-toplevel"], options.root);
|
|
136
|
+
if (root.code !== 0) {
|
|
137
|
+
if (options.snapshot) {
|
|
138
|
+
stdout.write('{"error":"not-a-git-repo"}\n');
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
stderr.write("not a git repository (or git is unavailable)\n");
|
|
142
|
+
}
|
|
143
|
+
exit(NOT_A_REPO);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const repoRoot = root.stdout.trim();
|
|
147
|
+
const headSha = git(["rev-parse", "HEAD"], options.root);
|
|
148
|
+
const branchProbe = git(["symbolic-ref", "--quiet", "--short", "HEAD"], options.root);
|
|
149
|
+
const head = classifyHead(branchProbe.stdout, branchProbe.code);
|
|
150
|
+
const upstreamProbe = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], options.root);
|
|
151
|
+
const upstreamRef = upstreamProbe.code === 0 ? upstreamProbe.stdout.trim() || null : null;
|
|
152
|
+
// Only ask for counts once an upstream is known to exist — the bare command
|
|
153
|
+
// exits 128 with `fatal: no upstream configured` otherwise.
|
|
154
|
+
const countsProbe = upstreamRef === null
|
|
155
|
+
? null
|
|
156
|
+
: git(["rev-list", "--left-right", "--count", "@{u}...HEAD"], options.root);
|
|
157
|
+
const counts = countsProbe !== null && countsProbe.code === 0
|
|
158
|
+
? parseRevListCounts(countsProbe.stdout)
|
|
159
|
+
: null;
|
|
160
|
+
const status = git(["status", "--porcelain=v1"], options.root);
|
|
161
|
+
const drift = git(["diff", "--name-only", "--ignore-all-space"], options.root);
|
|
162
|
+
const dirty = accountDirty(status.code === 0 ? status.stdout : null, drift.code === 0 ? drift.stdout : null);
|
|
163
|
+
if (options.snapshot) {
|
|
164
|
+
// Field-compatible with scripts/git-state-snapshot.sh, plus `contentDrift`
|
|
165
|
+
// and `inProgress`, which the shell version cannot report.
|
|
166
|
+
const upstreamSha = upstreamRef === null ? "none" : git(["rev-parse", "--short", "@{u}"], options.root).stdout.trim() || "none";
|
|
167
|
+
stdout.write(`${JSON.stringify({
|
|
168
|
+
head: git(["rev-parse", "--short", "HEAD"], options.root).stdout.trim(),
|
|
169
|
+
upstream: upstreamSha,
|
|
170
|
+
dirty: dirty.reported,
|
|
171
|
+
root: repoRoot,
|
|
172
|
+
branch: head.branch ?? "detached",
|
|
173
|
+
contentDrift: dirty.contentDrift,
|
|
174
|
+
inProgress: probeInProgress(options.root),
|
|
175
|
+
})}\n`);
|
|
176
|
+
exit(0);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (options.verifyPush !== null) {
|
|
180
|
+
const lsRemote = git(["ls-remote", "origin", `refs/heads/${options.verifyPush}`], options.root);
|
|
181
|
+
const verdict = verifyPushLanded({
|
|
182
|
+
localHead: headSha.stdout.trim(),
|
|
183
|
+
lsRemoteStdout: lsRemote.stdout,
|
|
184
|
+
lsRemoteExitCode: lsRemote.code,
|
|
185
|
+
});
|
|
186
|
+
const line = `push ${options.verifyPush}: ${verdict.verdict} — ${verdict.reason}`;
|
|
187
|
+
if (options.json)
|
|
188
|
+
stdout.write(`${JSON.stringify({ branch: options.verifyPush, ...verdict })}\n`);
|
|
189
|
+
else
|
|
190
|
+
stdout.write(`${line}\n`);
|
|
191
|
+
exit(verdict.verdict === "landed" ? 0 : 1);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const inProgress = probeInProgress(options.root);
|
|
195
|
+
const findings = assessGitState({
|
|
196
|
+
head,
|
|
197
|
+
upstreamRef,
|
|
198
|
+
counts,
|
|
199
|
+
inProgress,
|
|
200
|
+
dirty,
|
|
201
|
+
});
|
|
202
|
+
const stashes = git(["stash", "list"], options.root);
|
|
203
|
+
const stashCount = stashes.code === 0
|
|
204
|
+
? stashes.stdout.split(/\r?\n/).filter((line) => line.trim().length > 0).length
|
|
205
|
+
: null;
|
|
206
|
+
findings.push({
|
|
207
|
+
id: "stashes",
|
|
208
|
+
label: "stashes",
|
|
209
|
+
detail: stashCount === null
|
|
210
|
+
? "unprobed"
|
|
211
|
+
: stashCount === 0
|
|
212
|
+
? "none"
|
|
213
|
+
: `${stashCount} — may hold uncommitted work from an earlier session`,
|
|
214
|
+
severity: stashCount === null ? "warn" : "ok",
|
|
215
|
+
});
|
|
216
|
+
const worktrees = git(["worktree", "list", "--porcelain"], options.root);
|
|
217
|
+
const worktreeCount = worktrees.code === 0
|
|
218
|
+
? worktrees.stdout.split(/\r?\n/).filter((line) => line.startsWith("worktree ")).length
|
|
219
|
+
: null;
|
|
220
|
+
findings.push({
|
|
221
|
+
id: "worktrees",
|
|
222
|
+
label: "worktrees",
|
|
223
|
+
detail: worktreeCount === null
|
|
224
|
+
? "unprobed"
|
|
225
|
+
: worktreeCount <= 1
|
|
226
|
+
? "1 (this one)"
|
|
227
|
+
: `${worktreeCount} — a concurrent writer may hold another; re-read HEAD immediately before each mutation`,
|
|
228
|
+
severity: worktreeCount === null || (worktreeCount ?? 0) > 1 ? "warn" : "ok",
|
|
229
|
+
});
|
|
230
|
+
const blocked = hasBlockers(findings);
|
|
231
|
+
if (options.json) {
|
|
232
|
+
stdout.write(`${JSON.stringify({
|
|
233
|
+
root: repoRoot,
|
|
234
|
+
head: headSha.stdout.trim(),
|
|
235
|
+
branch: head.branch,
|
|
236
|
+
headKind: head.kind,
|
|
237
|
+
upstream: upstreamRef,
|
|
238
|
+
relation: classifyUpstream(counts, upstreamRef !== null),
|
|
239
|
+
counts,
|
|
240
|
+
inProgress,
|
|
241
|
+
dirty,
|
|
242
|
+
stashes: stashCount,
|
|
243
|
+
worktrees: worktreeCount,
|
|
244
|
+
findings,
|
|
245
|
+
blocked,
|
|
246
|
+
}, null, 2)}\n`);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
stdout.write(`${renderResolvedState(findings)}\n`);
|
|
250
|
+
if (blocked) {
|
|
251
|
+
stdout.write("\nBLOCKED — resolve every blocker above before mutating this tree.\n");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
exit(blocked ? 1 : 0);
|
|
255
|
+
}
|
|
256
|
+
const invokedDirectly = argv[1] !== undefined && import.meta.url.endsWith(argv[1].replace(/\\/g, "/"));
|
|
257
|
+
if (invokedDirectly || argv[1]?.endsWith("reconcile.mjs")) {
|
|
258
|
+
main();
|
|
259
|
+
}
|
|
@@ -13,15 +13,36 @@ Snapshots the full git state in one pass, detects a concurrent writer, classifie
|
|
|
13
13
|
|
|
14
14
|
## Establish ground truth
|
|
15
15
|
|
|
16
|
+
One command, cross-platform, no shell required:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
npx ci-reconcile # resolved-state block; exit 0 clear / 1 blocked / 2 not a repo
|
|
20
|
+
npx ci-reconcile --json # machine-readable
|
|
21
|
+
npx ci-reconcile --explain # the probe set and why each probe runs
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The same pass by hand. `src/lib/git-state.mts` is the source of truth for this list, and `npm run verify:reconcile-parity` fails if this file drifts from it:
|
|
25
|
+
|
|
16
26
|
```
|
|
17
|
-
git
|
|
18
|
-
git
|
|
19
|
-
git
|
|
27
|
+
git rev-parse --show-toplevel # inside a work tree, and where
|
|
28
|
+
git rev-parse HEAD # the sha every later claim is relative to
|
|
29
|
+
git symbolic-ref --quiet --short HEAD # branch; NON-ZERO EXIT = detached HEAD
|
|
30
|
+
git rev-parse --abbrev-ref --symbolic-full-name @{u} # upstream, or non-zero = none configured
|
|
31
|
+
git rev-list --left-right --count @{u}...HEAD # behind/ahead — only after the line above succeeded
|
|
32
|
+
git status --porcelain=v1 # reported changes (inflated by autocrlf)
|
|
33
|
+
git diff --name-only --ignore-all-space # real content drift — the number to trust
|
|
20
34
|
git stash list
|
|
21
|
-
git worktree list
|
|
22
|
-
|
|
35
|
+
git worktree list --porcelain
|
|
36
|
+
git rev-parse --git-path MERGE_HEAD # in-progress op: test the RESOLVED path
|
|
23
37
|
```
|
|
24
38
|
|
|
39
|
+
Four boundaries where the obvious command lies:
|
|
40
|
+
|
|
41
|
+
- **No upstream** — asking `git rev-list` for counts against `@{u}` exits 128; it does not return zeros. Resolve the upstream first.
|
|
42
|
+
- **Detached HEAD** — the `--show-current` form of `git branch` prints "" and exits 0, indistinguishable from success. `symbolic-ref --quiet` exits non-zero instead. Detached blocks.
|
|
43
|
+
- **Linked worktree** — `.git` is a *file* there, so a `.git/`-relative marker probe exits 2 exactly as it does on a clean tree: a real conflicted merge reads as clean. Use `rev-parse --git-path`.
|
|
44
|
+
- **autocrlf** — `git status` overstates drift. Stage by explicit filename, never `git add -A`.
|
|
45
|
+
|
|
25
46
|
## Then act, with gates
|
|
26
47
|
|
|
27
48
|
```
|
|
@@ -51,9 +72,12 @@ gh pr create --fill --base main # open one PR, then STOP
|
|
|
51
72
|
## Verify the push landed
|
|
52
73
|
|
|
53
74
|
```
|
|
54
|
-
|
|
75
|
+
npx ci-reconcile --verify-push <branch> # exit 0 only when the remote tip equals local HEAD
|
|
76
|
+
git ls-remote origin refs/heads/<branch> # by hand: remote tip must equal local HEAD
|
|
55
77
|
```
|
|
56
78
|
|
|
79
|
+
Three outcomes, never two: **landed** (tip matches), **not-landed** (probe succeeded, ref absent or different sha), **unverified** (`ls-remote` itself failed — you do not know; retry, and report neither success nor failure).
|
|
80
|
+
|
|
57
81
|
## Sync the default branch after the PR merges
|
|
58
82
|
|
|
59
83
|
"Latest work on main" is true only once the PR merges, and on a protected branch that merge is a human action. After it lands:
|