continuous-improvement 3.22.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.22.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,19 @@ 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
+
7
20
  ## [3.22.0] — 2026-08-02
8
21
 
9
22
  ### Added
package/bin/reconcile.mjs CHANGED
@@ -54,6 +54,7 @@ function parseArgs(args) {
54
54
  verifyPush: null,
55
55
  root: cwd(),
56
56
  };
57
+ let cwdProvided = false;
57
58
  for (let i = 0; i < args.length; i++) {
58
59
  const arg = args[i] ?? "";
59
60
  if (arg === "--json")
@@ -63,6 +64,9 @@ function parseArgs(args) {
63
64
  else if (arg === "--explain")
64
65
  options.explain = true;
65
66
  else if (arg === "--verify-push") {
67
+ if (options.verifyPush !== null) {
68
+ throw new Error("--verify-push may only be provided once");
69
+ }
66
70
  const value = args[i + 1];
67
71
  if (value === undefined || value.startsWith("--")) {
68
72
  throw new Error("--verify-push requires a branch name");
@@ -74,11 +78,15 @@ function parseArgs(args) {
74
78
  i++;
75
79
  }
76
80
  else if (arg === "--cwd") {
81
+ if (cwdProvided) {
82
+ throw new Error("--cwd may only be provided once");
83
+ }
77
84
  const value = args[i + 1];
78
- if (value === undefined || value.startsWith("--")) {
85
+ if (value === undefined || value.startsWith("--") || value.trim().length === 0) {
79
86
  throw new Error("--cwd requires a directory");
80
87
  }
81
88
  options.root = value;
89
+ cwdProvided = true;
82
90
  i++;
83
91
  }
84
92
  else if (arg === "--help" || arg === "-h") {
@@ -88,6 +96,14 @@ function parseArgs(args) {
88
96
  throw new Error(`unknown argument: ${arg}`);
89
97
  }
90
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
+ }
91
107
  return options;
92
108
  }
93
109
  /** Probe each in-progress marker through `--git-path`, which is worktree-correct. */
@@ -160,20 +176,35 @@ function main() {
160
176
  const status = git(["status", "--porcelain=v1"], options.root);
161
177
  const drift = git(["diff", "--name-only", "--ignore-all-space"], options.root);
162
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
+ });
163
188
  if (options.snapshot) {
164
- // Field-compatible with scripts/git-state-snapshot.sh, plus `contentDrift`
165
- // and `inProgress`, which the shell version cannot report.
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.
166
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);
167
196
  stdout.write(`${JSON.stringify({
168
- head: git(["rev-parse", "--short", "HEAD"], options.root).stdout.trim(),
197
+ head: shortHead.length > 0 ? shortHead : "unborn",
169
198
  upstream: upstreamSha,
170
199
  dirty: dirty.reported,
171
200
  root: repoRoot,
172
201
  branch: head.branch ?? "detached",
173
202
  contentDrift: dirty.contentDrift,
174
- inProgress: probeInProgress(options.root),
203
+ inProgress,
204
+ blocked: blockers.length > 0,
205
+ blockers,
175
206
  })}\n`);
176
- exit(0);
207
+ exit(blockers.length > 0 ? 1 : 0);
177
208
  return;
178
209
  }
179
210
  if (options.verifyPush !== null) {
@@ -191,14 +222,7 @@ function main() {
191
222
  exit(verdict.verdict === "landed" ? 0 : 1);
192
223
  return;
193
224
  }
194
- const inProgress = probeInProgress(options.root);
195
- const findings = assessGitState({
196
- head,
197
- upstreamRef,
198
- counts,
199
- inProgress,
200
- dirty,
201
- });
225
+ const findings = [...baseFindings];
202
226
  const stashes = git(["stash", "list"], options.root);
203
227
  const stashCount = stashes.code === 0
204
228
  ? stashes.stdout.split(/\r?\n/).filter((line) => line.trim().length > 0).length
@@ -42,6 +42,9 @@ Four boundaries where the obvious command lies:
42
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
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
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`.
45
48
 
46
49
  ## Then act, with gates
47
50
 
package/lib/git-state.mjs CHANGED
@@ -112,7 +112,8 @@ function isSha(value) {
112
112
  * comparison that authorizes a mutation.
113
113
  *
114
114
  * Fails closed: null, empty, surrounding whitespace, shell metacharacters,
115
- * `..`, refspec syntax (`@{`), a leading `-`, a trailing `/` or `.lock`, and
115
+ * `..`, refspec syntax (`@{`), a leading `-`, empty path components,
116
+ * dot-prefixed or dot-suffixed components, components ending `.lock`, and
116
117
  * anything over 255 characters all return false rather than being sanitized
117
118
  * into something that then looks valid.
118
119
  */
@@ -129,6 +130,13 @@ export function isSafeRefName(name) {
129
130
  return false;
130
131
  if (name.includes("..") || name.includes("@{"))
131
132
  return false;
133
+ const components = name.split("/");
134
+ if (components.some((component) => component.length === 0 ||
135
+ component.startsWith(".") ||
136
+ component.endsWith(".") ||
137
+ component.endsWith(".lock"))) {
138
+ return false;
139
+ }
132
140
  return SAFE_REF.test(name);
133
141
  }
134
142
  /**
@@ -154,8 +162,8 @@ export function classifyHead(rawBranch, exitCode = 0) {
154
162
  * Parse `git rev-list --left-right --count @{u}...HEAD` output, which is
155
163
  * `"<behind>\t<ahead>"`.
156
164
  *
157
- * Returns null for empty, malformed, negative, or non-integer output. Callers
158
- * must treat null as "unknown" — never as zero.
165
+ * Returns null for empty, malformed, negative, non-integer, or unsafe-integer
166
+ * output. Callers must treat null as "unknown" — never as zero.
159
167
  */
160
168
  export function parseRevListCounts(raw) {
161
169
  if (typeof raw !== "string")
@@ -168,9 +176,9 @@ export function parseRevListCounts(raw) {
168
176
  return null;
169
177
  const behind = Number(parts[0]);
170
178
  const ahead = Number(parts[1]);
171
- if (!Number.isInteger(behind) || behind < 0)
179
+ if (!Number.isSafeInteger(behind) || behind < 0)
172
180
  return null;
173
- if (!Number.isInteger(ahead) || ahead < 0)
181
+ if (!Number.isSafeInteger(ahead) || ahead < 0)
174
182
  return null;
175
183
  return { behind, ahead };
176
184
  }
@@ -312,6 +320,17 @@ export function baselineShifted(before, after) {
312
320
  export function assessGitState(input) {
313
321
  const findings = [];
314
322
  const protectedList = input.protectedBranches ?? DEFAULT_PROTECTED_BRANCHES;
323
+ if (Object.hasOwn(input, "headCommit")) {
324
+ const commit = (input.headCommit ?? "").trim();
325
+ findings.push({
326
+ id: "head-commit",
327
+ label: "HEAD commit",
328
+ detail: isSha(commit)
329
+ ? commit.slice(0, 12)
330
+ : "unborn or not a usable commit — create or verify the first commit before mutating",
331
+ severity: isSha(commit) ? "ok" : "blocker",
332
+ });
333
+ }
315
334
  if (input.head.kind === "branch") {
316
335
  const onProtected = isProtectedBranch(input.head.branch, protectedList);
317
336
  findings.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "continuous-improvement",
3
- "version": "3.22.0",
3
+ "version": "3.22.1",
4
4
  "description": "Claude Code that gets sharper every session: the persistent-memory and runtime-discipline layer built on the 7 Laws of AI Agent Discipline. It 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. 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. Beginner: one /plugin install command. Expert: adds MCP tools and session hooks.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "continuous-improvement",
3
- "version": "3.22.0",
3
+ "version": "3.22.1",
4
4
  "mode": "beginner",
5
5
  "description": "Beginner mode: see what your agent learned, list its instincts, and request a session reflection. Bundles three grounding skills (gateguard, tdd-workflow, verification-loop) so research, memory, tests, and verification happen by default — every edit starts from facts, not guesses.",
6
6
  "tools": [
@@ -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.22.0",
11
+ "version": "3.22.1",
12
12
  "source": "./",
13
13
  "author": {
14
14
  "name": "naimkatiman"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "continuous-improvement",
3
- "version": "3.22.0",
3
+ "version": "3.22.1",
4
4
  "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.",
5
5
  "author": {
6
6
  "name": "naimkatiman",
@@ -54,6 +54,7 @@ function parseArgs(args) {
54
54
  verifyPush: null,
55
55
  root: cwd(),
56
56
  };
57
+ let cwdProvided = false;
57
58
  for (let i = 0; i < args.length; i++) {
58
59
  const arg = args[i] ?? "";
59
60
  if (arg === "--json")
@@ -63,6 +64,9 @@ function parseArgs(args) {
63
64
  else if (arg === "--explain")
64
65
  options.explain = true;
65
66
  else if (arg === "--verify-push") {
67
+ if (options.verifyPush !== null) {
68
+ throw new Error("--verify-push may only be provided once");
69
+ }
66
70
  const value = args[i + 1];
67
71
  if (value === undefined || value.startsWith("--")) {
68
72
  throw new Error("--verify-push requires a branch name");
@@ -74,11 +78,15 @@ function parseArgs(args) {
74
78
  i++;
75
79
  }
76
80
  else if (arg === "--cwd") {
81
+ if (cwdProvided) {
82
+ throw new Error("--cwd may only be provided once");
83
+ }
77
84
  const value = args[i + 1];
78
- if (value === undefined || value.startsWith("--")) {
85
+ if (value === undefined || value.startsWith("--") || value.trim().length === 0) {
79
86
  throw new Error("--cwd requires a directory");
80
87
  }
81
88
  options.root = value;
89
+ cwdProvided = true;
82
90
  i++;
83
91
  }
84
92
  else if (arg === "--help" || arg === "-h") {
@@ -88,6 +96,14 @@ function parseArgs(args) {
88
96
  throw new Error(`unknown argument: ${arg}`);
89
97
  }
90
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
+ }
91
107
  return options;
92
108
  }
93
109
  /** Probe each in-progress marker through `--git-path`, which is worktree-correct. */
@@ -160,20 +176,35 @@ function main() {
160
176
  const status = git(["status", "--porcelain=v1"], options.root);
161
177
  const drift = git(["diff", "--name-only", "--ignore-all-space"], options.root);
162
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
+ });
163
188
  if (options.snapshot) {
164
- // Field-compatible with scripts/git-state-snapshot.sh, plus `contentDrift`
165
- // and `inProgress`, which the shell version cannot report.
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.
166
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);
167
196
  stdout.write(`${JSON.stringify({
168
- head: git(["rev-parse", "--short", "HEAD"], options.root).stdout.trim(),
197
+ head: shortHead.length > 0 ? shortHead : "unborn",
169
198
  upstream: upstreamSha,
170
199
  dirty: dirty.reported,
171
200
  root: repoRoot,
172
201
  branch: head.branch ?? "detached",
173
202
  contentDrift: dirty.contentDrift,
174
- inProgress: probeInProgress(options.root),
203
+ inProgress,
204
+ blocked: blockers.length > 0,
205
+ blockers,
175
206
  })}\n`);
176
- exit(0);
207
+ exit(blockers.length > 0 ? 1 : 0);
177
208
  return;
178
209
  }
179
210
  if (options.verifyPush !== null) {
@@ -191,14 +222,7 @@ function main() {
191
222
  exit(verdict.verdict === "landed" ? 0 : 1);
192
223
  return;
193
224
  }
194
- const inProgress = probeInProgress(options.root);
195
- const findings = assessGitState({
196
- head,
197
- upstreamRef,
198
- counts,
199
- inProgress,
200
- dirty,
201
- });
225
+ const findings = [...baseFindings];
202
226
  const stashes = git(["stash", "list"], options.root);
203
227
  const stashCount = stashes.code === 0
204
228
  ? stashes.stdout.split(/\r?\n/).filter((line) => line.trim().length > 0).length
@@ -42,6 +42,9 @@ Four boundaries where the obvious command lies:
42
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
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
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`.
45
48
 
46
49
  ## Then act, with gates
47
50
 
@@ -112,7 +112,8 @@ function isSha(value) {
112
112
  * comparison that authorizes a mutation.
113
113
  *
114
114
  * Fails closed: null, empty, surrounding whitespace, shell metacharacters,
115
- * `..`, refspec syntax (`@{`), a leading `-`, a trailing `/` or `.lock`, and
115
+ * `..`, refspec syntax (`@{`), a leading `-`, empty path components,
116
+ * dot-prefixed or dot-suffixed components, components ending `.lock`, and
116
117
  * anything over 255 characters all return false rather than being sanitized
117
118
  * into something that then looks valid.
118
119
  */
@@ -129,6 +130,13 @@ export function isSafeRefName(name) {
129
130
  return false;
130
131
  if (name.includes("..") || name.includes("@{"))
131
132
  return false;
133
+ const components = name.split("/");
134
+ if (components.some((component) => component.length === 0 ||
135
+ component.startsWith(".") ||
136
+ component.endsWith(".") ||
137
+ component.endsWith(".lock"))) {
138
+ return false;
139
+ }
132
140
  return SAFE_REF.test(name);
133
141
  }
134
142
  /**
@@ -154,8 +162,8 @@ export function classifyHead(rawBranch, exitCode = 0) {
154
162
  * Parse `git rev-list --left-right --count @{u}...HEAD` output, which is
155
163
  * `"<behind>\t<ahead>"`.
156
164
  *
157
- * Returns null for empty, malformed, negative, or non-integer output. Callers
158
- * must treat null as "unknown" — never as zero.
165
+ * Returns null for empty, malformed, negative, non-integer, or unsafe-integer
166
+ * output. Callers must treat null as "unknown" — never as zero.
159
167
  */
160
168
  export function parseRevListCounts(raw) {
161
169
  if (typeof raw !== "string")
@@ -168,9 +176,9 @@ export function parseRevListCounts(raw) {
168
176
  return null;
169
177
  const behind = Number(parts[0]);
170
178
  const ahead = Number(parts[1]);
171
- if (!Number.isInteger(behind) || behind < 0)
179
+ if (!Number.isSafeInteger(behind) || behind < 0)
172
180
  return null;
173
- if (!Number.isInteger(ahead) || ahead < 0)
181
+ if (!Number.isSafeInteger(ahead) || ahead < 0)
174
182
  return null;
175
183
  return { behind, ahead };
176
184
  }
@@ -312,6 +320,17 @@ export function baselineShifted(before, after) {
312
320
  export function assessGitState(input) {
313
321
  const findings = [];
314
322
  const protectedList = input.protectedBranches ?? DEFAULT_PROTECTED_BRANCHES;
323
+ if (Object.hasOwn(input, "headCommit")) {
324
+ const commit = (input.headCommit ?? "").trim();
325
+ findings.push({
326
+ id: "head-commit",
327
+ label: "HEAD commit",
328
+ detail: isSha(commit)
329
+ ? commit.slice(0, 12)
330
+ : "unborn or not a usable commit — create or verify the first commit before mutating",
331
+ severity: isSha(commit) ? "ok" : "blocker",
332
+ });
333
+ }
315
334
  if (input.head.kind === "branch") {
316
335
  const onProtected = isProtectedBranch(input.head.branch, protectedList);
317
336
  findings.push({
@@ -62,7 +62,11 @@ The ground-truth pass has to work wherever the agent runs, not only in Bash. `ci
62
62
  | `scripts/git-state-snapshot.sh` | needs Git Bash | yes | yes | root/branch only | reports `detached` | reports `none` |
63
63
  | Hand-run probe list above | yes | yes | yes | correct | non-zero exit | non-zero exit |
64
64
 
65
- Smoke-test on the OS you actually ship on. Both surfaces emit the same `{head, upstream, dirty, root, branch}` envelope — `ci-reconcile --snapshot` adds `contentDrift` and `inProgress` — and a test pins that parity so the two cannot drift apart silently.
65
+ Smoke-test on the OS you actually ship on. Both surfaces emit the same `{head, upstream, dirty, root, branch}` envelope — `ci-reconcile --snapshot` adds `contentDrift`, `inProgress`, `blocked` and `blockers` — and a test pins that parity so the two cannot drift apart silently.
66
+
67
+ `--snapshot` follows the same exit-code contract as the default mode: `0` clear, `1` at least one blocker, `2` not a git repository. It still writes the envelope on a blocker, so a script that wants the JSON regardless must tolerate exit 1 (`set -e` will otherwise abort on a detached HEAD or an unborn repo).
68
+
69
+ An **unborn HEAD** — `git init` with no commit yet — is a real git repository with no usable baseline. `git rev-parse HEAD` fails there, so the shell script reports `not-a-git-repo` and the envelope's `head` field would otherwise be empty in a way that reads like success. `ci-reconcile` reports `head: "unborn"` with `blocked: true` and blocks the mutation instead.
66
70
 
67
71
  ## Detect a Concurrent Writer
68
72
 
@@ -75,7 +79,7 @@ When another session/loop may be active, do not assume the tree is yours:
75
79
  # ... do work ...
76
80
  npx ci-reconcile --snapshot # compare head + branch against the baseline
77
81
  ```
78
- If either field moved, another writer got there first — re-survey from the top instead of committing onto an unexpected base. A missing or unparseable field counts as *shifted*; "we could not tell" is never "nothing moved".
82
+ If either field moved, another writer got there first — re-survey from the top instead of committing onto an unexpected base. A missing or unparseable field counts as *shifted*; "we could not tell" is never "nothing moved". Both calls exit 1 if the tree is blocked, so capture the baseline without `set -e` (or guard it) when a blocker is expected.
79
83
  - More than one entry in `git worktree list --porcelain` means a sibling checkout exists that another session may be writing to. The runner flags this.
80
84
  - If the git index keeps changing while you are idle, a writer is active. Pause and surface it rather than racing.
81
85
  - If `gateguard` is installed, its Parallel-Actor Gate already captured this baseline on the session's first mutation by running `bash "${CLAUDE_PLUGIN_ROOT}/scripts/git-state-snapshot.sh"` (source: `scripts/git-state-snapshot.sh`) and divergence-checks every later mutation — `reconcile` complements that gate, it does not replace it. That shell snapshot needs Git Bash and derives its `dirty` count from `git status`, which overstates drift on an `autocrlf` tree; `ci-reconcile --snapshot` is the same envelope without either limitation. Without gateguard, run one of them yourself.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "continuous-improvement",
3
- "version": "3.22.0",
3
+ "version": "3.22.1",
4
4
  "mode": "expert",
5
5
  "description": "Expert mode: tune confidence, manage instincts, and persist plans on disk. Adds safety, token-budget, and strategic-compact skills plus the /learn-eval command so long sessions stay sharp and learnings survive context resets.",
6
6
  "tools": [
@@ -62,7 +62,11 @@ The ground-truth pass has to work wherever the agent runs, not only in Bash. `ci
62
62
  | `scripts/git-state-snapshot.sh` | needs Git Bash | yes | yes | root/branch only | reports `detached` | reports `none` |
63
63
  | Hand-run probe list above | yes | yes | yes | correct | non-zero exit | non-zero exit |
64
64
 
65
- Smoke-test on the OS you actually ship on. Both surfaces emit the same `{head, upstream, dirty, root, branch}` envelope — `ci-reconcile --snapshot` adds `contentDrift` and `inProgress` — and a test pins that parity so the two cannot drift apart silently.
65
+ Smoke-test on the OS you actually ship on. Both surfaces emit the same `{head, upstream, dirty, root, branch}` envelope — `ci-reconcile --snapshot` adds `contentDrift`, `inProgress`, `blocked` and `blockers` — and a test pins that parity so the two cannot drift apart silently.
66
+
67
+ `--snapshot` follows the same exit-code contract as the default mode: `0` clear, `1` at least one blocker, `2` not a git repository. It still writes the envelope on a blocker, so a script that wants the JSON regardless must tolerate exit 1 (`set -e` will otherwise abort on a detached HEAD or an unborn repo).
68
+
69
+ An **unborn HEAD** — `git init` with no commit yet — is a real git repository with no usable baseline. `git rev-parse HEAD` fails there, so the shell script reports `not-a-git-repo` and the envelope's `head` field would otherwise be empty in a way that reads like success. `ci-reconcile` reports `head: "unborn"` with `blocked: true` and blocks the mutation instead.
66
70
 
67
71
  ## Detect a Concurrent Writer
68
72
 
@@ -75,7 +79,7 @@ When another session/loop may be active, do not assume the tree is yours:
75
79
  # ... do work ...
76
80
  npx ci-reconcile --snapshot # compare head + branch against the baseline
77
81
  ```
78
- If either field moved, another writer got there first — re-survey from the top instead of committing onto an unexpected base. A missing or unparseable field counts as *shifted*; "we could not tell" is never "nothing moved".
82
+ If either field moved, another writer got there first — re-survey from the top instead of committing onto an unexpected base. A missing or unparseable field counts as *shifted*; "we could not tell" is never "nothing moved". Both calls exit 1 if the tree is blocked, so capture the baseline without `set -e` (or guard it) when a blocker is expected.
79
83
  - More than one entry in `git worktree list --porcelain` means a sibling checkout exists that another session may be writing to. The runner flags this.
80
84
  - If the git index keeps changing while you are idle, a writer is active. Pause and surface it rather than racing.
81
85
  - If `gateguard` is installed, its Parallel-Actor Gate already captured this baseline on the session's first mutation by running `bash "${CLAUDE_PLUGIN_ROOT}/scripts/git-state-snapshot.sh"` (source: `scripts/git-state-snapshot.sh`) and divergence-checks every later mutation — `reconcile` complements that gate, it does not replace it. That shell snapshot needs Git Bash and derives its `dirty` count from `git status`, which overstates drift on an `autocrlf` tree; `ci-reconcile --snapshot` is the same envelope without either limitation. Without gateguard, run one of them yourself.