@orchestrator-claude/definitions 3.30.2 → 3.31.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/agents/adversarial-reviewer.md +217 -0
- package/agents/azure-devops-engineer.md +805 -0
- package/agents/methodology-researcher.md +190 -0
- package/agents/operational-artifact-builder.md +173 -0
- package/agents/presentation-engineer.md +605 -0
- package/dist/scripts/generate-manifest.js +3 -3
- package/dist/scripts/generate-manifest.js.map +1 -1
- package/hooks/mailbox-listener.ts +246 -0
- package/hooks/pre-agent-dispatch.ts +57 -0
- package/hooks/sprint-registry.ts +85 -0
- package/hooks/verification-before-completion.ts +97 -0
- package/manifest.json +276 -3
- package/package.json +1 -1
- package/skills/artifact-generator/SKILL.md +385 -0
- package/skills/brainstorm-formalize/SKILL.md +272 -0
- package/skills/brainstorming/SKILL.md +167 -0
- package/skills/brainstorming/scripts/frame-template.html +214 -0
- package/skills/brainstorming/scripts/helper.js +88 -0
- package/skills/brainstorming/scripts/server.cjs +354 -0
- package/skills/brainstorming/scripts/start-server.sh +148 -0
- package/skills/brainstorming/scripts/stop-server.sh +56 -0
- package/skills/brainstorming/visual-companion.md +139 -0
- package/skills/compositional-intelligence/SKILL.md +93 -0
- package/skills/design-system-validated-loop/SKILL.md +366 -0
- package/skills/finishing-branch/SKILL.md +184 -0
- package/skills/orchestrator/SKILL.md +141 -0
- package/skills/persona-library/SKILL.md +365 -0
- package/skills/project-management-enterprise/SKILL.md +304 -0
- package/skills/receiving-code-review/SKILL.md +186 -0
- package/skills/sprint-launch/SKILL.md +176 -0
- package/skills/sprint-teammate/sprint-teammate.md +107 -0
- package/skills/systematic-debugging/SKILL.md +262 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mailbox-listener.ts — PostToolUse + SessionStart hook (RFC-025 v3.1)
|
|
3
|
+
*
|
|
4
|
+
* PostToolUse: Checks the agent's mailbox inbox after every tool call.
|
|
5
|
+
* SessionStart: Consumes inbox at session boot so agents start with context.
|
|
6
|
+
*
|
|
7
|
+
* Environment variables (set by WorktreeIsolator in sprint worktrees):
|
|
8
|
+
* SPRINT_ID — tmux session name (e.g., "sprint-cd213cab")
|
|
9
|
+
* PANE_ROLE — agent's mailbox address (e.g., "implementer-0")
|
|
10
|
+
*
|
|
11
|
+
* When NOT in a sprint (no env vars), exits silently with zero overhead.
|
|
12
|
+
*
|
|
13
|
+
* Message format: RFC-025 MessageEnvelope (Zod-validated JSON files).
|
|
14
|
+
* Consume semantics: read → validate → delete (FIFO order).
|
|
15
|
+
* Invalid files are moved to quarantine/ (not lost, not injected).
|
|
16
|
+
*
|
|
17
|
+
* SessionStart branch:
|
|
18
|
+
* - Reads hook payload from stdin (JSON with hook_event_name)
|
|
19
|
+
* - If hook_event_name === "SessionStart" → consume inbox and outputContext("SessionStart", ...)
|
|
20
|
+
* - Backward compat: if process.stdin.isTTY → skip stdin read (PostToolUse path)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readdirSync, readFileSync, unlinkSync, renameSync, existsSync, mkdirSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { log, outputContext } from "./lib/api-client.js";
|
|
26
|
+
|
|
27
|
+
const HOOK = "MAILBOX-LISTENER";
|
|
28
|
+
|
|
29
|
+
// --- Types (minimal, no Zod dependency in hooks) ---
|
|
30
|
+
|
|
31
|
+
interface MailboxMessage {
|
|
32
|
+
id: string;
|
|
33
|
+
from: { role: string; index: number };
|
|
34
|
+
to: { role: string; index: number };
|
|
35
|
+
timestamp: string;
|
|
36
|
+
body: {
|
|
37
|
+
type: string;
|
|
38
|
+
taskId?: string;
|
|
39
|
+
title?: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
summary?: string;
|
|
42
|
+
progress?: number;
|
|
43
|
+
message?: string;
|
|
44
|
+
errorCode?: string;
|
|
45
|
+
acceptanceCriteria?: string[];
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Stdin reader (same pattern as sprint-registry.ts) ---
|
|
50
|
+
|
|
51
|
+
async function readStdinPayload(): Promise<Record<string, unknown>> {
|
|
52
|
+
const chunks: Buffer[] = [];
|
|
53
|
+
for await (const chunk of process.stdin) {
|
|
54
|
+
chunks.push(chunk as Buffer);
|
|
55
|
+
}
|
|
56
|
+
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
|
57
|
+
if (!raw) return {};
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
60
|
+
} catch {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- Core inbox consumption logic (shared between SessionStart and PostToolUse) ---
|
|
66
|
+
|
|
67
|
+
function consumeInbox(
|
|
68
|
+
inboxPath: string,
|
|
69
|
+
quarantinePath: string,
|
|
70
|
+
paneRole: string,
|
|
71
|
+
): { consumed: MailboxMessage[]; errors: string[] } {
|
|
72
|
+
// List .json files (exclude .tmp- in-flight writes)
|
|
73
|
+
let files: string[];
|
|
74
|
+
try {
|
|
75
|
+
files = readdirSync(inboxPath)
|
|
76
|
+
.filter((f) => f.endsWith(".json") && !f.startsWith(".tmp-"))
|
|
77
|
+
.sort(); // FIFO by filename (UUID-based, but sorted for determinism)
|
|
78
|
+
} catch {
|
|
79
|
+
return { consumed: [], errors: [] }; // Directory read error — fail silently
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (files.length === 0) {
|
|
83
|
+
return { consumed: [], errors: [] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
log(HOOK, `${files.length} message(s) in ${paneRole} inbox`);
|
|
87
|
+
|
|
88
|
+
const consumed: MailboxMessage[] = [];
|
|
89
|
+
const errors: string[] = [];
|
|
90
|
+
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
const filePath = join(inboxPath, file);
|
|
93
|
+
try {
|
|
94
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
95
|
+
const parsed = JSON.parse(raw) as MailboxMessage;
|
|
96
|
+
|
|
97
|
+
// Basic validation (no Zod in hooks — keep deps minimal)
|
|
98
|
+
if (!parsed.id || !parsed.from || !parsed.to || !parsed.body?.type) {
|
|
99
|
+
throw new Error(`Invalid envelope: missing required fields`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Consume: delete after successful parse
|
|
103
|
+
unlinkSync(filePath);
|
|
104
|
+
consumed.push(parsed);
|
|
105
|
+
log(HOOK, `Consumed: ${parsed.id} (${parsed.body.type} from ${parsed.from.role}-${parsed.from.index})`);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// Quarantine malformed message
|
|
108
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
109
|
+
errors.push(`${file}: ${errMsg}`);
|
|
110
|
+
log(HOOK, `Quarantine: ${file} — ${errMsg}`);
|
|
111
|
+
try {
|
|
112
|
+
mkdirSync(quarantinePath, { recursive: true });
|
|
113
|
+
renameSync(filePath, join(quarantinePath, file));
|
|
114
|
+
} catch {
|
|
115
|
+
// Best-effort quarantine
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { consumed, errors };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Context builder (shared between SessionStart and PostToolUse) ---
|
|
124
|
+
|
|
125
|
+
function buildContext(
|
|
126
|
+
consumed: MailboxMessage[],
|
|
127
|
+
errors: string[],
|
|
128
|
+
paneRole: string,
|
|
129
|
+
): string | null {
|
|
130
|
+
if (consumed.length === 0 && errors.length === 0) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const parts: string[] = [
|
|
135
|
+
`[MAILBOX] ${consumed.length} new message(s) for ${paneRole}:`,
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
for (const msg of consumed) {
|
|
139
|
+
const from = `${msg.from.role}-${msg.from.index}`;
|
|
140
|
+
parts.push("");
|
|
141
|
+
parts.push(`--- Message from ${from} (${msg.body.type}) ---`);
|
|
142
|
+
|
|
143
|
+
switch (msg.body.type) {
|
|
144
|
+
case "task_assignment":
|
|
145
|
+
parts.push(`Task: ${msg.body.taskId}`);
|
|
146
|
+
parts.push(`Title: ${msg.body.title}`);
|
|
147
|
+
if (msg.body.description) parts.push(`Description: ${msg.body.description}`);
|
|
148
|
+
if (msg.body.acceptanceCriteria?.length) {
|
|
149
|
+
parts.push(`Acceptance Criteria:`);
|
|
150
|
+
for (const ac of msg.body.acceptanceCriteria) {
|
|
151
|
+
parts.push(` - ${ac}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
|
|
156
|
+
case "task_complete":
|
|
157
|
+
parts.push(`Task: ${msg.body.taskId}`);
|
|
158
|
+
parts.push(`Summary: ${msg.body.summary}`);
|
|
159
|
+
break;
|
|
160
|
+
|
|
161
|
+
case "status_update":
|
|
162
|
+
parts.push(`Task: ${msg.body.taskId}`);
|
|
163
|
+
parts.push(`Progress: ${msg.body.progress}%`);
|
|
164
|
+
parts.push(`Status: ${msg.body.message}`);
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
case "error_report":
|
|
168
|
+
parts.push(`Error: ${msg.body.errorCode}`);
|
|
169
|
+
parts.push(`Message: ${msg.body.message}`);
|
|
170
|
+
if (msg.body.taskId) parts.push(`Task: ${msg.body.taskId}`);
|
|
171
|
+
break;
|
|
172
|
+
|
|
173
|
+
case "ping":
|
|
174
|
+
parts.push(`Ping received — respond with pong if appropriate.`);
|
|
175
|
+
break;
|
|
176
|
+
|
|
177
|
+
case "pong":
|
|
178
|
+
parts.push(`Pong received — heartbeat confirmed.`);
|
|
179
|
+
break;
|
|
180
|
+
|
|
181
|
+
default:
|
|
182
|
+
parts.push(`Type: ${msg.body.type}`);
|
|
183
|
+
parts.push(`Raw: ${JSON.stringify(msg.body)}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (errors.length > 0) {
|
|
188
|
+
parts.push("");
|
|
189
|
+
parts.push(`[MAILBOX-WARN] ${errors.length} malformed message(s) quarantined:`);
|
|
190
|
+
for (const e of errors) {
|
|
191
|
+
parts.push(` - ${e}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return parts.join("\n");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- Main ---
|
|
199
|
+
|
|
200
|
+
async function main(): Promise<void> {
|
|
201
|
+
const sprintId = process.env.SPRINT_ID;
|
|
202
|
+
const paneRole = process.env.PANE_ROLE;
|
|
203
|
+
|
|
204
|
+
// Not in a sprint — exit silently (zero overhead path)
|
|
205
|
+
if (!sprintId || !paneRole) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const inboxPath = `/tmp/${sprintId}/mailbox/${paneRole}/inbox`;
|
|
210
|
+
const quarantinePath = `/tmp/${sprintId}/mailbox/${paneRole}/quarantine`;
|
|
211
|
+
|
|
212
|
+
// No inbox directory — sprint may not have mailbox enabled
|
|
213
|
+
if (!existsSync(inboxPath)) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Determine hook event: read stdin unless this is an interactive TTY (PostToolUse path)
|
|
218
|
+
let hookEventName: string | undefined;
|
|
219
|
+
if (!process.stdin.isTTY) {
|
|
220
|
+
try {
|
|
221
|
+
const payload = await readStdinPayload();
|
|
222
|
+
hookEventName = payload.hook_event_name as string | undefined;
|
|
223
|
+
} catch {
|
|
224
|
+
// Stdin read failure — fall through to PostToolUse behaviour
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Consume inbox
|
|
229
|
+
const { consumed, errors } = consumeInbox(inboxPath, quarantinePath, paneRole);
|
|
230
|
+
|
|
231
|
+
if (consumed.length === 0 && errors.length === 0) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Build context string
|
|
236
|
+
const context = buildContext(consumed, errors, paneRole);
|
|
237
|
+
if (!context) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Emit context with the appropriate hook event name
|
|
242
|
+
const effectiveEvent = hookEventName === "SessionStart" ? "SessionStart" : "PostToolUse";
|
|
243
|
+
outputContext(effectiveEvent, context);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
void main();
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pre-agent-dispatch.ts — PreToolUse hook for Agent tool (RFC-027 Sprint 4)
|
|
3
|
+
*
|
|
4
|
+
* Warns when dispatching known agents without subagent_type.
|
|
5
|
+
* Non-blocking: always exits 0.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { log, readStdin, getField, outputContext } from "./lib/api-client.js";
|
|
9
|
+
|
|
10
|
+
const HOOK = "PRE-AGENT-DISPATCH";
|
|
11
|
+
|
|
12
|
+
const KNOWN_AGENTS = [
|
|
13
|
+
"implementer",
|
|
14
|
+
"planner",
|
|
15
|
+
"specifier",
|
|
16
|
+
"reviewer",
|
|
17
|
+
"task-generator",
|
|
18
|
+
"researcher",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const KNOWN_AGENTS_REGEX = new RegExp(
|
|
22
|
+
`\\b(${KNOWN_AGENTS.join("|")})\\b`,
|
|
23
|
+
"i"
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
async function main(): Promise<void> {
|
|
27
|
+
const stdin = await readStdin();
|
|
28
|
+
const toolInput = getField(stdin, "tool_input") || "";
|
|
29
|
+
const subagentType =
|
|
30
|
+
getField(stdin, "tool_input.subagent_type") ||
|
|
31
|
+
getField(stdin, "tool_input.subagentType") ||
|
|
32
|
+
"";
|
|
33
|
+
|
|
34
|
+
// If subagent_type is present, no warning needed
|
|
35
|
+
if (subagentType) {
|
|
36
|
+
log(HOOK, `Agent dispatched with subagent_type="${subagentType}", OK`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check if prompt mentions a known agent
|
|
41
|
+
const prompt =
|
|
42
|
+
getField(stdin, "tool_input.prompt") ||
|
|
43
|
+
getField(stdin, "tool_input.description") ||
|
|
44
|
+
(typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput));
|
|
45
|
+
|
|
46
|
+
const match = prompt.match(KNOWN_AGENTS_REGEX);
|
|
47
|
+
if (match) {
|
|
48
|
+
const detected = match[1].toLowerCase();
|
|
49
|
+
outputContext(
|
|
50
|
+
HOOK,
|
|
51
|
+
`[WARN] Agent dispatched without subagent_type. Known agent detected: "${detected}". Add subagent_type for proper frontmatter loading.`
|
|
52
|
+
);
|
|
53
|
+
log(HOOK, `WARN: known agent "${detected}" without subagent_type`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sprint-registry.ts — SessionStart self-registration hook (RFC-025 M4)
|
|
3
|
+
*
|
|
4
|
+
* Reads SPRINT_ID and PANE_ROLE from environment, then writes a registry
|
|
5
|
+
* entry to /tmp/sprint-<SPRINT_ID>/registry/<PANE_ROLE>.json.
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: No imports from scripts/lib/ — uses raw Node.js fs only.
|
|
8
|
+
* This hook runs in isolated worktree environments where scripts/lib may
|
|
9
|
+
* not be available on the module resolution path.
|
|
10
|
+
*
|
|
11
|
+
* Hook input (stdin): Claude Code SessionStart JSON payload (ignored).
|
|
12
|
+
* Exit 0 always — registration failure must not block the session.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
function log(msg: string): void {
|
|
19
|
+
process.stderr.write(`[sprint-registry] ${msg}\n`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Read hook JSON payload from stdin (async, same pattern as api-client.ts). */
|
|
23
|
+
async function readStdin(): Promise<Record<string, unknown>> {
|
|
24
|
+
const chunks: Buffer[] = [];
|
|
25
|
+
for await (const chunk of process.stdin) {
|
|
26
|
+
chunks.push(chunk as Buffer);
|
|
27
|
+
}
|
|
28
|
+
const raw = Buffer.concat(chunks).toString('utf-8').trim();
|
|
29
|
+
if (!raw) return {};
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function main(): Promise<void> {
|
|
38
|
+
// Read SessionStart payload from stdin — contains session_id
|
|
39
|
+
const stdin = await readStdin();
|
|
40
|
+
const sessionId = (stdin.session_id as string) ?? undefined;
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
// Read required env vars
|
|
44
|
+
const sprintId = process.env['SPRINT_ID'];
|
|
45
|
+
const paneRole = process.env['PANE_ROLE'];
|
|
46
|
+
|
|
47
|
+
if (!sprintId || !paneRole) {
|
|
48
|
+
log(`Skipping: SPRINT_ID=${sprintId ?? 'unset'} PANE_ROLE=${paneRole ?? 'unset'}`);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const registryDir = `/tmp/${sprintId}/registry`;
|
|
53
|
+
const entryPath = join(registryDir, `${paneRole}.json`);
|
|
54
|
+
|
|
55
|
+
const workflowId = process.env['WORKFLOW_ID'];
|
|
56
|
+
|
|
57
|
+
const entry: Record<string, unknown> = {
|
|
58
|
+
role: paneRole,
|
|
59
|
+
sprintId,
|
|
60
|
+
pid: process.pid,
|
|
61
|
+
registeredAt: new Date().toISOString(),
|
|
62
|
+
worktreePath: process.cwd(),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (sessionId) {
|
|
66
|
+
entry.sessionId = sessionId;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (workflowId) {
|
|
70
|
+
entry.workflowId = workflowId;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
mkdirSync(registryDir, { recursive: true });
|
|
75
|
+
writeFileSync(entryPath, JSON.stringify(entry, null, 2), 'utf-8');
|
|
76
|
+
log(`Registered ${paneRole} for sprint ${sprintId} (pid=${process.pid}, session=${sessionId ?? 'unknown'})`);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
// Log but do not fail — registration is best-effort
|
|
79
|
+
log(`Registration failed: ${err}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verification-before-completion.ts — PreToolUse[Bash] hook
|
|
3
|
+
*
|
|
4
|
+
* Injects a verification reminder when the model is about to run
|
|
5
|
+
* `git commit` or `git push`. Does NOT block — only adds context.
|
|
6
|
+
*
|
|
7
|
+
* Principle: Evidence before claims, always.
|
|
8
|
+
* Source: verification-before-completion skill (superpowers 5.0.7)
|
|
9
|
+
*
|
|
10
|
+
* Flow:
|
|
11
|
+
* 1. readStdin() → extract bash command
|
|
12
|
+
* 2. commandMatchesGitOp() → check for commit/push
|
|
13
|
+
* 3. If match → output allow + context reminder
|
|
14
|
+
* 4. If no match → silent pass-through (no output)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { log, readStdin, getField, outputPreToolUse } from "./lib/api-client.js";
|
|
18
|
+
|
|
19
|
+
const HOOK = "VERIFICATION-REMINDER";
|
|
20
|
+
|
|
21
|
+
// ─────────────────────────────────────────────────────────────
|
|
22
|
+
// Dependency injection interface (enables testing without IO)
|
|
23
|
+
// ─────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface HookDeps {
|
|
26
|
+
readStdin: () => Promise<Record<string, unknown>>;
|
|
27
|
+
output: (opts: { decision: "allow" | "deny"; reason?: string; context?: string }) => void;
|
|
28
|
+
log: (prefix: string, msg: string) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const defaultDeps: HookDeps = {
|
|
32
|
+
readStdin,
|
|
33
|
+
output: outputPreToolUse,
|
|
34
|
+
log,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ─────────────────────────────────────────────────────────────
|
|
38
|
+
// Pure helpers (exported for testing)
|
|
39
|
+
// ─────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns true when the bash command string contains a git commit or git push
|
|
43
|
+
* operation. Handles common variations: `git commit`, `git push`, multi-command
|
|
44
|
+
* pipelines (`&&`, `;`), and quoted forms.
|
|
45
|
+
*/
|
|
46
|
+
export function commandMatchesGitOp(command: string): boolean {
|
|
47
|
+
return /git\s+(commit|push)/.test(command);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─────────────────────────────────────────────────────────────
|
|
51
|
+
// Core logic (exported for testing)
|
|
52
|
+
// ─────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export async function mainWithDeps(deps: HookDeps): Promise<void> {
|
|
55
|
+
const { readStdin, output, log } = deps;
|
|
56
|
+
|
|
57
|
+
const stdin = await readStdin();
|
|
58
|
+
|
|
59
|
+
// Extract the bash command from the tool input envelope
|
|
60
|
+
const command =
|
|
61
|
+
(getField(stdin, "tool_input.command") as string | undefined) ||
|
|
62
|
+
(getField(stdin, "command") as string | undefined) ||
|
|
63
|
+
"";
|
|
64
|
+
|
|
65
|
+
if (!commandMatchesGitOp(command)) {
|
|
66
|
+
// Silent pass-through — not a git commit/push, nothing to do
|
|
67
|
+
log(HOOK, `PASS (no git op): ${command.slice(0, 60)}`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
log(HOOK, `REMIND git op detected: ${command.slice(0, 80)}`);
|
|
72
|
+
|
|
73
|
+
// Allow the command but inject a verification reminder as context.
|
|
74
|
+
// This hook NEVER blocks — it only nudges the model to verify first.
|
|
75
|
+
output({
|
|
76
|
+
decision: "allow",
|
|
77
|
+
context: [
|
|
78
|
+
"[VERIFICATION-REMINDER] You are about to run a git commit/push.",
|
|
79
|
+
"Before proceeding, confirm:",
|
|
80
|
+
" 1. Tests pass — run the test command and paste the output.",
|
|
81
|
+
" 2. Lint clean — run the lint command and confirm 0 errors.",
|
|
82
|
+
" 3. Build succeeds — run the build command and confirm exit 0.",
|
|
83
|
+
" 4. All acceptance criteria have matching test evidence.",
|
|
84
|
+
"Evidence before claims. No shortcuts for verification.",
|
|
85
|
+
].join("\n"),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─────────────────────────────────────────────────────────────
|
|
90
|
+
// Entry point (production use)
|
|
91
|
+
// ─────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
async function main(): Promise<void> {
|
|
94
|
+
await mainWithDeps(defaultDeps);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
main().catch(() => process.exit(0));
|