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.
@@ -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.1",
12
12
  "source": "./plugins/continuous-improvement",
13
13
  "author": {
14
14
  "name": "naimkatiman"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ All notable changes to this skill are documented here.
4
4
 
5
5
  ---
6
6
 
7
+ ## [3.22.1] — 2026-08-04
8
+
9
+ ### Fixed
10
+
11
+ - **`ci-reconcile` blocks an unborn HEAD instead of treating it as a usable baseline** — `git init` with no commit yet is a real git repository, but `git rev-parse HEAD` fails there. The runner accepted that as a pinnable state, and `--snapshot` emitted an empty `head` field that reads like success. Now reported as a `head-commit` blocker, with `--snapshot` emitting `head: "unborn"` and `blocked: true`. (#291)
12
+ - **`isSafeRefName` rejects ref shapes git itself rejects** — empty path components (`a//b`), dot-prefixed or dot-suffixed components (`a/.b`, `a/b.`), and a per-component `.lock` suffix (`a/b.lock/c`) previously passed. Validation is now component-by-component, closer to `git check-ref-format`. (#291)
13
+ - **`parseRevListCounts` uses `Number.isSafeInteger`** — `Number.isInteger` accepts values beyond `MAX_SAFE_INTEGER`, where arithmetic silently loses precision. Out-of-range counts now read as `unknown`, consistent with the module's fail-closed stance. (#291)
14
+ - **The CLI rejects malformed argument combinations instead of resolving them silently** — a repeated `--verify-push` or `--cwd` used to be last-wins, an empty or whitespace-only `--cwd` fell back to the process working directory, and `--snapshot` / `--explain` / `--verify-push` together picked one arbitrarily. Each is now an explicit error. (#291)
15
+
16
+ ### Changed
17
+
18
+ - **`--snapshot` follows the same exit contract as the default mode** (`0` clear / `1` blocked / `2` not a repository) where it previously always exited `0`, and its envelope gains `blocked` and `blockers`. It still writes the JSON on a blocker, so a `set -e` script capturing a baseline must tolerate exit 1. Documented in the skill and command, along with the unborn-HEAD row. (#291)
19
+
20
+ ## [3.22.0] — 2026-08-02
21
+
22
+ ### Added
23
+
24
+ - **`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)
25
+ - **`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)
26
+
27
+ ### Fixed
28
+
29
+ - **`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)
30
+ - **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)
31
+ - 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)
32
+
7
33
  ## [3.21.0] — 2026-07-11
8
34
 
9
35
  ### 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,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: