continuous-improvement 3.21.0 → 3.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,283 @@
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
+ let cwdProvided = false;
58
+ for (let i = 0; i < args.length; i++) {
59
+ const arg = args[i] ?? "";
60
+ if (arg === "--json")
61
+ options.json = true;
62
+ else if (arg === "--snapshot")
63
+ options.snapshot = true;
64
+ else if (arg === "--explain")
65
+ options.explain = true;
66
+ else if (arg === "--verify-push") {
67
+ if (options.verifyPush !== null) {
68
+ throw new Error("--verify-push may only be provided once");
69
+ }
70
+ const value = args[i + 1];
71
+ if (value === undefined || value.startsWith("--")) {
72
+ throw new Error("--verify-push requires a branch name");
73
+ }
74
+ if (!isSafeRefName(value)) {
75
+ throw new Error(`--verify-push branch is not a usable ref name: ${value}`);
76
+ }
77
+ options.verifyPush = value;
78
+ i++;
79
+ }
80
+ else if (arg === "--cwd") {
81
+ if (cwdProvided) {
82
+ throw new Error("--cwd may only be provided once");
83
+ }
84
+ const value = args[i + 1];
85
+ if (value === undefined || value.startsWith("--") || value.trim().length === 0) {
86
+ throw new Error("--cwd requires a directory");
87
+ }
88
+ options.root = value;
89
+ cwdProvided = true;
90
+ i++;
91
+ }
92
+ else if (arg === "--help" || arg === "-h") {
93
+ options.explain = true;
94
+ }
95
+ else {
96
+ throw new Error(`unknown argument: ${arg}`);
97
+ }
98
+ }
99
+ const actions = [
100
+ options.snapshot ? "--snapshot" : null,
101
+ options.explain ? "--explain" : null,
102
+ options.verifyPush !== null ? "--verify-push" : null,
103
+ ].filter((action) => action !== null);
104
+ if (actions.length > 1) {
105
+ throw new Error(`mutually exclusive action modes: ${actions.join(", ")}`);
106
+ }
107
+ return options;
108
+ }
109
+ /** Probe each in-progress marker through `--git-path`, which is worktree-correct. */
110
+ function probeInProgress(root) {
111
+ const present = [];
112
+ for (const marker of IN_PROGRESS_MARKERS) {
113
+ const resolved = git(["rev-parse", "--git-path", marker.gitPath], root);
114
+ // A failed resolve leaves the marker unprobed rather than absent; the
115
+ // classifier reports that explicitly instead of implying a clean tree.
116
+ if (resolved.code !== 0)
117
+ continue;
118
+ const path = resolved.stdout.trim();
119
+ if (path.length > 0 && existsSync(path))
120
+ present.push(marker.id);
121
+ }
122
+ return present;
123
+ }
124
+ function explain() {
125
+ const rows = GROUND_TRUTH_PROBES.map((probe) => ` git ${probe.args.join(" ")}\n ${probe.purpose}` +
126
+ (probe.tolerateFailure ? "\n (a non-zero exit is an answer, not a failure)" : ""));
127
+ const markers = IN_PROGRESS_MARKERS.map((marker) => ` git rev-parse --git-path ${marker.gitPath} # ${marker.label}`);
128
+ return [
129
+ "reconcile ground-truth probes (source: src/lib/git-state.mts):",
130
+ ...rows,
131
+ "",
132
+ "in-progress operation markers:",
133
+ ...markers,
134
+ ].join("\n");
135
+ }
136
+ function main() {
137
+ let options;
138
+ try {
139
+ options = parseArgs(argv.slice(2));
140
+ }
141
+ catch (error) {
142
+ stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
143
+ exit(NOT_A_REPO);
144
+ return;
145
+ }
146
+ if (options.explain) {
147
+ stdout.write(`${explain()}\n`);
148
+ exit(0);
149
+ return;
150
+ }
151
+ const root = git(["rev-parse", "--show-toplevel"], options.root);
152
+ if (root.code !== 0) {
153
+ if (options.snapshot) {
154
+ stdout.write('{"error":"not-a-git-repo"}\n');
155
+ }
156
+ else {
157
+ stderr.write("not a git repository (or git is unavailable)\n");
158
+ }
159
+ exit(NOT_A_REPO);
160
+ return;
161
+ }
162
+ const repoRoot = root.stdout.trim();
163
+ const headSha = git(["rev-parse", "HEAD"], options.root);
164
+ const branchProbe = git(["symbolic-ref", "--quiet", "--short", "HEAD"], options.root);
165
+ const head = classifyHead(branchProbe.stdout, branchProbe.code);
166
+ const upstreamProbe = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], options.root);
167
+ const upstreamRef = upstreamProbe.code === 0 ? upstreamProbe.stdout.trim() || null : null;
168
+ // Only ask for counts once an upstream is known to exist — the bare command
169
+ // exits 128 with `fatal: no upstream configured` otherwise.
170
+ const countsProbe = upstreamRef === null
171
+ ? null
172
+ : git(["rev-list", "--left-right", "--count", "@{u}...HEAD"], options.root);
173
+ const counts = countsProbe !== null && countsProbe.code === 0
174
+ ? parseRevListCounts(countsProbe.stdout)
175
+ : null;
176
+ const status = git(["status", "--porcelain=v1"], options.root);
177
+ const drift = git(["diff", "--name-only", "--ignore-all-space"], options.root);
178
+ const dirty = accountDirty(status.code === 0 ? status.stdout : null, drift.code === 0 ? drift.stdout : null);
179
+ const inProgress = probeInProgress(options.root);
180
+ const baseFindings = assessGitState({
181
+ headCommit: headSha.code === 0 ? headSha.stdout.trim() : null,
182
+ head,
183
+ upstreamRef,
184
+ counts,
185
+ inProgress,
186
+ dirty,
187
+ });
188
+ if (options.snapshot) {
189
+ // Field-compatible with scripts/git-state-snapshot.sh, plus `contentDrift`,
190
+ // `inProgress`, and a fail-closed blocker summary the shell version cannot
191
+ // report. An unborn HEAD is a git repo, but not a usable mutation baseline;
192
+ // do not emit an empty `head` field that looks like success.
193
+ const upstreamSha = upstreamRef === null ? "none" : git(["rev-parse", "--short", "@{u}"], options.root).stdout.trim() || "none";
194
+ const shortHead = headSha.code === 0 ? git(["rev-parse", "--short", "HEAD"], options.root).stdout.trim() : "";
195
+ const blockers = baseFindings.filter((finding) => finding.severity === "blocker").map((finding) => finding.id);
196
+ stdout.write(`${JSON.stringify({
197
+ head: shortHead.length > 0 ? shortHead : "unborn",
198
+ upstream: upstreamSha,
199
+ dirty: dirty.reported,
200
+ root: repoRoot,
201
+ branch: head.branch ?? "detached",
202
+ contentDrift: dirty.contentDrift,
203
+ inProgress,
204
+ blocked: blockers.length > 0,
205
+ blockers,
206
+ })}\n`);
207
+ exit(blockers.length > 0 ? 1 : 0);
208
+ return;
209
+ }
210
+ if (options.verifyPush !== null) {
211
+ const lsRemote = git(["ls-remote", "origin", `refs/heads/${options.verifyPush}`], options.root);
212
+ const verdict = verifyPushLanded({
213
+ localHead: headSha.stdout.trim(),
214
+ lsRemoteStdout: lsRemote.stdout,
215
+ lsRemoteExitCode: lsRemote.code,
216
+ });
217
+ const line = `push ${options.verifyPush}: ${verdict.verdict} — ${verdict.reason}`;
218
+ if (options.json)
219
+ stdout.write(`${JSON.stringify({ branch: options.verifyPush, ...verdict })}\n`);
220
+ else
221
+ stdout.write(`${line}\n`);
222
+ exit(verdict.verdict === "landed" ? 0 : 1);
223
+ return;
224
+ }
225
+ const findings = [...baseFindings];
226
+ const stashes = git(["stash", "list"], options.root);
227
+ const stashCount = stashes.code === 0
228
+ ? stashes.stdout.split(/\r?\n/).filter((line) => line.trim().length > 0).length
229
+ : null;
230
+ findings.push({
231
+ id: "stashes",
232
+ label: "stashes",
233
+ detail: stashCount === null
234
+ ? "unprobed"
235
+ : stashCount === 0
236
+ ? "none"
237
+ : `${stashCount} — may hold uncommitted work from an earlier session`,
238
+ severity: stashCount === null ? "warn" : "ok",
239
+ });
240
+ const worktrees = git(["worktree", "list", "--porcelain"], options.root);
241
+ const worktreeCount = worktrees.code === 0
242
+ ? worktrees.stdout.split(/\r?\n/).filter((line) => line.startsWith("worktree ")).length
243
+ : null;
244
+ findings.push({
245
+ id: "worktrees",
246
+ label: "worktrees",
247
+ detail: worktreeCount === null
248
+ ? "unprobed"
249
+ : worktreeCount <= 1
250
+ ? "1 (this one)"
251
+ : `${worktreeCount} — a concurrent writer may hold another; re-read HEAD immediately before each mutation`,
252
+ severity: worktreeCount === null || (worktreeCount ?? 0) > 1 ? "warn" : "ok",
253
+ });
254
+ const blocked = hasBlockers(findings);
255
+ if (options.json) {
256
+ stdout.write(`${JSON.stringify({
257
+ root: repoRoot,
258
+ head: headSha.stdout.trim(),
259
+ branch: head.branch,
260
+ headKind: head.kind,
261
+ upstream: upstreamRef,
262
+ relation: classifyUpstream(counts, upstreamRef !== null),
263
+ counts,
264
+ inProgress,
265
+ dirty,
266
+ stashes: stashCount,
267
+ worktrees: worktreeCount,
268
+ findings,
269
+ blocked,
270
+ }, null, 2)}\n`);
271
+ }
272
+ else {
273
+ stdout.write(`${renderResolvedState(findings)}\n`);
274
+ if (blocked) {
275
+ stdout.write("\nBLOCKED — resolve every blocker above before mutating this tree.\n");
276
+ }
277
+ }
278
+ exit(blocked ? 1 : 0);
279
+ }
280
+ const invokedDirectly = argv[1] !== undefined && import.meta.url.endsWith(argv[1].replace(/\\/g, "/"));
281
+ if (invokedDirectly || argv[1]?.endsWith("reconcile.mjs")) {
282
+ main();
283
+ }
@@ -13,15 +13,39 @@ 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 branch --show-current
18
- git status --porcelain=v1 # but trust git diff --stat for real drift (autocrlf)
19
- git rev-list --left-right --count '@{u}...HEAD' # behind / ahead (quote the ref — bare @{u} trips the Bash parser)
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
- ls .git/MERGE_HEAD .git/rebase-merge .git/rebase-apply 2>/dev/null # in-progress op = another actor; do not race
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
+ - **Unborn HEAD** — `git init` with no commit is a real repo with no usable baseline; `rev-parse HEAD` fails. Reported as `head: "unborn"` and blocked, never as an empty field that reads like success.
46
+
47
+ `--snapshot` shares the exit contract (`0` clear / `1` blocked / `2` not a repo) and adds `contentDrift`, `inProgress`, `blocked`, `blockers` to the shell script's envelope. It still writes the JSON on a blocker, so tolerate exit 1 when capturing a baseline under `set -e`.
48
+
25
49
  ## Then act, with gates
26
50
 
27
51
  ```
@@ -51,9 +75,12 @@ gh pr create --fill --base main # open one PR, then STOP
51
75
  ## Verify the push landed
52
76
 
53
77
  ```
54
- git ls-remote origin refs/heads/<branch> # remote tip must equal local HEAD, else it did not land
78
+ npx ci-reconcile --verify-push <branch> # exit 0 only when the remote tip equals local HEAD
79
+ git ls-remote origin refs/heads/<branch> # by hand: remote tip must equal local HEAD
55
80
  ```
56
81
 
82
+ 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).
83
+
57
84
  ## Sync the default branch after the PR merges
58
85
 
59
86
  "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: