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.
@@ -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 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
+
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
- git ls-remote origin refs/heads/<branch> # remote tip must equal local HEAD, else it did not land
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: