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.
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "continuous-improvement",
10
10
  "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.",
11
- "version": "3.21.0",
11
+ "version": "3.22.0",
12
12
  "source": "./plugins/continuous-improvement",
13
13
  "author": {
14
14
  "name": "naimkatiman"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable changes to this skill are documented here.
4
4
 
5
5
  ---
6
6
 
7
+ ## [3.22.0] — 2026-08-02
8
+
9
+ ### Added
10
+
11
+ - **`ci-reconcile`**: the `reconcile` ground-truth pass is now a command, not just prose. It spawns `git` argv with no shell — no bash, no coreutils, no `.git/`-relative path — so it behaves the same in PowerShell, cmd, Git Bash and WSL, and stays correct inside a linked worktree. `--json`, `--explain`, `--verify-push <branch>`, and `--snapshot` (field-compatible with `scripts/git-state-snapshot.sh`, plus `contentDrift` and `inProgress`). Exits `0` clear / `1` blocked / `2` not a git repository. (#289)
12
+ - **`verify:reconcile-parity`**, the 16th `verify:all` invariant: a fenced code block in each of `skills/reconcile.md` and `commands/reconcile.md` must prescribe every probe in `GROUND_TRUTH_PROBES` verbatim, and no fenced block may reintroduce a retired form. Fenced-only, so prose stays free to explain *why* a command was retired. (#289)
13
+
14
+ ### Fixed
15
+
16
+ - **`reconcile` no longer reports a false clean state at four boundaries.** Reproduced against real git: asking `git rev-list` for counts against `@{u}` exits **128** with `fatal: no upstream configured` rather than returning zeros; `git branch --show-current` prints an empty string and exits **0** on a detached HEAD, indistinguishable from a successful read; inside a linked worktree `.git` is a **file**, so listing a `.git/`-relative `MERGE_HEAD` exits **2** exactly as it does on a clean tree, meaning a real conflicted merge read as clean; and `git status` overstates drift on an `autocrlf` tree. The skill and command now document a portable probe for each, plus a compatibility matrix. (#289)
17
+ - **Every `git-state` classifier fails closed.** Unparseable ahead/behind counts read as `unknown` (a blocker), never `even`. A failed `git ls-remote` reads as `unverified` — never `not-landed` and never `landed` — so a network failure can no longer be reported as either outcome. An unprobed in-progress marker reads as `unprobed`, never `absent`. An unparseable branch name never matches an expected one. (#289)
18
+ - Verify-lint count prose corrected across `CLAUDE.md` (claimed 14 and omitted `landing-version`), `CONTRIBUTING.md` (13) and `docs/RELEASING.md` (12); the actual count was 15, now 16. (#289)
19
+
7
20
  ## [3.21.0] — 2026-07-11
8
21
 
9
22
  ### Added
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Reconcile-Parity Invariant Check
4
+ *
5
+ * The ground-truth git command set exists in three places at once: the runner
6
+ * (`bin/reconcile.mjs`), the skill (`skills/reconcile.md`) and the slash command
7
+ * (`commands/reconcile.md`). Prose copies drift — the `'@{u}'` Bash-quoting fix
8
+ * had to be applied by hand to both docs, and a portability fix applied to one
9
+ * copy leaves the other telling agents to run the broken form.
10
+ *
11
+ * `GROUND_TRUTH_PROBES` in src/lib/git-state.mts is the single source of truth.
12
+ * This lint asserts the docs still document exactly that set, and that neither
13
+ * doc has regressed to a command proven non-portable on 2026-08-02:
14
+ *
15
+ * Side A — a fenced code block in each doc prescribes every probe in
16
+ * GROUND_TRUTH_PROBES verbatim. Fenced blocks only: a probe named in
17
+ * prose but dropped from the copyable block would otherwise pass
18
+ * while the command an agent actually runs had lost it.
19
+ * Side B — no fenced code block in either doc *prescribes* a retired form:
20
+ * `ls .git/...` (`.git` is a FILE in a linked worktree,
21
+ * so the probe silently reports nothing)
22
+ * `branch --show-current` (empty stdout + exit 0 on a detached
23
+ * HEAD is indistinguishable from success)
24
+ * Only fenced blocks are scanned, so the prose stays free to explain
25
+ * why each form was retired without tripping its own lint.
26
+ * Side C — both docs document `rev-parse --git-path`, the worktree-correct way
27
+ * to locate an in-progress-operation marker.
28
+ *
29
+ * Fail-closed: a missing file or an empty doc is a violation, not a silent pass.
30
+ *
31
+ * Usage:
32
+ * node bin/check-reconcile-parity.mjs # Check the current repo
33
+ * node bin/check-reconcile-parity.mjs <repo-root> # Check a specific repo root
34
+ *
35
+ * Exit codes:
36
+ * 0 — both docs match the shipped probe set and carry no retired form
37
+ * 1 — at least one drift
38
+ */
39
+ import { existsSync, readFileSync } from "node:fs";
40
+ import { join } from "node:path";
41
+ import { argv, cwd, exit } from "node:process";
42
+ import { GROUND_TRUTH_PROBES } from "../lib/git-state.mjs";
43
+ const DOCS = ["skills/reconcile.md", "commands/reconcile.md"];
44
+ const RETIRED_FORMS = [
45
+ {
46
+ pattern: /\bls\s+[^\n`]*\.git\//,
47
+ label: "ls .git/<marker>",
48
+ why: "`.git` is a FILE inside a linked worktree, so this probe exits 2 exactly as it does on a clean tree — a real merge reads as no operation in progress",
49
+ },
50
+ {
51
+ pattern: /\bbranch\s+--show-current\b/,
52
+ label: "git branch --show-current",
53
+ why: "returns an empty string with exit 0 on a detached HEAD, which cannot be told apart from a successful read; use `symbolic-ref --quiet --short HEAD`",
54
+ },
55
+ ];
56
+ const REQUIRED_SUBSTRINGS = [
57
+ {
58
+ needle: "rev-parse --git-path",
59
+ why: "the only worktree-correct way to locate MERGE_HEAD / rebase-merge / rebase-apply",
60
+ },
61
+ ];
62
+ /**
63
+ * Normalize a doc or a command for comparison: collapse whitespace and drop the
64
+ * shell quoting docs add around `@{u}` refspecs. Returns "" for absent input.
65
+ */
66
+ export function normalizeForMatch(text) {
67
+ if (typeof text !== "string")
68
+ return "";
69
+ return text.replace(/[`'"]/g, "").replace(/\s+/g, " ");
70
+ }
71
+ /** The literal command line a doc must contain for a probe, before normalization. */
72
+ export function probeCommand(args) {
73
+ return `git ${args.join(" ")}`;
74
+ }
75
+ /**
76
+ * Concatenate the bodies of every fenced code block in `body`.
77
+ *
78
+ * Retired forms are only a defect when a doc *prescribes* them, so Side B scans
79
+ * fenced blocks alone — prose may name a retired command to explain it. Returns
80
+ * "" when the doc has no fenced block.
81
+ */
82
+ export function fencedBlocks(body) {
83
+ const lines = body.split(/\r?\n/);
84
+ const collected = [];
85
+ let inside = false;
86
+ for (const line of lines) {
87
+ if (/^\s*```/.test(line)) {
88
+ inside = !inside;
89
+ continue;
90
+ }
91
+ if (inside)
92
+ collected.push(line);
93
+ }
94
+ return collected.join("\n");
95
+ }
96
+ /** Check one doc body against the shipped probe set. Returns [] when reconciled. */
97
+ export function checkDoc(doc, body) {
98
+ if (body === null)
99
+ return [{ doc, kind: "missing-file", detail: "file does not exist" }];
100
+ if (body.trim().length === 0)
101
+ return [{ doc, kind: "empty", detail: "file is empty" }];
102
+ const violations = [];
103
+ const prescribed = fencedBlocks(body);
104
+ const prescribedHaystack = normalizeForMatch(prescribed);
105
+ // Side A scans fenced blocks only. A probe named in prose but absent from the
106
+ // copyable block would otherwise satisfy the check while the command an agent
107
+ // actually runs had silently dropped it.
108
+ for (const probe of GROUND_TRUTH_PROBES) {
109
+ const command = probeCommand(probe.args);
110
+ if (!prescribedHaystack.includes(normalizeForMatch(command))) {
111
+ violations.push({
112
+ doc,
113
+ kind: "missing-probe",
114
+ detail: `no fenced block prescribes probe "${probe.id}": ${command}`,
115
+ });
116
+ }
117
+ }
118
+ for (const retired of RETIRED_FORMS) {
119
+ if (retired.pattern.test(prescribed)) {
120
+ violations.push({
121
+ doc,
122
+ kind: "retired-form",
123
+ detail: `a fenced block still prescribes \`${retired.label}\` — ${retired.why}`,
124
+ });
125
+ }
126
+ }
127
+ const haystack = normalizeForMatch(body);
128
+ for (const required of REQUIRED_SUBSTRINGS) {
129
+ if (!haystack.includes(normalizeForMatch(required.needle))) {
130
+ violations.push({
131
+ doc,
132
+ kind: "missing-substring",
133
+ detail: `does not mention \`${required.needle}\` — ${required.why}`,
134
+ });
135
+ }
136
+ }
137
+ return violations;
138
+ }
139
+ /** Read every doc under `repoRoot` and collect violations across all of them. */
140
+ export function checkRepo(repoRoot) {
141
+ return DOCS.flatMap((doc) => {
142
+ const path = join(repoRoot, doc);
143
+ const body = existsSync(path) ? readFileSync(path, "utf8") : null;
144
+ return checkDoc(doc, body);
145
+ });
146
+ }
147
+ function main() {
148
+ const repoRoot = argv[2] ?? cwd();
149
+ const violations = checkRepo(repoRoot);
150
+ if (violations.length === 0) {
151
+ console.log(`OK reconcile-parity: ${DOCS.length} doc(s) document all ${GROUND_TRUTH_PROBES.length} ground-truth probe(s), no retired forms.`);
152
+ return;
153
+ }
154
+ console.error(`FAIL reconcile-parity: ${violations.length} drift(s).`);
155
+ console.error("");
156
+ for (const violation of violations) {
157
+ console.error(` ${violation.doc} — ${violation.detail}`);
158
+ }
159
+ console.error("");
160
+ console.error("Fix: GROUND_TRUTH_PROBES in src/lib/git-state.mts is the source of truth. Update the");
161
+ console.error("doc prose to match it (or change the probe set there first), then `npm run build` so the");
162
+ console.error("plugin mirrors regenerate.");
163
+ exit(1);
164
+ }
165
+ const invokedDirectly = argv[1] !== undefined && import.meta.url.endsWith(argv[1].replace(/\\/g, "/"));
166
+ if (invokedDirectly || argv[1]?.endsWith("check-reconcile-parity.mjs")) {
167
+ main();
168
+ }
@@ -147,6 +147,8 @@ async function writePluginBundle() {
147
147
  copyFileTo(join(REPO_ROOT, "bin", "mcp-server.mjs"), join(PLUGIN_BUNDLE_DIR, "bin", "mcp-server.mjs")),
148
148
  copyFileTo(join(REPO_ROOT, "bin", "observe.mjs"), join(PLUGIN_BUNDLE_DIR, "bin", "observe.mjs")),
149
149
  copyFileTo(join(REPO_ROOT, "bin", "backfill.mjs"), join(PLUGIN_BUNDLE_DIR, "bin", "backfill.mjs")),
150
+ copyFileTo(join(REPO_ROOT, "bin", "reconcile.mjs"), join(PLUGIN_BUNDLE_DIR, "bin", "reconcile.mjs")),
151
+ copyFileTo(join(REPO_ROOT, "lib", "git-state.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "git-state.mjs")),
150
152
  copyFileTo(join(REPO_ROOT, "bin", "gateguard-clear.mjs"), join(PLUGIN_BUNDLE_DIR, "bin", "gateguard-clear.mjs")),
151
153
  copyFileTo(join(REPO_ROOT, "lib", "gateguard-state.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "gateguard-state.mjs")),
152
154
  copyFileTo(join(REPO_ROOT, "lib", "plugin-metadata.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "plugin-metadata.mjs")),
@@ -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: