continuous-improvement 3.17.0 → 3.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * portfolio-health — v0 portfolio repo scorer from LOCAL static signals.
4
+ *
5
+ * Portfolio-spine train (docs/plans/2026-07-04-portfolio-spine.md).
6
+ *
7
+ * Reads the machine-readable registry at portfolio/repos.json and scores each
8
+ * repo 0-100 from local, offline signals only (no network):
9
+ *
10
+ * - CI configured (`.github/workflows` with at least one yml/yaml)
11
+ * - Release receipts (`.releases/*.md` or `docs/releases/*.md`, templates excluded)
12
+ * - Experiment records (`.experiments/*.md` or `docs/experiments/*.md`, templates excluded)
13
+ * - Commit freshness (`git log -1 --format=%ct` via child_process, guarded)
14
+ * - High-severity findings from auditWorkflows (imported pure function)
15
+ *
16
+ * Repos without a local checkout score "n/a (no local checkout)" — never a crash.
17
+ * The weighted rubric lives in the named WEIGHT_* constants below and is
18
+ * mirrored in the generated report's appendix.
19
+ *
20
+ * Usage:
21
+ * node bin/portfolio-health.mjs [--config <path>] [--out <file>]
22
+ * --config <path> registry path (default "portfolio/repos.json")
23
+ * --out <file> markdown report path (default "reports/portfolio-health.md")
24
+ * --help print usage
25
+ *
26
+ * Exit codes: 0 report written; 1 config/registry error (missing or malformed
27
+ * --config file reports a one-line `portfolio-health: ...` error, no stack).
28
+ */
29
+ import { execFileSync } from "node:child_process";
30
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
31
+ import { dirname, join } from "node:path";
32
+ import { argv, exit } from "node:process";
33
+ import { auditWorkflows } from "./audit-actions.mjs";
34
+ // Scoring rubric (documented in the report appendix; weights sum to 100).
35
+ export const WEIGHT_CI = 25;
36
+ export const WEIGHT_RECEIPTS = 20;
37
+ export const WEIGHT_EXPERIMENTS = 15;
38
+ export const WEIGHT_FRESHNESS = 20;
39
+ export const WEIGHT_SECURITY = 20;
40
+ export const HIGH_FINDING_PENALTY = 5; // security points lost per high finding
41
+ export const RECEIPT_FULL_CREDIT = 3; // receipts needed for full receipt credit
42
+ export const EXPERIMENT_FULL_CREDIT = 2; // experiment records needed for full credit
43
+ export const FRESH_DAYS = 7; // full freshness credit at or under this
44
+ export const STALE_DAYS = 30; // half credit at or under this; zero beyond
45
+ const TEMPLATE_NAME = /template/i;
46
+ const RECEIPT_DIRS = [".releases", join("docs", "releases")];
47
+ const EXPERIMENT_DIRS = [".experiments", join("docs", "experiments")];
48
+ function countProofFiles(localPath, dirs) {
49
+ let count = 0;
50
+ for (const dir of dirs) {
51
+ const full = join(localPath, dir);
52
+ if (!existsSync(full))
53
+ continue;
54
+ for (const entry of readdirSync(full)) {
55
+ if (entry.endsWith(".md") && !TEMPLATE_NAME.test(entry))
56
+ count++;
57
+ }
58
+ }
59
+ return count;
60
+ }
61
+ function readWorkflowFiles(localPath) {
62
+ const dir = join(localPath, ".github", "workflows");
63
+ if (!existsSync(dir))
64
+ return [];
65
+ const files = [];
66
+ for (const entry of readdirSync(dir).sort()) {
67
+ if (!entry.endsWith(".yml") && !entry.endsWith(".yaml"))
68
+ continue;
69
+ try {
70
+ files.push({ path: `.github/workflows/${entry}`, content: readFileSync(join(dir, entry), "utf8") });
71
+ }
72
+ catch {
73
+ // Unreadable workflow file: skip rather than crash the whole report.
74
+ }
75
+ }
76
+ return files;
77
+ }
78
+ /** Last-commit age in whole days via `git log -1 --format=%ct`. Any failure → null. */
79
+ function daysSinceLastCommit(localPath, nowMs) {
80
+ try {
81
+ const out = execFileSync("git", ["-C", localPath, "log", "-1", "--format=%ct"], {
82
+ stdio: ["ignore", "pipe", "ignore"],
83
+ timeout: 10000,
84
+ })
85
+ .toString()
86
+ .trim();
87
+ const epochSeconds = Number.parseInt(out, 10);
88
+ if (!Number.isFinite(epochSeconds) || epochSeconds <= 0)
89
+ return null;
90
+ return Math.max(0, Math.floor((nowMs - epochSeconds * 1000) / 86400000));
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ /** Gather all local static signals for one checked-out repo. */
97
+ export function collectSignals(localPath, opts = {}) {
98
+ const workflows = readWorkflowFiles(localPath);
99
+ const findings = auditWorkflows(workflows);
100
+ return {
101
+ hasCI: workflows.length > 0,
102
+ receiptCount: countProofFiles(localPath, RECEIPT_DIRS),
103
+ experimentCount: countProofFiles(localPath, EXPERIMENT_DIRS),
104
+ daysSinceLastCommit: daysSinceLastCommit(localPath, opts.nowMs ?? Date.now()),
105
+ highFindings: findings.filter((f) => f.severity === "high").length,
106
+ };
107
+ }
108
+ function freshnessPoints(days) {
109
+ if (days === null)
110
+ return 0;
111
+ if (days <= FRESH_DAYS)
112
+ return WEIGHT_FRESHNESS;
113
+ if (days <= STALE_DAYS)
114
+ return WEIGHT_FRESHNESS / 2;
115
+ return 0;
116
+ }
117
+ /**
118
+ * Weighted 0-100 score plus the largest-gap risk and its next action.
119
+ * Security credit requires CI: with no workflows there is nothing audited,
120
+ * so a repo cannot earn security points by having no CI at all.
121
+ */
122
+ export function scoreRepo(s) {
123
+ const ci = s.hasCI ? WEIGHT_CI : 0;
124
+ const receipts = Math.round((WEIGHT_RECEIPTS * Math.min(s.receiptCount, RECEIPT_FULL_CREDIT)) / RECEIPT_FULL_CREDIT);
125
+ const experiments = Math.round((WEIGHT_EXPERIMENTS * Math.min(s.experimentCount, EXPERIMENT_FULL_CREDIT)) / EXPERIMENT_FULL_CREDIT);
126
+ const freshness = freshnessPoints(s.daysSinceLastCommit);
127
+ const security = s.hasCI ? Math.max(0, WEIGHT_SECURITY - HIGH_FINDING_PENALTY * s.highFindings) : 0;
128
+ const score = ci + receipts + experiments + freshness + security;
129
+ // Largest points gap wins; ties resolve in this fixed order (worst first).
130
+ const gaps = [
131
+ {
132
+ lost: WEIGHT_SECURITY - security,
133
+ risk: s.hasCI
134
+ ? `${s.highFindings} high-severity Actions finding(s)`
135
+ : "No CI, so no security signal exists",
136
+ action: s.hasCI
137
+ ? "Run ci-audit-actions --strict and fix the high findings"
138
+ : "Add CI first, then run ci-audit-actions",
139
+ },
140
+ {
141
+ lost: WEIGHT_CI - ci,
142
+ risk: "No CI configured (.github/workflows is empty or missing)",
143
+ action: "Add a CI workflow that runs the test suite on push",
144
+ },
145
+ {
146
+ lost: WEIGHT_FRESHNESS - freshness,
147
+ risk: s.daysSinceLastCommit === null
148
+ ? "Last-commit date unknown (git unavailable or no history)"
149
+ : `No commits in ${s.daysSinceLastCommit} day(s)`,
150
+ action: "Land one small maintenance commit or archive the repo deliberately",
151
+ },
152
+ {
153
+ lost: WEIGHT_RECEIPTS - receipts,
154
+ risk: `Only ${s.receiptCount} release receipt(s) on record`,
155
+ action: "Write a .releases/ receipt for the next ship (template in continuous-improvement)",
156
+ },
157
+ {
158
+ lost: WEIGHT_EXPERIMENTS - experiments,
159
+ risk: `Only ${s.experimentCount} experiment record(s) on record`,
160
+ action: "Record the next hypothesis in .experiments/ before building",
161
+ },
162
+ ];
163
+ gaps.sort((a, b) => b.lost - a.lost);
164
+ const top = gaps[0];
165
+ if (top.lost === 0) {
166
+ return { score, topRisk: "none", nextAction: "Keep the receipts flowing" };
167
+ }
168
+ return { score, topRisk: top.risk, nextAction: top.action };
169
+ }
170
+ /** Registry loader with explicit validation — fail fast on malformed config. */
171
+ export function loadConfig(configPath) {
172
+ const raw = readFileSync(configPath, "utf8");
173
+ const data = JSON.parse(raw);
174
+ if (!Array.isArray(data)) {
175
+ throw new Error(`portfolio registry ${configPath} must be a JSON array of repo entries`);
176
+ }
177
+ const entries = [];
178
+ for (let i = 0; i < data.length; i++) {
179
+ const e = data[i];
180
+ const missing = [];
181
+ if (typeof e.repo !== "string" || e.repo === "")
182
+ missing.push("repo");
183
+ if (typeof e.lane !== "string" || e.lane === "")
184
+ missing.push("lane");
185
+ if (typeof e.localPath !== "string" || e.localPath === "")
186
+ missing.push("localPath");
187
+ if (typeof e.active !== "boolean")
188
+ missing.push("active");
189
+ if (missing.length > 0) {
190
+ throw new Error(`portfolio registry entry ${i} has missing/invalid field(s): ${missing.join(", ")}`);
191
+ }
192
+ entries.push({ repo: e.repo, lane: e.lane, localPath: e.localPath, active: e.active });
193
+ }
194
+ return entries;
195
+ }
196
+ /**
197
+ * One row per entry. Missing local checkouts degrade to an "n/a" row.
198
+ * Deterministic ordering: scored rows worst-first (score asc, then repo),
199
+ * then n/a rows alphabetically.
200
+ */
201
+ export function buildRows(entries, opts = {}) {
202
+ const rows = entries.map((entry) => {
203
+ if (!existsSync(entry.localPath)) {
204
+ return {
205
+ repo: entry.repo,
206
+ lane: entry.lane,
207
+ score: null,
208
+ scoreLabel: "n/a (no local checkout)",
209
+ topRisk: "No local checkout on this host",
210
+ nextAction: `Clone to ${entry.localPath} or set active:false in the registry`,
211
+ };
212
+ }
213
+ const signals = collectSignals(entry.localPath, opts);
214
+ const { score, topRisk, nextAction } = scoreRepo(signals);
215
+ return { repo: entry.repo, lane: entry.lane, score, scoreLabel: String(score), topRisk, nextAction };
216
+ });
217
+ return rows.sort((a, b) => {
218
+ if (a.score === null && b.score === null)
219
+ return a.repo.localeCompare(b.repo);
220
+ if (a.score === null)
221
+ return 1;
222
+ if (b.score === null)
223
+ return -1;
224
+ return a.score - b.score || a.repo.localeCompare(b.repo);
225
+ });
226
+ }
227
+ /** Markdown report: scored table (worst first) + how-scores-are-computed appendix. */
228
+ export function renderReport(rows, generatedAt) {
229
+ const out = [];
230
+ out.push("# Portfolio health report");
231
+ out.push("");
232
+ out.push(`Generated at: ${generatedAt}`);
233
+ out.push("");
234
+ out.push(`Scored ${rows.filter((r) => r.score !== null).length} repo(s) from local signals; ${rows.filter((r) => r.score === null).length} without a local checkout. Worst score first.`);
235
+ out.push("");
236
+ out.push("| Repo | Score | Top Risk | Next Action |");
237
+ out.push("|---|---|---|---|");
238
+ for (const r of rows) {
239
+ out.push(`| ${r.repo} | ${r.scoreLabel} | ${r.topRisk} | ${r.nextAction} |`);
240
+ }
241
+ out.push("");
242
+ out.push("## How scores are computed");
243
+ out.push("");
244
+ out.push("v0 scores use LOCAL static signals only — no network calls. Weights (sum 100):");
245
+ out.push("");
246
+ out.push("| Signal | Weight | Full credit |");
247
+ out.push("|---|---|---|");
248
+ out.push(`| CI configured (.github/workflows with >=1 yml/yaml) | ${WEIGHT_CI} | present |`);
249
+ out.push(`| Release receipts (.releases/ or docs/releases/, templates excluded) | ${WEIGHT_RECEIPTS} | ${RECEIPT_FULL_CREDIT}+ receipts |`);
250
+ out.push(`| Experiment records (.experiments/ or docs/experiments/, templates excluded) | ${WEIGHT_EXPERIMENTS} | ${EXPERIMENT_FULL_CREDIT}+ records |`);
251
+ out.push(`| Commit freshness (git log -1 --format=%ct, guarded) | ${WEIGHT_FRESHNESS} | <=${FRESH_DAYS} days; half <=${STALE_DAYS} days |`);
252
+ out.push(`| Actions security (high findings from audit-actions) | ${WEIGHT_SECURITY} | 0 high findings; -${HIGH_FINDING_PENALTY}/finding; requires CI |`);
253
+ out.push("");
254
+ out.push("Repos without a local checkout are reported as n/a rather than guessed at.");
255
+ out.push("");
256
+ return out.join("\n");
257
+ }
258
+ const USAGE = `Usage: node bin/portfolio-health.mjs [--config <path>] [--out <file>]
259
+
260
+ --config <path> registry path (default "portfolio/repos.json")
261
+ --out <file> markdown report path (default "reports/portfolio-health.md")
262
+ --help print this usage
263
+
264
+ Exit codes: 0 report written; 1 config/registry error.
265
+ `;
266
+ function main() {
267
+ const args = argv.slice(2);
268
+ if (args.includes("--help")) {
269
+ console.log(USAGE);
270
+ return 0;
271
+ }
272
+ const configIdx = args.indexOf("--config");
273
+ const outIdx = args.indexOf("--out");
274
+ const configPath = configIdx >= 0 ? (args[configIdx + 1] ?? "portfolio/repos.json") : "portfolio/repos.json";
275
+ const outPath = outIdx >= 0 ? (args[outIdx + 1] ?? "reports/portfolio-health.md") : "reports/portfolio-health.md";
276
+ // Explicit error handling at the CLI boundary: a missing or malformed
277
+ // --config file is the most common operator mistake and must produce a
278
+ // one-line error, not a raw stack trace (loadConfig throws deliberately).
279
+ try {
280
+ const entries = loadConfig(configPath).filter((e) => e.active);
281
+ const rows = buildRows(entries);
282
+ const report = renderReport(rows, new Date().toISOString());
283
+ mkdirSync(dirname(outPath), { recursive: true });
284
+ writeFileSync(outPath, report);
285
+ const scored = rows.filter((r) => r.score !== null);
286
+ console.log(`portfolio-health: ${entries.length} active repo(s), ${scored.length} scored, ${rows.length - scored.length} without local checkout.`);
287
+ console.log(`Report written to ${outPath}`);
288
+ return 0;
289
+ }
290
+ catch (err) {
291
+ console.error(`portfolio-health: ${err instanceof Error ? err.message : String(err)}`);
292
+ return 1;
293
+ }
294
+ }
295
+ const invokedDirectly = argv[1]?.endsWith("portfolio-health.mjs");
296
+ if (invokedDirectly) {
297
+ exit(main());
298
+ }
@@ -1,22 +1,22 @@
1
1
  ---
2
2
  name: reconcile
3
- description: Establish git ground truth (branch, status, stashes, worktrees, ahead/behind) before any mutation, halt on protected or destructive operations, and verify a push actually landed. Enforces Law 1 (Research Before Executing).
3
+ description: Establish git ground truth (branch, status, stashes, worktrees, ahead/behind) before any mutation, halt on protected or destructive operations, then carry the known-good state through a single-concern commit, a push, an open PR, and — after the PR merges — a fast-forward of the default branch. Enforces Law 1 (Research Before Executing).
4
4
  ---
5
5
 
6
- # /reconcile — Ground-Truth Git State Before You Touch It
6
+ # /reconcile — Ground Truth, Then Commit, Push, and Open the PR
7
7
 
8
- Read the repo's real state before acting on it: a branch that shifted, a push that did not land, or another session mid-merge will burn a whole session if you assume instead of check.
8
+ Read the repo's real state before acting on it: a branch that shifted, a push that did not land, or another session mid-merge will burn a whole session if you assume instead of check. Once the state is known, `/reconcile` carries the work through to an open PR and back to an up-to-date default branch.
9
9
 
10
10
  ## What it does
11
11
 
12
- Snapshots the full git state in one pass, detects a concurrent writer, classifies the upstream relationship, then acts only on the known state — stopping at every operation that is hard to reverse. Backed by the `reconcile` skill.
12
+ Snapshots the full git state in one pass, detects a concurrent writer, classifies the upstream relationship, then acts only on the known state — stopping at every operation that is hard to reverse. When work is ready, it stages by filename, commits one concern, pushes a feature branch, verifies the push landed, and opens a PR. After a human merges, it fast-forwards the default branch and checks it out. Backed by the `reconcile` skill.
13
13
 
14
14
  ## Establish ground truth
15
15
 
16
16
  ```
17
17
  git branch --show-current
18
18
  git status --porcelain=v1 # but trust git diff --stat for real drift (autocrlf)
19
- git rev-list --left-right --count @{u}...HEAD # behind / ahead
19
+ git rev-list --left-right --count '@{u}...HEAD' # behind / ahead (quote the ref — bare @{u} trips the Bash parser)
20
20
  git stash list
21
21
  git worktree list
22
22
  ls .git/MERGE_HEAD .git/rebase-merge .git/rebase-apply 2>/dev/null # in-progress op = another actor; do not race
@@ -31,7 +31,22 @@ behind -> git pull --ff-only
31
31
  diverged -> rebase/merge deliberately; never blind --force
32
32
  ```
33
33
 
34
- STOP for authorization before: pushing to a protected branch (this repo = feature branch + PR, never direct push to main), `--force` / `--force-with-lease`, `reset --hard`, `clean -fd`, or removing a dirty worktree. Never stage with `git add -A` on a Windows autocrlf tree (it commits phantom line-ending-only changes) — stage by explicit filename.
34
+ STOP for authorization before: pushing to a protected branch (this repo = feature branch + PR, never direct push to main), merging the PR you opened, `--force` / `--force-with-lease`, `reset --hard`, `clean -fd`, force-deleting a branch (`branch -D`), or removing a dirty worktree. Never stage with `git add -A` on a Windows autocrlf tree (it commits phantom line-ending-only changes) — stage by explicit filename.
35
+
36
+ ## Commit and open the PR (self-contained)
37
+
38
+ Reimplements the commit → push → PR tail inline, so it works with no companion plugin installed:
39
+
40
+ ```
41
+ git switch main && git pull --ff-only origin main # branch from a fresh base
42
+ git switch -c <type>/<slug> # only if not already on a feature branch
43
+ git add path/one path/two # stage by name, one concern
44
+ git commit -m "feat(scope): <observable outcome>" # single-line -m; never a multi-line here-doc on Windows
45
+ git push -u origin <type>/<slug>
46
+ gh pr create --fill --base main # open one PR, then STOP — the merge is a human decision
47
+ ```
48
+
49
+ `/reconcile` never merges the PR, never uses `--admin` / `--force` / `--no-verify`, never auto-merges on green CI, and never deploys.
35
50
 
36
51
  ## Verify the push landed
37
52
 
@@ -39,9 +54,21 @@ STOP for authorization before: pushing to a protected branch (this repo = featur
39
54
  git ls-remote origin refs/heads/<branch> # remote tip must equal local HEAD, else it did not land
40
55
  ```
41
56
 
57
+ ## Sync the default branch after the PR merges
58
+
59
+ "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:
60
+
61
+ ```
62
+ git switch main # or master
63
+ git pull --ff-only origin main # fast-forward only; if it will not ff, main diverged — re-survey, do not force
64
+ git rev-parse HEAD # confirm this equals the squash-merge SHA
65
+ git branch -d <type>/<slug> # delete the merged feature branch (safe -d, never -D)
66
+ ```
67
+
42
68
  ## Pairs with
43
69
 
44
70
  - **`reconcile`** skill — the discipline this command runs.
45
71
  - **`gateguard`** / **`safety-guard`** — runtime + destructive-op guardrails.
46
72
  - **`recall`** — recall whether the same git op failed here before.
47
- - **`audit`** — the loop that often produces the fix `reconcile` then ships.
73
+ - **`audit`** — the loop that often produces the fix `/reconcile` then ships.
74
+ - **`/ship`** — the TDD-gated single-defect variant; `commit-commands:commit-push-pr` is the external-plugin equivalent of the commit → PR tail.
@@ -35,7 +35,7 @@
35
35
  import { readFileSync } from "node:fs";
36
36
  import { dirname, join } from "node:path";
37
37
  import { fileURLToPath } from "node:url";
38
- import { MAX_CLEARED_FILES, canonicalizeFileKey, isCapReached, isFileCleared, loadState, markFileCleared, resolveSessionDir, saveState, } from "../lib/gateguard-state.mjs";
38
+ import { MAX_CLEARED_FILES, canonicalizeFileKey, canonicalizeProjectRoot, isCapReached, isFileCleared, loadState, markFileCleared, resolveProjectRoot, resolveSessionDir, saveState, } from "../lib/gateguard-state.mjs";
39
39
  const TOOL_ROUTE = {
40
40
  Read: "allow",
41
41
  Grep: "allow",
@@ -69,8 +69,20 @@ const DESTRUCTIVE_PATTERNS = [
69
69
  "Remove-Item -Recurse",
70
70
  "Remove-Item -Force",
71
71
  ];
72
+ // Flags whose VALUE is human prose (a commit message, a PR body) or a filename —
73
+ // never a command to execute. Their contents must not trip the destructive scan:
74
+ // `git commit -m "drop the stale format helper"` and `gh pr create --body "…"`
75
+ // were stranding finished work on their own wording. `-c` is deliberately
76
+ // EXCLUDED — `bash -c "rm -rf /"` carries a real command and must still gate.
77
+ const MESSAGE_FLAG_RE = /(^|\s)(-m|--message|-F|--file|--body|--body-file|--title|--notes|-C|--reuse-message)(=|\s+)('[^']*'|"[^"]*"|\S+)/g;
78
+ // Blank the value of every message/body flag so only executable command syntax
79
+ // remains for the destructive-pattern scan. The flag itself is preserved so a
80
+ // flag like `-F` never accidentally merges with its neighbours.
81
+ function stripMessageArgs(command) {
82
+ return command.replace(MESSAGE_FLAG_RE, (_match, lead, flag) => `${lead}${flag} `);
83
+ }
72
84
  function isDestructiveBash(command) {
73
- const lower = command.toLowerCase();
85
+ const lower = stripMessageArgs(command).toLowerCase();
74
86
  return DESTRUCTIVE_PATTERNS.some((p) => lower.includes(p.toLowerCase()));
75
87
  }
76
88
  function classifyTool(toolName, toolInput) {
@@ -95,6 +107,60 @@ function extractFilePaths(toolInput) {
95
107
  return [toolInput.command];
96
108
  return [];
97
109
  }
110
+ // --- Path exclusions -------------------------------------------------------
111
+ // Opt-in: skip the fact-forcing gate for low-risk paths a user edits
112
+ // constantly (an LLM-maintained prose wiki, a generated scratch dir). Set the
113
+ // CI_GATEGUARD_EXCLUDE env var to a comma-separated list of path substrings;
114
+ // each is matched case-insensitively against the forward-slash-normalized file
115
+ // path. Unset/empty (the default) changes nothing — every mutating file call is
116
+ // gated exactly as before. A call whose targets mix excluded and non-excluded
117
+ // paths still gates the non-excluded ones.
118
+ const EXCLUDE_FRAGMENTS = String(process.env.CI_GATEGUARD_EXCLUDE ?? "")
119
+ .split(",")
120
+ .map((fragment) => fragment.trim().replace(/\\/g, "/").toLowerCase())
121
+ .filter((fragment) => fragment !== "");
122
+ function isExcludedPath(filePath) {
123
+ if (EXCLUDE_FRAGMENTS.length === 0 || typeof filePath !== "string" || filePath === "") {
124
+ return false;
125
+ }
126
+ const normalized = filePath.replace(/\\/g, "/").toLowerCase();
127
+ return EXCLUDE_FRAGMENTS.some((fragment) => normalized.includes(fragment));
128
+ }
129
+ // --- Target lock (opt-in) --------------------------------------------------
130
+ // A fact-list can't catch a wrong-repo / wrong-worktree write — you can present
131
+ // perfect facts about the wrong file. CI_GATEGUARD_TARGET_LOCK=block denies a
132
+ // mutating call whose ABSOLUTE target canonicalizes outside the session project
133
+ // root. Default (unset) checks nothing, so existing sessions — including
134
+ // legitimate out-of-root edits to ~/.claude or /tmp — are unaffected. This is
135
+ // the warn-first rollout: ship non-enforcing, flip to block per session.
136
+ const TARGET_LOCK_ON = String(process.env.CI_GATEGUARD_TARGET_LOCK ?? "").toLowerCase() === "block";
137
+ // Relative paths resolve under cwd (= the project root) and always pass; only an
138
+ // absolute path into a different tree can be out-of-root. Drive-letter (d:/,
139
+ // D:\), POSIX-absolute (/x), and UNC (\\host) forms all count as absolute.
140
+ function isAbsolutePathString(p) {
141
+ return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith("/") || p.startsWith("\\\\");
142
+ }
143
+ function isTargetOutsideRoot(filePath, projectRoot) {
144
+ if (!isAbsolutePathString(filePath))
145
+ return false;
146
+ const root = canonicalizeProjectRoot(projectRoot);
147
+ if (root === "global" || root === "")
148
+ return false; // no known root — do not guess
149
+ const target = canonicalizeFileKey(filePath);
150
+ return target !== root && !target.startsWith(`${root}/`);
151
+ }
152
+ function buildTargetLockReason(strayPath, projectRoot) {
153
+ return [
154
+ `Target is outside the session project root — refusing a possible wrong-repo / wrong-worktree write.`,
155
+ "",
156
+ ` Target: ${strayPath.replace(/\\/g, "/")}`,
157
+ ` Session root: ${canonicalizeProjectRoot(projectRoot)}`,
158
+ "",
159
+ "If this is intentional, confirm you are in the right worktree (cwd / CLAUDE_PROJECT_DIR),",
160
+ "or unset CI_GATEGUARD_TARGET_LOCK for this session. Target lock is opt-in; it fires only",
161
+ "when CI_GATEGUARD_TARGET_LOCK=block.",
162
+ ].join("\n");
163
+ }
98
164
  // The call site only reads this inside the block branch, where at least one
99
165
  // path is uncleared; an all-cleared batch returns "" and is never consumed.
100
166
  function firstUnclearedFilePath(toolInput, state) {
@@ -144,6 +210,47 @@ function buildMutatingFileReason(toolName, filePaths, stateFilePath) {
144
210
  " `_gateguard_facts_presented: true`; Claude Code's strict schema rejects that, so use A or B.)",
145
211
  ].join("\n");
146
212
  }
213
+ function findUnquotedBraceRef(command) {
214
+ let quote = null;
215
+ for (let i = 0; i < command.length; i++) {
216
+ const ch = command[i];
217
+ if (quote) {
218
+ if (ch === quote)
219
+ quote = null;
220
+ continue;
221
+ }
222
+ if (ch === '"' || ch === "'") {
223
+ quote = ch;
224
+ continue;
225
+ }
226
+ if (ch === "@" && command[i + 1] === "{") {
227
+ // Expand to the whitespace-delimited word that carries this @{ ref, then
228
+ // single-quote that whole word in the suggested fix.
229
+ let wordStart = i;
230
+ while (wordStart > 0 && !/\s/.test(command[wordStart - 1]))
231
+ wordStart--;
232
+ let wordEnd = i;
233
+ while (wordEnd < command.length && !/\s/.test(command[wordEnd]))
234
+ wordEnd++;
235
+ const word = command.slice(wordStart, wordEnd);
236
+ const braceEnd = command.indexOf("}", i);
237
+ const ref = braceEnd === -1 ? command.slice(i, wordEnd) : command.slice(i, braceEnd + 1);
238
+ const fixed = `${command.slice(0, wordStart)}'${word}'${command.slice(wordEnd)}`;
239
+ return { ref, fixed };
240
+ }
241
+ }
242
+ return null;
243
+ }
244
+ function buildBraceRefReason(hit) {
245
+ return [
246
+ `Unquoted git ref ${hit.ref} — Claude Code's Bash parser trips on the braces and blocks this`,
247
+ "post-hoc, costing a retry. Quote the ref and run the SAME command:",
248
+ "",
249
+ ` ${hit.fixed}`,
250
+ "",
251
+ "Single quotes stop the shell from touching the braces; git reads the ref as-is.",
252
+ ].join("\n");
253
+ }
147
254
  function buildDestructiveBashReason(command) {
148
255
  return [
149
256
  `Destructive command requested: ${command}`,
@@ -199,6 +306,16 @@ function main() {
199
306
  const toolName = typeof payload.tool_name === "string" ? payload.tool_name : "";
200
307
  const toolInput = payload.tool_input ?? {};
201
308
  const gate = classifyTool(toolName, toolInput);
309
+ // Unquoted @{…} refs trip the built-in Bash parser — catch them first, for
310
+ // both routine and destructive commands, so the quoted fix surfaces before the
311
+ // opaque post-hoc block (and before the destructive rollback demand).
312
+ if (toolName === "Bash" && typeof toolInput.command === "string") {
313
+ const braceHit = findUnquotedBraceRef(toolInput.command);
314
+ if (braceHit) {
315
+ emitDeny(buildBraceRefReason(braceHit));
316
+ return;
317
+ }
318
+ }
202
319
  if (gate === "allow") {
203
320
  emitAllow();
204
321
  return;
@@ -215,7 +332,24 @@ function main() {
215
332
  const sessionDir = resolveSessionDir(sessionId);
216
333
  const stateFilePath = join(sessionDir, "gateguard-session.json");
217
334
  const state = loadState(sessionDir);
218
- const filePaths = extractFilePaths(toolInput);
335
+ const allTargetPaths = extractFilePaths(toolInput);
336
+ const filePaths = allTargetPaths.filter((path) => !isExcludedPath(path));
337
+ if (allTargetPaths.length > 0 && filePaths.length === 0) {
338
+ emitAllow(); // every target is under a CI_GATEGUARD_EXCLUDE path; skip the gate
339
+ return;
340
+ }
341
+ // Target lock runs before the fact gate and independent of clearance: a
342
+ // wrong-repo write is wrong even with perfect facts. Excluded paths were
343
+ // already filtered out above, so an explicitly-excluded scratch dir outside
344
+ // the root is never target-locked.
345
+ if (TARGET_LOCK_ON) {
346
+ const projectRoot = resolveProjectRoot();
347
+ const stray = filePaths.find((path) => isTargetOutsideRoot(path, projectRoot));
348
+ if (stray) {
349
+ emitDeny(buildTargetLockReason(stray, projectRoot));
350
+ return;
351
+ }
352
+ }
219
353
  const filePath = firstUnclearedFilePath(toolInput, state);
220
354
  const factsFlagged = toolInput._gateguard_facts_presented === true;
221
355
  const alreadyCleared = filePaths.length > 0 && filePaths.every((path) => isFileCleared(state, path));
@@ -59,7 +59,11 @@ function sanitizeSessionId(sessionId) {
59
59
  return "";
60
60
  return sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
61
61
  }
62
- function resolveProjectRoot() {
62
+ // Exported for the target-lock gate (RISA 2 / G2): the hook compares a mutating
63
+ // call's absolute target against this root to catch wrong-repo / wrong-worktree
64
+ // writes. Returns "global" when no CLAUDE_PROJECT_DIR and no git toplevel — the
65
+ // caller treats that as "no known root, do not guess".
66
+ export function resolveProjectRoot() {
63
67
  const fromEnv = process.env.CLAUDE_PROJECT_DIR;
64
68
  if (fromEnv)
65
69
  return fromEnv;
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "continuous-improvement",
3
- "version": "3.17.0",
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 27 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.",
3
+ "version": "3.18.0",
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 27 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",
7
+ "claude-code-plugin",
7
8
  "claude-code-skill",
9
+ "claude",
10
+ "anthropic",
8
11
  "ai-agent",
12
+ "ai-agents",
9
13
  "agent-skill",
10
14
  "ai-discipline",
11
15
  "mulahazah",
12
- "instinct",
16
+ "instinct-learning",
13
17
  "hooks",
14
18
  "mcp",
15
- "mcp-server",
16
19
  "github-action",
17
20
  "transcript-linter"
18
21
  ],
@@ -30,7 +33,9 @@
30
33
  "continuous-improvement": "bin/install.mjs",
31
34
  "ci-lint-transcript": "bin/lint-transcript.mjs",
32
35
  "ci": "bin/unified-cli.mjs",
33
- "ci-plan-pack": "bin/plan-pack.mjs"
36
+ "ci-plan-pack": "bin/plan-pack.mjs",
37
+ "ci-audit-actions": "bin/audit-actions.mjs",
38
+ "ci-portfolio-health": "bin/portfolio-health.mjs"
34
39
  },
35
40
  "scripts": {
36
41
  "build": "tsc -p tsconfig.json && node bin/generate-plugin-manifests.mjs && node -e \"const fs=require('node:fs'); for (const f of fs.readdirSync('bin')) { if (f.endsWith('.mjs')) fs.chmodSync('bin/'+f, 0o755); } for (const f of fs.readdirSync('hooks')) { if (f.endsWith('.mjs')) fs.chmodSync('hooks/'+f, 0o755); } for (const f of fs.readdirSync('lib')) { if (f.endsWith('.mjs')) fs.chmodSync('lib/'+f, 0o755); } for (const f of fs.readdirSync('plugins/continuous-improvement/bin')) { if (f.endsWith('.mjs')) fs.chmodSync('plugins/continuous-improvement/bin/'+f, 0o755); } for (const f of fs.readdirSync('plugins/continuous-improvement/lib')) { if (f.endsWith('.mjs')) fs.chmodSync('plugins/continuous-improvement/lib/'+f, 0o755); } for (const f of fs.readdirSync('plugins/continuous-improvement/hooks')) { if (f.endsWith('.mjs')) fs.chmodSync('plugins/continuous-improvement/hooks/'+f, 0o755); } for (const f of fs.readdirSync('scripts')) { if (f.endsWith('.mjs')) fs.chmodSync('scripts/'+f, 0o755); } for (const f of fs.readdirSync('synthetic-checks')) { if (f.endsWith('.mjs')) fs.chmodSync('synthetic-checks/'+f, 0o755); } \"",
@@ -45,6 +50,7 @@
45
50
  "verify:skill-law-tag": "node bin/check-skill-law-tag.mjs",
46
51
  "verify:skill-count": "node bin/check-skill-count.mjs",
47
52
  "verify:skill-count-prose": "node bin/check-skill-count-prose.mjs",
53
+ "verify:command-count": "node bin/check-command-count.mjs",
48
54
  "verify:docs-substrings": "node bin/check-docs-substrings.mjs",
49
55
  "verify:everything-mirror": "node bin/check-everything-mirror.mjs",
50
56
  "verify:routing-targets": "node bin/check-routing-targets.mjs",
@@ -53,7 +59,7 @@
53
59
  "verify:scripts-citation-drift": "node bin/check-scripts-citation-drift.mjs",
54
60
  "verify:third-party-shape": "node bin/check-third-party-shape.mjs",
55
61
  "verify:tool-count": "node bin/check-tool-count.mjs",
56
- "verify:all": "npm run verify:skill-mirror && npm run verify:skill-tiers && npm run verify:skill-law-tag && npm run verify:skill-count && npm run verify:skill-count-prose && npm run verify:docs-substrings && npm run verify:everything-mirror && npm run verify:routing-targets && npm run verify:doc-runtime-claims && npm run verify:test-imports-only && npm run verify:scripts-citation-drift && npm run verify:third-party-shape && npm run verify:tool-count && npm run typecheck"
62
+ "verify:all": "npm run verify:skill-mirror && npm run verify:skill-tiers && npm run verify:skill-law-tag && npm run verify:skill-count && npm run verify:skill-count-prose && npm run verify:command-count && npm run verify:docs-substrings && npm run verify:everything-mirror && npm run verify:routing-targets && npm run verify:doc-runtime-claims && npm run verify:test-imports-only && npm run verify:scripts-citation-drift && npm run verify:third-party-shape && npm run verify:tool-count && npm run typecheck"
57
63
  },
58
64
  "files": [
59
65
  ".claude-plugin/",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "continuous-improvement",
3
- "version": "3.17.0",
3
+ "version": "3.18.0",
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 27 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.17.0",
11
+ "version": "3.18.0",
12
12
  "source": "./",
13
13
  "author": {
14
14
  "name": "naimkatiman"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "continuous-improvement",
3
- "version": "3.17.0",
3
+ "version": "3.18.0",
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 27 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",