continuous-improvement 3.17.0 → 3.19.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +165 -99
- package/bin/audit-actions.mjs +433 -0
- package/bin/check-command-count.mjs +114 -0
- package/bin/generate-plugin-manifests.mjs +1 -0
- package/bin/portfolio-health.mjs +298 -0
- package/commands/reconcile.md +34 -7
- package/hooks/gateguard.mjs +137 -3
- package/hooks/typecheck-stop.mjs +117 -0
- package/lib/gateguard-state.mjs +5 -1
- package/lib/plugin-metadata.mjs +10 -2
- package/lib/typecheck-gate.mjs +62 -0
- package/package.json +12 -6
- 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/commands/reconcile.md +34 -7
- package/plugins/continuous-improvement/hooks/gateguard.mjs +137 -3
- package/plugins/continuous-improvement/hooks/hooks.json +6 -1
- package/plugins/continuous-improvement/hooks/typecheck-stop.mjs +117 -0
- package/plugins/continuous-improvement/lib/gateguard-state.mjs +5 -1
- package/plugins/continuous-improvement/lib/plugin-metadata.mjs +10 -2
- package/plugins/continuous-improvement/lib/typecheck-gate.mjs +62 -0
- package/plugins/continuous-improvement/skills/README.md +1 -1
- package/plugins/continuous-improvement/skills/gateguard/SKILL.md +10 -0
- package/plugins/continuous-improvement/skills/reconcile/SKILL.md +52 -4
- package/plugins/expert.json +1 -1
- package/skills/gateguard.md +10 -0
- package/skills/reconcile.md +52 -4
- package/templates/actions_security_checklist.md +39 -0
- package/templates/experiment_template.md +38 -0
- package/templates/portfolio_event.schema.json +69 -0
- package/templates/release_receipt_template.md +37 -0
|
@@ -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
|
+
}
|
package/commands/reconcile.md
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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.
|
package/hooks/gateguard.mjs
CHANGED
|
@@ -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
|
|
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));
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* typecheck-stop.mts — Stop hook that runs the project typecheck on changed TS
|
|
4
|
+
* files and, when opted in, returns a block decision so the failure re-enters
|
|
5
|
+
* model context and the agent fixes it before ending the turn — in all modes,
|
|
6
|
+
* including headless `-p` runs where Stop still fires (RISA 4 / G4).
|
|
7
|
+
*
|
|
8
|
+
* Ports the proven detection logic of ~/.claude/scripts/typecheck-changed.sh
|
|
9
|
+
* (skip non-TS repos, skip TS repos with no changed TS file, prefer the npm
|
|
10
|
+
* `typecheck` script) and adds the block mechanism of goal-drift-stop.mts.
|
|
11
|
+
*
|
|
12
|
+
* Mode via CLAUDE_TYPECHECK_GATE: "off" (default) | "warn" | "block".
|
|
13
|
+
* off : no-op (the global script's advisory systemMessage stays the default
|
|
14
|
+
* layer; this hook is the opt-in enforcement layer — zero regression).
|
|
15
|
+
* warn : one-line stderr notice; never blocks.
|
|
16
|
+
* block : {"decision":"block","reason":...} to re-prompt the model.
|
|
17
|
+
*
|
|
18
|
+
* Fail-open by construction: any error, missing root, non-TS repo, no changed
|
|
19
|
+
* TS file, no runnable typecheck, or a run that times out exits 0 (allow). No
|
|
20
|
+
* network. The wiring gives this hook a longer timeout than the 5s hooks
|
|
21
|
+
* because `tsc` is slower; on the internal timeout it fails open.
|
|
22
|
+
*/
|
|
23
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
24
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { decideTypecheckAction, formatTypecheckReason, hasChangedTsFile, parseChangedFiles, pickTypecheckKind, resolveTypecheckMode, } from "../lib/typecheck-gate.mjs";
|
|
27
|
+
const SPAWN_TIMEOUT_MS = 25_000;
|
|
28
|
+
function resolveProjectRoot() {
|
|
29
|
+
const fromEnv = process.env.CLAUDE_PROJECT_DIR;
|
|
30
|
+
if (fromEnv && fromEnv.trim())
|
|
31
|
+
return fromEnv.trim();
|
|
32
|
+
try {
|
|
33
|
+
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
34
|
+
encoding: "utf8",
|
|
35
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
36
|
+
}).trim();
|
|
37
|
+
return root || null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function collectChangedFiles(root) {
|
|
44
|
+
const run = (args) => {
|
|
45
|
+
try {
|
|
46
|
+
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const unstaged = run(["diff", "--name-only", "--diff-filter=ACMR"]);
|
|
53
|
+
const staged = run(["diff", "--cached", "--name-only", "--diff-filter=ACMR"]);
|
|
54
|
+
return [...parseChangedFiles(unstaged), ...parseChangedFiles(staged)];
|
|
55
|
+
}
|
|
56
|
+
function hasNpmTypecheckScript(root) {
|
|
57
|
+
try {
|
|
58
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
59
|
+
return typeof pkg.scripts?.typecheck === "string";
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function localTscPath(root) {
|
|
66
|
+
// node_modules/typescript/bin/tsc is a Node script — invoking it via `node`
|
|
67
|
+
// is cross-platform, unlike node_modules/.bin/tsc (a shell script / .cmd).
|
|
68
|
+
const path = join(root, "node_modules", "typescript", "bin", "tsc");
|
|
69
|
+
return existsSync(path) ? path : null;
|
|
70
|
+
}
|
|
71
|
+
function runTypecheck(root) {
|
|
72
|
+
const tscPath = localTscPath(root);
|
|
73
|
+
const kind = pickTypecheckKind(hasNpmTypecheckScript(root), tscPath !== null);
|
|
74
|
+
if (!kind)
|
|
75
|
+
return { ran: false, rc: 0, output: "" };
|
|
76
|
+
const result = kind === "npm"
|
|
77
|
+
? spawnSync("npm", ["run", "--silent", "typecheck"], {
|
|
78
|
+
cwd: root,
|
|
79
|
+
encoding: "utf8",
|
|
80
|
+
shell: true,
|
|
81
|
+
timeout: SPAWN_TIMEOUT_MS,
|
|
82
|
+
})
|
|
83
|
+
: spawnSync(process.execPath, [tscPath, "--noEmit"], {
|
|
84
|
+
cwd: root,
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
timeout: SPAWN_TIMEOUT_MS,
|
|
87
|
+
});
|
|
88
|
+
// A timeout or spawn failure leaves error set / status null — inconclusive, so
|
|
89
|
+
// fail open (do not block on a check that never produced a verdict).
|
|
90
|
+
if (result.error || typeof result.status !== "number")
|
|
91
|
+
return { ran: false, rc: 0, output: "" };
|
|
92
|
+
return { ran: true, rc: result.status, output: `${result.stdout ?? ""}${result.stderr ?? ""}` };
|
|
93
|
+
}
|
|
94
|
+
function main() {
|
|
95
|
+
const mode = resolveTypecheckMode(process.env.CLAUDE_TYPECHECK_GATE);
|
|
96
|
+
if (mode === "off")
|
|
97
|
+
return;
|
|
98
|
+
const root = resolveProjectRoot();
|
|
99
|
+
if (!root || !existsSync(join(root, "tsconfig.json")))
|
|
100
|
+
return; // not a TS project
|
|
101
|
+
if (!hasChangedTsFile(collectChangedFiles(root)))
|
|
102
|
+
return; // nothing TS changed — near-zero cost
|
|
103
|
+
const { ran, rc, output } = runTypecheck(root);
|
|
104
|
+
const action = decideTypecheckAction({ mode, ranTypecheck: ran, rc });
|
|
105
|
+
if (action === "block") {
|
|
106
|
+
process.stdout.write(`${JSON.stringify({ decision: "block", reason: formatTypecheckReason(output) })}\n`);
|
|
107
|
+
}
|
|
108
|
+
else if (action === "warn") {
|
|
109
|
+
process.stderr.write(`[continuous-improvement] typecheck: ${formatTypecheckReason(output)}\n`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
main();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// fail open — never trap a turn on a hook bug
|
|
117
|
+
}
|