@alecsibilia/luca 13.0.0-alpha.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/LICENSE +201 -0
- package/README.md +47 -0
- package/bin/luca.js +3 -0
- package/dist/chunks/branch.mjs +47 -0
- package/dist/chunks/bun-runtime.mjs +46 -0
- package/dist/chunks/checks.mjs +53 -0
- package/dist/chunks/claim-verify.mjs +465 -0
- package/dist/chunks/classify.mjs +105 -0
- package/dist/chunks/confidence.mjs +199 -0
- package/dist/chunks/doctor.mjs +158 -0
- package/dist/chunks/hook.mjs +696 -0
- package/dist/chunks/init.mjs +715 -0
- package/dist/chunks/muninndb-health.mjs +66 -0
- package/dist/chunks/phase.mjs +38 -0
- package/dist/chunks/pr-review.mjs +122 -0
- package/dist/chunks/preferences.mjs +61 -0
- package/dist/chunks/repair.mjs +111 -0
- package/dist/chunks/repo.mjs +58 -0
- package/dist/chunks/retro.mjs +86 -0
- package/dist/chunks/roadmap.mjs +58 -0
- package/dist/chunks/rules.mjs +527 -0
- package/dist/chunks/stale-mcp-server.mjs +90 -0
- package/dist/chunks/state.mjs +57 -0
- package/dist/chunks/stray-local-install.mjs +200 -0
- package/dist/chunks/telemetry.mjs +165 -0
- package/dist/chunks/todo.mjs +151 -0
- package/dist/chunks/vault-init.mjs +300 -0
- package/dist/chunks/verification.mjs +95 -0
- package/dist/chunks/version.mjs +70 -0
- package/dist/chunks/workflow.mjs +47 -0
- package/dist/claude/.claude/agents/architect.md +410 -0
- package/dist/claude/.claude/agents/build.md +111 -0
- package/dist/claude/.claude/agents/discuss.md +93 -0
- package/dist/claude/.claude/agents/discussion.md +149 -0
- package/dist/claude/.claude/agents/execute.md +416 -0
- package/dist/claude/.claude/agents/executor.md +161 -0
- package/dist/claude/.claude/agents/fast.md +84 -0
- package/dist/claude/.claude/agents/finalize.md +484 -0
- package/dist/claude/.claude/agents/learner.md +160 -0
- package/dist/claude/.claude/agents/plan-reviewer.md +129 -0
- package/dist/claude/.claude/agents/plan.md +96 -0
- package/dist/claude/.claude/agents/research.md +327 -0
- package/dist/claude/.claude/agents/researcher.md +78 -0
- package/dist/claude/.claude/agents/review.md +283 -0
- package/dist/claude/.claude/agents/reviewer.md +163 -0
- package/dist/claude/.claude/agents/shadow-scanner.md +257 -0
- package/dist/claude/.claude/agents/triage.md +230 -0
- package/dist/claude/.claude/agents/verifier.md +131 -0
- package/dist/claude/.claude/commands/bug-diagnose.md +12 -0
- package/dist/claude/.claude/commands/gh-issue-triage.md +14 -0
- package/dist/claude/.claude/commands/gh-pr-address.md +235 -0
- package/dist/claude/.claude/commands/gh-prepare.md +12 -0
- package/dist/claude/.claude/commands/grill-me.md +12 -0
- package/dist/claude/.claude/commands/lu-review.md +51 -0
- package/dist/claude/.claude/commands/lu.md +75 -0
- package/dist/claude/.claude/commands/luca-init.md +14 -0
- package/dist/claude/.claude/commands/luca-telemetry-report.md +12 -0
- package/dist/claude/.claude/commands/memory-audit.md +12 -0
- package/dist/claude/.claude/commands/milestone-new.md +122 -0
- package/dist/claude/.claude/commands/phase-discuss.md +45 -0
- package/dist/claude/.claude/commands/phase-execute.md +39 -0
- package/dist/claude/.claude/commands/phase-plan.md +53 -0
- package/dist/claude/.claude/commands/repo-cleanup.md +80 -0
- package/dist/claude/.claude/commands/todo-add.md +28 -0
- package/dist/claude/.claude/commands/todo-check.md +36 -0
- package/dist/claude/.claude/hooks/context-refresher.ts +285 -0
- package/dist/claude/.claude/hooks/continuation-messages.ts +215 -0
- package/dist/claude/.claude/hooks/pipeline-guard.ts +182 -0
- package/dist/claude/.claude/settings.json +41 -0
- package/dist/claude/skills/arch-audit/SKILL.md +161 -0
- package/dist/claude/skills/autopilot/SKILL.md +1299 -0
- package/dist/claude/skills/bug-diagnose/SKILL.md +102 -0
- package/dist/claude/skills/choose/SKILL.md +124 -0
- package/dist/claude/skills/gh-issue-triage/SKILL.md +97 -0
- package/dist/claude/skills/gh-pr-address/SKILL.md +235 -0
- package/dist/claude/skills/gh-prepare/SKILL.md +209 -0
- package/dist/claude/skills/grill-me/SKILL.md +46 -0
- package/dist/claude/skills/lu/SKILL.md +112 -0
- package/dist/claude/skills/lu-review/SKILL.md +51 -0
- package/dist/claude/skills/luca-init/SKILL.md +91 -0
- package/dist/claude/skills/luca-telemetry-report/SKILL.md +145 -0
- package/dist/claude/skills/luca-write-surface/SKILL.md +213 -0
- package/dist/claude/skills/memory-audit/SKILL.md +217 -0
- package/dist/claude/skills/milestone-audit/SKILL.md +545 -0
- package/dist/claude/skills/milestone-complete/SKILL.md +168 -0
- package/dist/claude/skills/milestone-gaps/SKILL.md +60 -0
- package/dist/claude/skills/milestone-new/SKILL.md +125 -0
- package/dist/claude/skills/note/SKILL.md +162 -0
- package/dist/claude/skills/phase-add/SKILL.md +91 -0
- package/dist/claude/skills/phase-assumptions/SKILL.md +92 -0
- package/dist/claude/skills/phase-discuss/SKILL.md +165 -0
- package/dist/claude/skills/phase-execute/SKILL.md +1786 -0
- package/dist/claude/skills/phase-insert/SKILL.md +100 -0
- package/dist/claude/skills/phase-plan/SKILL.md +461 -0
- package/dist/claude/skills/phase-remove/SKILL.md +113 -0
- package/dist/claude/skills/phase-research/SKILL.md +80 -0
- package/dist/claude/skills/post-init-tour/SKILL.md +58 -0
- package/dist/claude/skills/progress/SKILL.md +271 -0
- package/dist/claude/skills/project-new/SKILL.md +609 -0
- package/dist/claude/skills/quick/SKILL.md +256 -0
- package/dist/claude/skills/rename-audit/SKILL.md +52 -0
- package/dist/claude/skills/repo-audit/SKILL.md +88 -0
- package/dist/claude/skills/repo-cleanup/SKILL.md +80 -0
- package/dist/claude/skills/seed-memory/SKILL.md +235 -0
- package/dist/claude/skills/session-pause/SKILL.md +126 -0
- package/dist/claude/skills/session-plan/SKILL.md +112 -0
- package/dist/claude/skills/session-resume/SKILL.md +75 -0
- package/dist/claude/skills/todo-add/SKILL.md +85 -0
- package/dist/claude/skills/todo-check/SKILL.md +77 -0
- package/dist/claude/skills/workflow-save/SKILL.md +277 -0
- package/dist/index.d.mts +33 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.mjs +69 -0
- package/dist/shared/luca.B3Mimc0P.mjs +52 -0
- package/dist/shared/luca.B3saVjJm.mjs +163 -0
- package/dist/shared/luca.BYdjkfnz.mjs +217 -0
- package/dist/shared/luca.BmhNkYe2.mjs +56 -0
- package/dist/shared/luca.C4gMUoBd.mjs +358 -0
- package/dist/shared/luca.CQ3g1xrD.mjs +19 -0
- package/dist/shared/luca.CRmaAfXR.mjs +713 -0
- package/dist/shared/luca.CrXzXueR.mjs +57 -0
- package/dist/shared/luca.DTomPq7I.mjs +91 -0
- package/dist/shared/luca.DjDTeDCi.mjs +1904 -0
- package/dist/shared/luca.HZxBTBgD.mjs +201 -0
- package/dist/shared/luca.TSMg1t7I.mjs +10 -0
- package/dist/shared/luca.dM-MKlNE.mjs +25 -0
- package/dist/shared/luca.naWEcQ4B.mjs +7 -0
- package/package.json +76 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { c as coarsePhaseOf, a as classifyWritePath, A as AUDIT_PATH_PATTERN, W as WAVE_FILE_RE } from '../shared/luca.CRmaAfXR.mjs';
|
|
3
|
+
import { l as loadCurrentState, r as resolveActiveSlug } from '../shared/luca.CrXzXueR.mjs';
|
|
4
|
+
import 'node:fs';
|
|
5
|
+
import 'node:fs/promises';
|
|
6
|
+
import 'node:path';
|
|
7
|
+
import { S as STEP_ARTIFACTS } from '../shared/luca.B3Mimc0P.mjs';
|
|
8
|
+
import { p as phasePathFor } from '../shared/luca.TSMg1t7I.mjs';
|
|
9
|
+
import 'node:crypto';
|
|
10
|
+
import 'node:module';
|
|
11
|
+
import 'node:url';
|
|
12
|
+
import 'node:child_process';
|
|
13
|
+
import { parse } from 'shell-quote';
|
|
14
|
+
import 'zod';
|
|
15
|
+
import 'node:os';
|
|
16
|
+
|
|
17
|
+
const STAGE_TOOL_MATRIX = {
|
|
18
|
+
IDLE: {
|
|
19
|
+
"code-write": true,
|
|
20
|
+
"planning-write-general": true,
|
|
21
|
+
"planning-write-audit": true,
|
|
22
|
+
"bash-readonly": true,
|
|
23
|
+
"bash-mutate": true,
|
|
24
|
+
"bash-commit": true,
|
|
25
|
+
"luca-write": true
|
|
26
|
+
},
|
|
27
|
+
PLANNING: {
|
|
28
|
+
"code-write": false,
|
|
29
|
+
"planning-write-general": true,
|
|
30
|
+
"planning-write-audit": true,
|
|
31
|
+
"bash-readonly": true,
|
|
32
|
+
"bash-mutate": false,
|
|
33
|
+
"bash-commit": false,
|
|
34
|
+
"luca-write": true
|
|
35
|
+
},
|
|
36
|
+
EXECUTING: {
|
|
37
|
+
"code-write": true,
|
|
38
|
+
"planning-write-general": true,
|
|
39
|
+
"planning-write-audit": true,
|
|
40
|
+
"bash-readonly": true,
|
|
41
|
+
"bash-mutate": true,
|
|
42
|
+
"bash-commit": false,
|
|
43
|
+
"luca-write": true
|
|
44
|
+
},
|
|
45
|
+
REVIEWING: {
|
|
46
|
+
"code-write": false,
|
|
47
|
+
// General .luca/ writes blocked — reviewers must write via the
|
|
48
|
+
// audit MCP tool, which lands at .luca/phases/<slug>/audits/<reviewer>.md
|
|
49
|
+
"planning-write-general": false,
|
|
50
|
+
"planning-write-audit": true,
|
|
51
|
+
"bash-readonly": true,
|
|
52
|
+
"bash-mutate": false,
|
|
53
|
+
"bash-commit": false,
|
|
54
|
+
"luca-write": true
|
|
55
|
+
},
|
|
56
|
+
FINALIZING: {
|
|
57
|
+
"code-write": false,
|
|
58
|
+
"planning-write-general": true,
|
|
59
|
+
"planning-write-audit": true,
|
|
60
|
+
"bash-readonly": true,
|
|
61
|
+
"bash-mutate": false,
|
|
62
|
+
"bash-commit": true,
|
|
63
|
+
"luca-write": true
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function isToolAllowed({
|
|
68
|
+
phase,
|
|
69
|
+
category
|
|
70
|
+
}) {
|
|
71
|
+
return STAGE_TOOL_MATRIX[phase][category];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const READONLY_COMMANDS = /* @__PURE__ */ new Set([
|
|
75
|
+
"ls",
|
|
76
|
+
"cat",
|
|
77
|
+
"grep",
|
|
78
|
+
"find",
|
|
79
|
+
"pwd",
|
|
80
|
+
"head",
|
|
81
|
+
"tail",
|
|
82
|
+
"wc",
|
|
83
|
+
"basename",
|
|
84
|
+
"dirname",
|
|
85
|
+
"type",
|
|
86
|
+
"which",
|
|
87
|
+
"echo",
|
|
88
|
+
"printf",
|
|
89
|
+
"true",
|
|
90
|
+
"false"
|
|
91
|
+
]);
|
|
92
|
+
const GIT_READONLY_SUBCOMMANDS = /* @__PURE__ */ new Set([
|
|
93
|
+
"status",
|
|
94
|
+
"log",
|
|
95
|
+
"diff",
|
|
96
|
+
"show",
|
|
97
|
+
"branch",
|
|
98
|
+
"remote",
|
|
99
|
+
"rev-parse",
|
|
100
|
+
"rev-list",
|
|
101
|
+
"config",
|
|
102
|
+
"describe",
|
|
103
|
+
"blame"
|
|
104
|
+
]);
|
|
105
|
+
const GIT_COMMIT_SUBCOMMANDS = /* @__PURE__ */ new Set(["commit", "push", "tag"]);
|
|
106
|
+
const GIT_MUTATE_SUBCOMMANDS = /* @__PURE__ */ new Set([
|
|
107
|
+
"add",
|
|
108
|
+
"mv",
|
|
109
|
+
"rm",
|
|
110
|
+
"checkout",
|
|
111
|
+
"reset",
|
|
112
|
+
"restore",
|
|
113
|
+
"switch",
|
|
114
|
+
"stash",
|
|
115
|
+
"merge",
|
|
116
|
+
"rebase",
|
|
117
|
+
"cherry-pick",
|
|
118
|
+
"apply",
|
|
119
|
+
"clean",
|
|
120
|
+
"fetch",
|
|
121
|
+
"pull",
|
|
122
|
+
"clone",
|
|
123
|
+
"init",
|
|
124
|
+
"gc",
|
|
125
|
+
"prune"
|
|
126
|
+
]);
|
|
127
|
+
const GH_READONLY_PATTERNS = [
|
|
128
|
+
["pr", "view"],
|
|
129
|
+
["pr", "list"],
|
|
130
|
+
["pr", "checks"],
|
|
131
|
+
["pr", "status"],
|
|
132
|
+
["pr", "diff"],
|
|
133
|
+
["issue", "view"],
|
|
134
|
+
["issue", "list"],
|
|
135
|
+
["repo", "view"],
|
|
136
|
+
["release", "list"],
|
|
137
|
+
["release", "view"],
|
|
138
|
+
["api", "GET"]
|
|
139
|
+
];
|
|
140
|
+
const GH_COMMIT_PATTERNS = [
|
|
141
|
+
["pr", "create"],
|
|
142
|
+
["pr", "merge"],
|
|
143
|
+
["pr", "close"],
|
|
144
|
+
["pr", "comment"],
|
|
145
|
+
["issue", "create"],
|
|
146
|
+
["issue", "close"],
|
|
147
|
+
["issue", "comment"],
|
|
148
|
+
["release", "create"]
|
|
149
|
+
];
|
|
150
|
+
const MUTATE_COMMANDS = /* @__PURE__ */ new Set([
|
|
151
|
+
"cp",
|
|
152
|
+
"mv",
|
|
153
|
+
"rm",
|
|
154
|
+
"mkdir",
|
|
155
|
+
"rmdir",
|
|
156
|
+
"touch",
|
|
157
|
+
"ln",
|
|
158
|
+
"install",
|
|
159
|
+
"unlink",
|
|
160
|
+
"chmod",
|
|
161
|
+
"chown",
|
|
162
|
+
"sed",
|
|
163
|
+
"awk",
|
|
164
|
+
// can have -i side effects via gsub('foo', val, file) — conservative
|
|
165
|
+
"patch",
|
|
166
|
+
"tar",
|
|
167
|
+
"unzip",
|
|
168
|
+
"zip"
|
|
169
|
+
]);
|
|
170
|
+
const PKG_MUTATE_PATTERNS = [
|
|
171
|
+
["bun", "install"],
|
|
172
|
+
["bun", "add"],
|
|
173
|
+
["bun", "remove"],
|
|
174
|
+
["bun", "update"],
|
|
175
|
+
["bun", "run", "build"],
|
|
176
|
+
["bunx"],
|
|
177
|
+
// generic — could mutate via build
|
|
178
|
+
["npm", "install"],
|
|
179
|
+
["npm", "i"],
|
|
180
|
+
["npm", "run", "build"],
|
|
181
|
+
["yarn", "install"],
|
|
182
|
+
["yarn", "add"],
|
|
183
|
+
["pnpm", "install"],
|
|
184
|
+
["pnpm", "add"]
|
|
185
|
+
];
|
|
186
|
+
const ALWAYS_DENIED_COMMANDS = /* @__PURE__ */ new Set(["eval", "source", "."]);
|
|
187
|
+
const LUCA_READ_VERBS = /* @__PURE__ */ new Set([
|
|
188
|
+
"read",
|
|
189
|
+
"current",
|
|
190
|
+
"list",
|
|
191
|
+
"guard",
|
|
192
|
+
"filter-stale",
|
|
193
|
+
"detect-convergence",
|
|
194
|
+
"regression-check"
|
|
195
|
+
]);
|
|
196
|
+
const LUCA_NOUN_VERBS = {
|
|
197
|
+
state: /* @__PURE__ */ new Set(["read", "advance"]),
|
|
198
|
+
phase: /* @__PURE__ */ new Set(["current"]),
|
|
199
|
+
roadmap: /* @__PURE__ */ new Set(["read", "create"]),
|
|
200
|
+
preferences: /* @__PURE__ */ new Set(["read", "write"]),
|
|
201
|
+
todo: /* @__PURE__ */ new Set(["add", "list", "update"]),
|
|
202
|
+
"pr-review": /* @__PURE__ */ new Set([
|
|
203
|
+
"filter-stale",
|
|
204
|
+
"detect-convergence",
|
|
205
|
+
"regression-check"
|
|
206
|
+
]),
|
|
207
|
+
repo: /* @__PURE__ */ new Set(["cleanup-apply"]),
|
|
208
|
+
checks: /* @__PURE__ */ new Set(["run"]),
|
|
209
|
+
branch: /* @__PURE__ */ new Set(["guard"]),
|
|
210
|
+
workflow: /* @__PURE__ */ new Set(["reset"]),
|
|
211
|
+
confidence: /* @__PURE__ */ new Set(["log"])
|
|
212
|
+
};
|
|
213
|
+
function classifyLucaCommand(rest) {
|
|
214
|
+
const noun = rest.find((t) => !t.startsWith("-"));
|
|
215
|
+
if (!noun) return void 0;
|
|
216
|
+
const verbs = LUCA_NOUN_VERBS[noun];
|
|
217
|
+
if (!verbs) return void 0;
|
|
218
|
+
const afterNoun = rest.slice(rest.indexOf(noun) + 1);
|
|
219
|
+
const verb = afterNoun.find((t) => !t.startsWith("-"));
|
|
220
|
+
if (!verb || !verbs.has(verb)) {
|
|
221
|
+
return "luca-write";
|
|
222
|
+
}
|
|
223
|
+
return LUCA_READ_VERBS.has(verb) ? "bash-readonly" : "luca-write";
|
|
224
|
+
}
|
|
225
|
+
const SEVERITY = {
|
|
226
|
+
"bash-readonly": 0,
|
|
227
|
+
// `luca-write` and `bash-mutate` share a tier: both are "mutating" but
|
|
228
|
+
// neither escalates past a commit. In a mixed pipeline `maxCategory`
|
|
229
|
+
// keeps the first-seen at equal severity — acceptable, mixed
|
|
230
|
+
// `luca`+mutate command strings are not a real pattern.
|
|
231
|
+
"luca-write": 1,
|
|
232
|
+
"bash-mutate": 1,
|
|
233
|
+
"bash-commit": 2,
|
|
234
|
+
denied: 3
|
|
235
|
+
};
|
|
236
|
+
function maxCategory(a, b) {
|
|
237
|
+
return SEVERITY[a] >= SEVERITY[b] ? a : b;
|
|
238
|
+
}
|
|
239
|
+
function splitIntoSubcommands(entries) {
|
|
240
|
+
const subcommands = [];
|
|
241
|
+
let current = { tokens: [] };
|
|
242
|
+
let i = 0;
|
|
243
|
+
while (i < entries.length) {
|
|
244
|
+
const t = entries[i];
|
|
245
|
+
if (typeof t === "string") {
|
|
246
|
+
current.tokens.push(t);
|
|
247
|
+
i++;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if ("op" in t) {
|
|
251
|
+
const op = String(t.op);
|
|
252
|
+
if (op === ">" || op === ">>" || op === "&>") {
|
|
253
|
+
const target = entries[i + 1];
|
|
254
|
+
current.redirect = {
|
|
255
|
+
op,
|
|
256
|
+
target: typeof target === "string" ? target : void 0
|
|
257
|
+
};
|
|
258
|
+
i += 2;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (op === ";" || op === "&&" || op === "||" || op === "|") {
|
|
262
|
+
current.follower = op;
|
|
263
|
+
subcommands.push(current);
|
|
264
|
+
current = { tokens: [] };
|
|
265
|
+
i++;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
i++;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (typeof t === "object" && "pattern" in t) {
|
|
272
|
+
current.tokens.push(t.pattern);
|
|
273
|
+
}
|
|
274
|
+
i++;
|
|
275
|
+
}
|
|
276
|
+
subcommands.push(current);
|
|
277
|
+
return subcommands;
|
|
278
|
+
}
|
|
279
|
+
function classifySubcommand(sub) {
|
|
280
|
+
const tokens = sub.tokens;
|
|
281
|
+
if (tokens.length === 0) {
|
|
282
|
+
return { category: "bash-readonly", targetPaths: [] };
|
|
283
|
+
}
|
|
284
|
+
const cmd = tokens[0];
|
|
285
|
+
const rest = tokens.slice(1);
|
|
286
|
+
if (ALWAYS_DENIED_COMMANDS.has(cmd)) {
|
|
287
|
+
return {
|
|
288
|
+
category: "denied",
|
|
289
|
+
reason: `'${cmd}' is always denied`,
|
|
290
|
+
targetPaths: []
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const targetsFromRedirect = sub.redirect?.target ? [sub.redirect.target] : [];
|
|
294
|
+
if (cmd === "git" && rest.length > 0) {
|
|
295
|
+
const sub1 = rest[0];
|
|
296
|
+
if (GIT_COMMIT_SUBCOMMANDS.has(sub1)) {
|
|
297
|
+
return {
|
|
298
|
+
category: "bash-commit",
|
|
299
|
+
targetPaths: targetsFromRedirect
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (GIT_MUTATE_SUBCOMMANDS.has(sub1)) {
|
|
303
|
+
const target = lastNonFlag(rest);
|
|
304
|
+
return {
|
|
305
|
+
category: "bash-mutate",
|
|
306
|
+
targetPaths: [
|
|
307
|
+
...targetsFromRedirect,
|
|
308
|
+
...target ? [target] : []
|
|
309
|
+
]
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (GIT_READONLY_SUBCOMMANDS.has(sub1)) {
|
|
313
|
+
return {
|
|
314
|
+
category: sub.redirect ? "bash-mutate" : "bash-readonly",
|
|
315
|
+
targetPaths: targetsFromRedirect
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return { category: "bash-mutate", targetPaths: targetsFromRedirect };
|
|
319
|
+
}
|
|
320
|
+
if (cmd === "gh" && rest.length >= 2) {
|
|
321
|
+
const pair = [rest[0], rest[1]];
|
|
322
|
+
if (GH_COMMIT_PATTERNS.some((p) => p[0] === pair[0] && p[1] === pair[1])) {
|
|
323
|
+
return { category: "bash-commit", targetPaths: targetsFromRedirect };
|
|
324
|
+
}
|
|
325
|
+
if (GH_READONLY_PATTERNS.some(
|
|
326
|
+
(p) => p[0] === pair[0] && p[1] === pair[1]
|
|
327
|
+
)) {
|
|
328
|
+
return {
|
|
329
|
+
category: sub.redirect ? "bash-mutate" : "bash-readonly",
|
|
330
|
+
targetPaths: targetsFromRedirect
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
return { category: "bash-mutate", targetPaths: targetsFromRedirect };
|
|
334
|
+
}
|
|
335
|
+
if (cmd === "luca") {
|
|
336
|
+
const lucaCategory = classifyLucaCommand(rest);
|
|
337
|
+
if (lucaCategory) {
|
|
338
|
+
if (sub.redirect) {
|
|
339
|
+
return {
|
|
340
|
+
category: "bash-mutate",
|
|
341
|
+
targetPaths: targetsFromRedirect
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return { category: lucaCategory, targetPaths: [] };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (cmd === "bunx" && tokens.includes("tsc") && tokens.includes("--noEmit")) {
|
|
348
|
+
return {
|
|
349
|
+
category: sub.redirect ? "bash-mutate" : "bash-readonly",
|
|
350
|
+
targetPaths: targetsFromRedirect
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
for (const pattern of PKG_MUTATE_PATTERNS) {
|
|
354
|
+
if (matchesPrefix(tokens, pattern)) {
|
|
355
|
+
return {
|
|
356
|
+
category: "bash-mutate",
|
|
357
|
+
targetPaths: targetsFromRedirect
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (MUTATE_COMMANDS.has(cmd)) {
|
|
362
|
+
const lastArg = cmd === "cp" || cmd === "mv" || cmd === "ln" ? lastNonFlag(rest) : void 0;
|
|
363
|
+
const sedTarget = cmd === "sed" && rest.includes("-i") ? rest[rest.length - 1] : void 0;
|
|
364
|
+
const additionalTargets = [lastArg, sedTarget].filter(
|
|
365
|
+
(x) => Boolean(x)
|
|
366
|
+
);
|
|
367
|
+
return {
|
|
368
|
+
category: "bash-mutate",
|
|
369
|
+
targetPaths: [...targetsFromRedirect, ...additionalTargets]
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (READONLY_COMMANDS.has(cmd)) {
|
|
373
|
+
return {
|
|
374
|
+
category: sub.redirect ? "bash-mutate" : "bash-readonly",
|
|
375
|
+
targetPaths: targetsFromRedirect
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
category: "bash-mutate",
|
|
380
|
+
targetPaths: targetsFromRedirect
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function lastNonFlag(args) {
|
|
384
|
+
for (let i = args.length - 1; i >= 0; i -= 1) {
|
|
385
|
+
if (!args[i].startsWith("-")) return args[i];
|
|
386
|
+
}
|
|
387
|
+
return void 0;
|
|
388
|
+
}
|
|
389
|
+
function matchesPrefix(tokens, pattern) {
|
|
390
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
391
|
+
if (tokens[i] !== pattern[i]) return false;
|
|
392
|
+
}
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
function detectPipeToShell(subcommands) {
|
|
396
|
+
for (let i = 0; i < subcommands.length; i += 1) {
|
|
397
|
+
const sub = subcommands[i];
|
|
398
|
+
if (sub.follower !== "|") continue;
|
|
399
|
+
const next = subcommands[i + 1];
|
|
400
|
+
if (!next) continue;
|
|
401
|
+
const nextCmd = next.tokens[0];
|
|
402
|
+
if (nextCmd === "sh" || nextCmd === "bash") {
|
|
403
|
+
const upstreamCmd = sub.tokens[0];
|
|
404
|
+
if (upstreamCmd === "curl" || upstreamCmd === "wget" || upstreamCmd === "base64" && sub.tokens.includes("-d") || upstreamCmd === "echo" && subcommands.slice(0, i + 1).some(
|
|
405
|
+
(s) => s.tokens.some(
|
|
406
|
+
(t) => /^[A-Za-z0-9+/=]{16,}$/.test(t)
|
|
407
|
+
)
|
|
408
|
+
)) {
|
|
409
|
+
return `pipe-to-${nextCmd} pattern (${upstreamCmd} | \u2026 | ${nextCmd})`;
|
|
410
|
+
}
|
|
411
|
+
return `pipe-to-${nextCmd} pattern (${upstreamCmd ?? "?"} | \u2026 | ${nextCmd})`;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return void 0;
|
|
415
|
+
}
|
|
416
|
+
function classifyBashCommand(cmd) {
|
|
417
|
+
const trimmed = cmd.trim();
|
|
418
|
+
if (!trimmed) {
|
|
419
|
+
return { category: "bash-readonly", targetPaths: [] };
|
|
420
|
+
}
|
|
421
|
+
let entries;
|
|
422
|
+
try {
|
|
423
|
+
entries = parse(trimmed);
|
|
424
|
+
} catch {
|
|
425
|
+
return {
|
|
426
|
+
category: "bash-mutate",
|
|
427
|
+
reason: "command could not be parsed; treating as mutating (conservative)",
|
|
428
|
+
targetPaths: []
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
if (entries.length === 0) {
|
|
432
|
+
return {
|
|
433
|
+
category: "bash-mutate",
|
|
434
|
+
reason: "command parsed to empty (malformed)",
|
|
435
|
+
targetPaths: []
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const subcommands = splitIntoSubcommands(entries);
|
|
439
|
+
const pipeToShellReason = detectPipeToShell(subcommands);
|
|
440
|
+
if (pipeToShellReason) {
|
|
441
|
+
return {
|
|
442
|
+
category: "denied",
|
|
443
|
+
reason: pipeToShellReason,
|
|
444
|
+
targetPaths: []
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
let category = "bash-readonly";
|
|
448
|
+
let reason;
|
|
449
|
+
const targetPaths = [];
|
|
450
|
+
for (const sub of subcommands) {
|
|
451
|
+
const r = classifySubcommand(sub);
|
|
452
|
+
category = maxCategory(category, r.category);
|
|
453
|
+
if (r.category === "denied" && !reason) reason = r.reason;
|
|
454
|
+
for (const p of r.targetPaths) {
|
|
455
|
+
if (!targetPaths.includes(p)) targetPaths.push(p);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return { category, reason, targetPaths };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function handleStageGateHook(opts) {
|
|
462
|
+
const log = opts.log ?? (() => {
|
|
463
|
+
});
|
|
464
|
+
if (!opts.stdin.trim()) {
|
|
465
|
+
log("stage-gate: empty stdin \u2014 allowing");
|
|
466
|
+
return { exitCode: 0, decision: "allow" };
|
|
467
|
+
}
|
|
468
|
+
let parsed;
|
|
469
|
+
try {
|
|
470
|
+
parsed = JSON.parse(opts.stdin);
|
|
471
|
+
} catch (err) {
|
|
472
|
+
log(
|
|
473
|
+
`stage-gate: could not parse stdin as JSON \u2014 allowing (${err.message})`
|
|
474
|
+
);
|
|
475
|
+
return { exitCode: 0, decision: "allow" };
|
|
476
|
+
}
|
|
477
|
+
const toolName = parsed.tool_name ?? parsed.toolName;
|
|
478
|
+
const toolInput = parsed.tool_input ?? parsed.toolInput;
|
|
479
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
480
|
+
const homedir = opts.homedir ?? process.env.HOME;
|
|
481
|
+
const state = await loadCurrentState({ cwd });
|
|
482
|
+
const phase = coarsePhaseOf(state.pipelineStep);
|
|
483
|
+
if (phase === "IDLE") {
|
|
484
|
+
log(
|
|
485
|
+
`stage-gate: pipelineStep=idle (phase=IDLE) \u2014 allowing ${toolName ?? "(unknown tool)"}`
|
|
486
|
+
);
|
|
487
|
+
return { exitCode: 0, toolName, toolInput, decision: "allow" };
|
|
488
|
+
}
|
|
489
|
+
let category;
|
|
490
|
+
let pathBlockReason;
|
|
491
|
+
if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
|
|
492
|
+
const targetPath = toolInput?.file_path;
|
|
493
|
+
if (!targetPath) {
|
|
494
|
+
log(`stage-gate: ${toolName} without file_path \u2014 allowing`);
|
|
495
|
+
return { exitCode: 0, toolName, toolInput, decision: "allow" };
|
|
496
|
+
}
|
|
497
|
+
const pc = classifyWritePath(targetPath, { homedir });
|
|
498
|
+
if (pc.class === "denied") {
|
|
499
|
+
pathBlockReason = `${toolName} to '${targetPath}' is always denied: ${pc.reason ?? "forbidden path"}`;
|
|
500
|
+
} else if (pc.class === "planning-general" || pc.class === "planning-audit") {
|
|
501
|
+
const gate = artifactPathGate(targetPath, state.pipelineStep, state);
|
|
502
|
+
if (gate.kind === "block") {
|
|
503
|
+
const msg = `stage-gate BLOCK: ${toolName} ${gate.reason} (pipelineStep=${state.pipelineStep})`;
|
|
504
|
+
log(msg);
|
|
505
|
+
return {
|
|
506
|
+
exitCode: 2,
|
|
507
|
+
toolName,
|
|
508
|
+
toolInput,
|
|
509
|
+
decision: "block",
|
|
510
|
+
reason: msg
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
log(
|
|
514
|
+
`stage-gate: ${toolName} to '${targetPath}' is the legal artifact for pipelineStep=${state.pipelineStep} \u2014 allowing`
|
|
515
|
+
);
|
|
516
|
+
return { exitCode: 0, toolName, toolInput, decision: "allow" };
|
|
517
|
+
} else {
|
|
518
|
+
category = pathClassToToolCategory(pc.class);
|
|
519
|
+
}
|
|
520
|
+
} else if (toolName === "Bash") {
|
|
521
|
+
const command = toolInput?.command ?? "";
|
|
522
|
+
const bashResult = classifyBashCommand(command);
|
|
523
|
+
if (bashResult.category === "denied") {
|
|
524
|
+
pathBlockReason = `Bash command is always denied: ${bashResult.reason ?? "forbidden command"}`;
|
|
525
|
+
} else {
|
|
526
|
+
for (const target of bashResult.targetPaths) {
|
|
527
|
+
const pc = classifyWritePath(target, { homedir });
|
|
528
|
+
if (pc.class === "denied") {
|
|
529
|
+
pathBlockReason = `Bash writes to denied path '${target}': ${pc.reason ?? "forbidden path"}`;
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (!pathBlockReason) {
|
|
534
|
+
category = bashCategoryToToolCategory(bashResult.category);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
} else {
|
|
538
|
+
log(
|
|
539
|
+
`stage-gate: ${toolName ?? "(unknown)"} is not write-class \u2014 allowing`
|
|
540
|
+
);
|
|
541
|
+
return { exitCode: 0, toolName, toolInput, decision: "allow" };
|
|
542
|
+
}
|
|
543
|
+
if (pathBlockReason) {
|
|
544
|
+
const msg = `stage-gate BLOCK: ${pathBlockReason}`;
|
|
545
|
+
log(msg);
|
|
546
|
+
return {
|
|
547
|
+
exitCode: 2,
|
|
548
|
+
toolName,
|
|
549
|
+
toolInput,
|
|
550
|
+
decision: "block",
|
|
551
|
+
reason: msg
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
if (!category) {
|
|
555
|
+
log("stage-gate: could not classify tool \u2014 allowing");
|
|
556
|
+
return { exitCode: 0, toolName, toolInput, decision: "allow" };
|
|
557
|
+
}
|
|
558
|
+
const allowed = isToolAllowed({ phase, category });
|
|
559
|
+
if (!allowed) {
|
|
560
|
+
const msg = `stage-gate BLOCK: ${toolName} (category=${category}) is not allowed in phase=${phase} (pipelineStep=${state.pipelineStep})`;
|
|
561
|
+
log(msg);
|
|
562
|
+
return {
|
|
563
|
+
exitCode: 2,
|
|
564
|
+
toolName,
|
|
565
|
+
toolInput,
|
|
566
|
+
decision: "block",
|
|
567
|
+
reason: msg
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
log(
|
|
571
|
+
`stage-gate: ${toolName} (category=${category}) allowed in phase=${phase}`
|
|
572
|
+
);
|
|
573
|
+
return { exitCode: 0, toolName, toolInput, decision: "allow" };
|
|
574
|
+
}
|
|
575
|
+
const FIXED_PHASE_FILE_ARTIFACTS = /* @__PURE__ */ new Set([
|
|
576
|
+
"research",
|
|
577
|
+
"context",
|
|
578
|
+
"plan",
|
|
579
|
+
"plan-review",
|
|
580
|
+
"verify",
|
|
581
|
+
"learn",
|
|
582
|
+
"confidence",
|
|
583
|
+
"execute/summary",
|
|
584
|
+
"execute/progress"
|
|
585
|
+
]);
|
|
586
|
+
function artifactPathGate(targetPath, pipelineStep, state) {
|
|
587
|
+
const legalArtifacts = STEP_ARTIFACTS[pipelineStep];
|
|
588
|
+
if (legalArtifacts.length === 0) {
|
|
589
|
+
return {
|
|
590
|
+
kind: "block",
|
|
591
|
+
reason: `write to '${targetPath}' is not permitted in pipelineStep='${pipelineStep}' \u2014 this step produces no freeform .luca/ artifact (structured mutations go through the 'luca' CLI)`
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
const resolved = resolveActiveSlug(state);
|
|
595
|
+
if (!resolved.ok) {
|
|
596
|
+
return {
|
|
597
|
+
kind: "block",
|
|
598
|
+
reason: `write to '${targetPath}' cannot be validated \u2014 ${resolved.error} (no active phase slug, so no legal artifact path can be computed)`
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
const { slug } = resolved;
|
|
602
|
+
const legalPaths = [];
|
|
603
|
+
for (const artifact of legalArtifacts) {
|
|
604
|
+
if (artifact === "audits/*") {
|
|
605
|
+
if (AUDIT_PATH_PATTERN.test(targetPath) && targetPath.startsWith(`.luca/phases/${slug}/audits/`)) {
|
|
606
|
+
return { kind: "allow" };
|
|
607
|
+
}
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (artifact === "execute/wave") {
|
|
611
|
+
const waveDir = `.luca/phases/${slug}/execute/waves/`;
|
|
612
|
+
if (targetPath.startsWith(waveDir)) {
|
|
613
|
+
const filename = targetPath.slice(waveDir.length);
|
|
614
|
+
if (WAVE_FILE_RE.test(filename)) {
|
|
615
|
+
return { kind: "allow" };
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (FIXED_PHASE_FILE_ARTIFACTS.has(artifact)) {
|
|
621
|
+
legalPaths.push(phasePathFor(slug, artifact));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (legalPaths.includes(targetPath)) {
|
|
625
|
+
return { kind: "allow" };
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
kind: "block",
|
|
629
|
+
reason: `write to '${targetPath}' is not the legal artifact for pipelineStep='${pipelineStep}'. Allowed for this step: ${describeLegalArtifacts(legalArtifacts, legalPaths, slug)}`
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
function describeLegalArtifacts(legalArtifacts, fixedPaths, slug) {
|
|
633
|
+
const parts = [...fixedPaths];
|
|
634
|
+
for (const a of legalArtifacts) {
|
|
635
|
+
if (a === "audits/*") {
|
|
636
|
+
parts.push(`.luca/phases/${slug}/audits/<reviewer>.md`);
|
|
637
|
+
} else if (a === "execute/wave") {
|
|
638
|
+
parts.push(`.luca/phases/${slug}/execute/waves/NN.md`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return parts.length > 0 ? parts.join(", ") : "(none)";
|
|
642
|
+
}
|
|
643
|
+
function pathClassToToolCategory(c) {
|
|
644
|
+
switch (c) {
|
|
645
|
+
case "code":
|
|
646
|
+
return "code-write";
|
|
647
|
+
case "planning-general":
|
|
648
|
+
return "planning-write-general";
|
|
649
|
+
case "planning-audit":
|
|
650
|
+
return "planning-write-audit";
|
|
651
|
+
case "denied":
|
|
652
|
+
throw new Error("pathClassToToolCategory called with denied");
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
function bashCategoryToToolCategory(c) {
|
|
656
|
+
switch (c) {
|
|
657
|
+
case "bash-readonly":
|
|
658
|
+
return "bash-readonly";
|
|
659
|
+
case "bash-mutate":
|
|
660
|
+
return "bash-mutate";
|
|
661
|
+
case "bash-commit":
|
|
662
|
+
return "bash-commit";
|
|
663
|
+
case "luca-write":
|
|
664
|
+
return "luca-write";
|
|
665
|
+
case "denied":
|
|
666
|
+
throw new Error("bashCategoryToToolCategory called with denied");
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const stageGateCommand = defineCommand({
|
|
671
|
+
meta: {
|
|
672
|
+
name: "stage-gate",
|
|
673
|
+
description: "PreToolUse stage-gate handler \u2014 parses stdin, checks phase rules, exits non-zero to block"
|
|
674
|
+
},
|
|
675
|
+
async run() {
|
|
676
|
+
const stdin = await Bun.stdin.text();
|
|
677
|
+
const result = await handleStageGateHook({
|
|
678
|
+
stdin,
|
|
679
|
+
log: (msg) => {
|
|
680
|
+
console.error(msg);
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
process.exit(result.exitCode);
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
const hookCommand = defineCommand({
|
|
687
|
+
meta: {
|
|
688
|
+
name: "hook",
|
|
689
|
+
description: "Hook handlers invoked by .claude/hooks/*.sh wrappers (not for direct user invocation)"
|
|
690
|
+
},
|
|
691
|
+
subCommands: {
|
|
692
|
+
"stage-gate": stageGateCommand
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
export { hookCommand };
|