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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +26 -0
- package/bin/check-reconcile-parity.mjs +168 -0
- package/bin/generate-plugin-manifests.mjs +2 -0
- package/bin/reconcile.mjs +283 -0
- package/commands/reconcile.md +33 -6
- package/lib/git-state.mjs +430 -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 +283 -0
- package/plugins/continuous-improvement/commands/reconcile.md +33 -6
- package/plugins/continuous-improvement/lib/git-state.mjs +430 -0
- package/plugins/continuous-improvement/skills/reconcile/SKILL.md +66 -12
- package/plugins/expert.json +1 -1
- package/skills/reconcile.md +66 -12
|
@@ -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.
|
|
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
|
+
}
|
package/commands/reconcile.md
CHANGED
|
@@ -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
|
|
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
|
+
- **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
|
-
|
|
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:
|