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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +13 -0
- package/bin/reconcile.mjs +38 -14
- package/commands/reconcile.md +3 -0
- package/lib/git-state.mjs +24 -5
- package/package.json +1 -1
- 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 +38 -14
- package/plugins/continuous-improvement/commands/reconcile.md +3 -0
- package/plugins/continuous-improvement/lib/git-state.mjs +24 -5
- package/plugins/continuous-improvement/skills/reconcile/SKILL.md +6 -2
- package/plugins/expert.json +1 -1
- package/skills/reconcile.md +6 -2
|
@@ -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.
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
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
|
|
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
|
package/commands/reconcile.md
CHANGED
|
@@ -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 `-`,
|
|
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,
|
|
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.
|
|
179
|
+
if (!Number.isSafeInteger(behind) || behind < 0)
|
|
172
180
|
return null;
|
|
173
|
-
if (!Number.
|
|
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.
|
|
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",
|
package/plugins/beginner.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "continuous-improvement",
|
|
3
|
-
"version": "3.22.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
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
|
|
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 `-`,
|
|
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,
|
|
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.
|
|
179
|
+
if (!Number.isSafeInteger(behind) || behind < 0)
|
|
172
180
|
return null;
|
|
173
|
-
if (!Number.
|
|
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 `
|
|
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.
|
package/plugins/expert.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "continuous-improvement",
|
|
3
|
-
"version": "3.22.
|
|
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": [
|
package/skills/reconcile.md
CHANGED
|
@@ -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 `
|
|
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.
|