lockage 0.0.0-stage → 0.9.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 +21 -0
- package/README.md +355 -2
- package/dist/db/index.d.ts +52 -0
- package/dist/db/index.js +238 -0
- package/dist/db/schema.sql +238 -0
- package/dist/hook/clients.d.ts +56 -0
- package/dist/hook/clients.js +258 -0
- package/dist/hook/context.d.ts +22 -0
- package/dist/hook/context.js +40 -0
- package/dist/hook/dispatch.d.ts +21 -0
- package/dist/hook/dispatch.js +212 -0
- package/dist/hook/guard.d.ts +34 -0
- package/dist/hook/guard.js +106 -0
- package/dist/hook/repo.d.ts +40 -0
- package/dist/hook/repo.js +173 -0
- package/dist/hook/setup-kiro.d.ts +30 -0
- package/dist/hook/setup-kiro.js +118 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +694 -0
- package/dist/launcher/client.d.ts +22 -0
- package/dist/launcher/client.js +57 -0
- package/dist/launcher/publish-adapter.d.ts +88 -0
- package/dist/launcher/publish-adapter.js +233 -0
- package/dist/launcher/publish-cli.d.ts +2 -0
- package/dist/launcher/publish-cli.js +123 -0
- package/dist/launcher/publish.d.ts +140 -0
- package/dist/launcher/publish.js +522 -0
- package/dist/lockage/board.d.ts +247 -0
- package/dist/lockage/board.js +407 -0
- package/dist/lockage/claim.d.ts +307 -0
- package/dist/lockage/claim.js +580 -0
- package/dist/lockage/files.d.ts +229 -0
- package/dist/lockage/files.js +403 -0
- package/dist/lockage/git.d.ts +92 -0
- package/dist/lockage/git.js +183 -0
- package/dist/lockage/globs.d.ts +26 -0
- package/dist/lockage/globs.js +125 -0
- package/dist/lockage/merge.d.ts +132 -0
- package/dist/lockage/merge.js +831 -0
- package/dist/lockage/publish-lock.d.ts +38 -0
- package/dist/lockage/publish-lock.js +73 -0
- package/dist/lockage/tickets.d.ts +213 -0
- package/dist/lockage/tickets.js +340 -0
- package/dist/lockage/worktree.d.ts +212 -0
- package/dist/lockage/worktree.js +563 -0
- package/package.json +43 -4
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-client adapters. They call the pure logic in repo.ts / context.ts / guard.ts, parse each
|
|
3
|
+
* client's input (claude / antigravity / kiro), and build the response. Only the input/output
|
|
4
|
+
* shapes are client-specific; the decision logic itself stays inside the pure logic.
|
|
5
|
+
*
|
|
6
|
+
* The final control of exit codes and stdout/stderr is handled by the outermost shell in
|
|
7
|
+
* dispatch.ts. Here we decide "what to output", and dispatch.ts handles "how to output it" (write
|
|
8
|
+
* the JSON to stdout / set the exit code). Rather than returning both side effects (record, stderr
|
|
9
|
+
* log) and an "output payload", each adapter function returns a value natural to its client.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { buildContext, DEFAULT_HEADER } from './context.js';
|
|
15
|
+
import { decide, record } from './guard.js';
|
|
16
|
+
import { log, mainTreeRoot, repoState } from './repo.js';
|
|
17
|
+
/** The heading used by the kiro session (the kiro version of HEADER). */
|
|
18
|
+
const KIRO_HEADER = '## lockage coordinated development (lockage Kiro Power)\n';
|
|
19
|
+
/** Parse a JSON string. Unparseable / non-JSON is treated as an empty object. */
|
|
20
|
+
function parsePayload(stdin) {
|
|
21
|
+
try {
|
|
22
|
+
const v = JSON.parse(stdin);
|
|
23
|
+
return v && typeof v === 'object' ? v : {};
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Result of the claude guard. Returns the JSON to write to stdout (null if none). record is already
|
|
31
|
+
* performed as a side effect. For PreToolUse, if decide returns a reason, an ask JSON, otherwise no
|
|
32
|
+
* output. For PostToolUse, record and output nothing.
|
|
33
|
+
*/
|
|
34
|
+
export function claudeGuard(stdin) {
|
|
35
|
+
const payload = parsePayload(stdin);
|
|
36
|
+
if (payload.hook_event_name === 'PostToolUse') {
|
|
37
|
+
record(payload);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const reason = decide(payload);
|
|
41
|
+
if (reason === null)
|
|
42
|
+
return null;
|
|
43
|
+
return {
|
|
44
|
+
hookSpecificOutput: {
|
|
45
|
+
hookEventName: 'PreToolUse',
|
|
46
|
+
permissionDecision: 'ask',
|
|
47
|
+
permissionDecisionReason: reason,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build the fixed text for lockage-worker.
|
|
53
|
+
* Distinct from buildContext: even if the worker is started outside git, pass at least the location
|
|
54
|
+
* of the canonical rules.
|
|
55
|
+
*/
|
|
56
|
+
function buildWorkerContext(agentsMd, mainRoot) {
|
|
57
|
+
let context = DEFAULT_HEADER + `- canonical coordination rules: ${agentsMd} (read it with Read before starting work)\n`;
|
|
58
|
+
if (mainRoot !== null) {
|
|
59
|
+
context += `- repository of the launch location: ${mainRoot}\n`;
|
|
60
|
+
}
|
|
61
|
+
return context;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Result of the claude session. Returns the JSON to write to stdout (null if none).
|
|
65
|
+
* For SessionStart, no output if repoState(cwd) is null, otherwise buildContext as
|
|
66
|
+
* additionalContext. For SubagentStart, no output unless agent_type includes lockage-worker, in
|
|
67
|
+
* which case buildWorkerContext as additionalContext. The output JSON's hookEventName echoes the
|
|
68
|
+
* input event name.
|
|
69
|
+
*/
|
|
70
|
+
export function claudeSession(stdin, agentsMd) {
|
|
71
|
+
const payload = parsePayload(stdin);
|
|
72
|
+
const event = payload.hook_event_name || 'SessionStart';
|
|
73
|
+
// Add nothing for subagents unrelated to lockage. Decide this before calling git
|
|
74
|
+
if (event === 'SubagentStart' && !(payload.agent_type || '').includes('lockage-worker')) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const st = repoState(payload.cwd || process.cwd());
|
|
78
|
+
let context;
|
|
79
|
+
if (event === 'SubagentStart') {
|
|
80
|
+
context = buildWorkerContext(agentsMd, st === null ? null : st.mainRoot);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
if (st === null)
|
|
84
|
+
return null; // inject nothing outside a git repository
|
|
85
|
+
context = buildContext(st, { agentsMd, readTool: 'Read' });
|
|
86
|
+
}
|
|
87
|
+
return { hookSpecificOutput: { hookEventName: event, additionalContext: context } };
|
|
88
|
+
}
|
|
89
|
+
/** Normalize antigravity input into the claude-shaped guard payload. */
|
|
90
|
+
function toGuardPayload(payload) {
|
|
91
|
+
const args = payload.toolCall?.args ?? {};
|
|
92
|
+
const workspaces = payload.workspacePaths ?? [];
|
|
93
|
+
return {
|
|
94
|
+
tool_input: { file_path: args.TargetFile ?? null },
|
|
95
|
+
// Resolve relative paths from the workspace (the hook's working directory depends on the caller)
|
|
96
|
+
cwd: workspaces.length > 0 ? workspaces[0] : null,
|
|
97
|
+
session_id: payload.conversationId ?? null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** The default response when the antigravity guard "passes" (built each time). */
|
|
101
|
+
function antigravityPass() {
|
|
102
|
+
return { decision: 'ask' };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Result of the antigravity guard. Always returns an output JSON (returning nothing is treated as
|
|
106
|
+
* deny, so always return a decision).
|
|
107
|
+
* pre: decide null→{decision:'ask'}, with a reason→{decision:'force_ask',reason}.
|
|
108
|
+
* post: record only when there is no error, always {}.
|
|
109
|
+
*/
|
|
110
|
+
export function antigravityGuard(stdin, event) {
|
|
111
|
+
const payload = parsePayload(stdin);
|
|
112
|
+
if (event === 'pre') {
|
|
113
|
+
const reason = decide(toGuardPayload(payload));
|
|
114
|
+
if (reason === null)
|
|
115
|
+
return antigravityPass();
|
|
116
|
+
return { decision: 'force_ask', reason };
|
|
117
|
+
}
|
|
118
|
+
// post: do not record when the tool failed (there is an error)
|
|
119
|
+
if (!payload.error) {
|
|
120
|
+
record(toGuardPayload(payload));
|
|
121
|
+
}
|
|
122
|
+
return {};
|
|
123
|
+
}
|
|
124
|
+
/** Whether a transcript line is an EPHEMERAL_MESSAGE we injected. */
|
|
125
|
+
function isOurMessage(line) {
|
|
126
|
+
// To avoid parsing every line even for a large transcript, narrow by string first, then parse
|
|
127
|
+
if (!line.includes('EPHEMERAL_MESSAGE'))
|
|
128
|
+
return false;
|
|
129
|
+
let step;
|
|
130
|
+
try {
|
|
131
|
+
step = JSON.parse(line);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
return step.type === 'EPHEMERAL_MESSAGE' && String(step.content ?? '').startsWith(DEFAULT_HEADER);
|
|
137
|
+
}
|
|
138
|
+
/** Expand a `~`-prefixed path with homedir (minimal version). */
|
|
139
|
+
function expandUser(path) {
|
|
140
|
+
if (path === '~')
|
|
141
|
+
return homedir();
|
|
142
|
+
if (path.startsWith('~/'))
|
|
143
|
+
return join(homedir(), path.slice(2));
|
|
144
|
+
return path;
|
|
145
|
+
}
|
|
146
|
+
/** Whether the text has already been injected into the conversation. Unreadable/not-yet-created is false. */
|
|
147
|
+
function alreadyInjected(transcriptPath) {
|
|
148
|
+
if (!transcriptPath)
|
|
149
|
+
return false;
|
|
150
|
+
let content;
|
|
151
|
+
try {
|
|
152
|
+
content = readFileSync(expandUser(transcriptPath), { encoding: 'utf8' });
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// The transcript is not written yet (first call) or unreadable. Prefer injecting again over missing it
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
return content.split('\n').some((line) => isOurMessage(line));
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Result of the antigravity session. Returns the JSON to inject (injectSteps or {}).
|
|
162
|
+
* If invocationNum!==0 or already injected, {}; otherwise concatenate buildContext over
|
|
163
|
+
* workspacePaths in order, deduplicating by repoState→mainRoot??toplevel.
|
|
164
|
+
*/
|
|
165
|
+
export function antigravitySession(stdin, agentsMd) {
|
|
166
|
+
const payload = parsePayload(stdin);
|
|
167
|
+
const message = buildAntigravityMessage(payload, agentsMd);
|
|
168
|
+
return message ? { injectSteps: [{ ephemeralMessage: message }] } : {};
|
|
169
|
+
}
|
|
170
|
+
function buildAntigravityMessage(payload, agentsMd) {
|
|
171
|
+
// Read the transcript only when invocationNum is 0 (do not re-read every time)
|
|
172
|
+
if (payload.invocationNum !== 0)
|
|
173
|
+
return null;
|
|
174
|
+
if (alreadyInjected(payload.transcriptPath))
|
|
175
|
+
return null;
|
|
176
|
+
const contexts = [];
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
const opts = { agentsMd, readTool: 'view_file' };
|
|
179
|
+
for (const workspace of payload.workspacePaths ?? []) {
|
|
180
|
+
const st = repoState(workspace);
|
|
181
|
+
if (st === null)
|
|
182
|
+
continue; // outside a git repository
|
|
183
|
+
const key = st.mainRoot ?? st.toplevel;
|
|
184
|
+
if (seen.has(key))
|
|
185
|
+
continue;
|
|
186
|
+
seen.add(key);
|
|
187
|
+
contexts.push(buildContext(st, opts));
|
|
188
|
+
}
|
|
189
|
+
return contexts.length > 0 ? contexts.join('\n') : null;
|
|
190
|
+
}
|
|
191
|
+
// --- kiro ---------------------------------------------------------------------
|
|
192
|
+
// Exit codes for the kiro guard. PreToolUse is fail-closed.
|
|
193
|
+
export const KIRO_EXIT_ALLOW = 0;
|
|
194
|
+
export const KIRO_EXIT_DENY = 2;
|
|
195
|
+
/**
|
|
196
|
+
* Decision result of the kiro guard. Returns an exit code. Messages to stderr are emitted inside
|
|
197
|
+
* this function.
|
|
198
|
+
* - PostToolUse → exit 0 (a defensive contract; not called by the generated hook)
|
|
199
|
+
* - path unavailable → exit 0 + stderr warning (the list of tool_input keys)
|
|
200
|
+
* - mainTreeRoot !== null → exit 2 + stderr message
|
|
201
|
+
* - otherwise → exit 0
|
|
202
|
+
* Exceptions (e.g. a git parse error) are made fail-closed (exit 2) by the outermost shell in
|
|
203
|
+
* dispatch.ts.
|
|
204
|
+
*/
|
|
205
|
+
export function kiroGuard(stdin) {
|
|
206
|
+
const payload = parsePayload(stdin);
|
|
207
|
+
// In Kiro, PostToolUse is only a post-hoc notification; this Power keeps no memory of allowances, so it does not decide (always allow)
|
|
208
|
+
if (payload.hook_event_name === 'PostToolUse')
|
|
209
|
+
return KIRO_EXIT_ALLOW;
|
|
210
|
+
const toolInput = payload.tool_input ?? {};
|
|
211
|
+
const path = toolInput.file_path || toolInput.path;
|
|
212
|
+
if (!path) {
|
|
213
|
+
const keys = Object.keys(payload.tool_input ?? {}).sort();
|
|
214
|
+
log(`Could not extract the edit target path from ${payload.tool_name}'s tool_input, so allowed without deciding` +
|
|
215
|
+
` (tool_input keys: [${keys.join(', ')}]; expected via file_path or path).`);
|
|
216
|
+
return KIRO_EXIT_ALLOW;
|
|
217
|
+
}
|
|
218
|
+
if (mainTreeRoot(path, payload.cwd ?? undefined) === null) {
|
|
219
|
+
return KIRO_EXIT_ALLOW;
|
|
220
|
+
}
|
|
221
|
+
process.stderr.write('This edit target is the main working tree of a repository operating under lockage. ' +
|
|
222
|
+
'If you are working as a lockage task, edit inside the worktree_path returned by assign_worktree. ' +
|
|
223
|
+
'If you must edit the main working tree directly, set the environment variable LOCKAGE_GUARD_DISABLE=1 to disable the guard.\n');
|
|
224
|
+
return KIRO_EXIT_DENY;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Result of the kiro session. Returns the text to write to stdout (null if none).
|
|
228
|
+
* No output if repoState(cwd) is null, otherwise buildContext (KIRO_HEADER) as stdout text.
|
|
229
|
+
* Appends a self-diagnostic at the end inside the kiro adapter.
|
|
230
|
+
*/
|
|
231
|
+
export function kiroSession(stdin, agentsMd) {
|
|
232
|
+
const payload = parsePayload(stdin);
|
|
233
|
+
const st = repoState(payload.cwd || process.cwd());
|
|
234
|
+
if (st === null)
|
|
235
|
+
return null; // inject nothing outside a git repository
|
|
236
|
+
let text = buildContext(st, { agentsMd, readTool: 'Read', header: KIRO_HEADER });
|
|
237
|
+
text += kiroGuardDiagnostic(st.mainRoot ?? st.toplevel);
|
|
238
|
+
return text;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Self-diagnostic for a missing guard hook. Looks at .kiro/hooks/lockage-guard.json directly under
|
|
242
|
+
* the main repository, and returns a one-line warning if it is missing. Returns an empty string
|
|
243
|
+
* (appends nothing) if present or on exception. This does not pollute buildContext itself; it is
|
|
244
|
+
* done only inside the kiro adapter.
|
|
245
|
+
*/
|
|
246
|
+
function kiroGuardDiagnostic(base) {
|
|
247
|
+
try {
|
|
248
|
+
const guardPath = join(base, '.kiro', 'hooks', 'lockage-guard.json');
|
|
249
|
+
if (!existsSync(guardPath)) {
|
|
250
|
+
return '- The guard hook is not installed. Run npx -y lockage setup kiro to install the guard hook.\n';
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// Swallow a diagnostic failure and append nothing (injection-only; never deny)
|
|
255
|
+
}
|
|
256
|
+
return '';
|
|
257
|
+
}
|
|
258
|
+
//# sourceMappingURL=clients.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text passed to the model at the start of a session (the repository state, and the location
|
|
3
|
+
* of the canonical coordination rules).
|
|
4
|
+
*
|
|
5
|
+
* How it is delivered (claude's SessionStart, antigravity's PreInvocation, kiro's SessionStart) is
|
|
6
|
+
* handled by each client's adapter (clients.ts).
|
|
7
|
+
*
|
|
8
|
+
* The body of AGENTS.md is not passed (it would exceed the client's additionalContext limit, so we
|
|
9
|
+
* pass only the path and have the model read it).
|
|
10
|
+
*/
|
|
11
|
+
import type { RepoState } from './repo.js';
|
|
12
|
+
export declare const DEFAULT_HEADER = "## lockage coordinated development (lockage plugin)\n";
|
|
13
|
+
/** Per-client parameters for buildContext. */
|
|
14
|
+
export interface BuildContextOptions {
|
|
15
|
+
agentsMd: string;
|
|
16
|
+
readTool: string;
|
|
17
|
+
header?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build the injected text.
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildContext(st: RepoState, opts: BuildContextOptions): string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text passed to the model at the start of a session (the repository state, and the location
|
|
3
|
+
* of the canonical coordination rules).
|
|
4
|
+
*
|
|
5
|
+
* How it is delivered (claude's SessionStart, antigravity's PreInvocation, kiro's SessionStart) is
|
|
6
|
+
* handled by each client's adapter (clients.ts).
|
|
7
|
+
*
|
|
8
|
+
* The body of AGENTS.md is not passed (it would exceed the client's additionalContext limit, so we
|
|
9
|
+
* pass only the path and have the model read it).
|
|
10
|
+
*/
|
|
11
|
+
import { inUse } from './repo.js';
|
|
12
|
+
// Default HEADER. Clients replace it (kiro overrides it with the "lockage Kiro Power" version).
|
|
13
|
+
// Keep it identical across clients (so the injected content does not change).
|
|
14
|
+
export const DEFAULT_HEADER = '## lockage coordinated development (lockage plugin)\n';
|
|
15
|
+
/**
|
|
16
|
+
* Build the injected text.
|
|
17
|
+
*/
|
|
18
|
+
export function buildContext(st, opts) {
|
|
19
|
+
const { agentsMd, readTool, header = DEFAULT_HEADER } = opts;
|
|
20
|
+
const tree = st.isLinked ? 'worktree' : 'main working tree';
|
|
21
|
+
const repoPath = st.mainRoot ?? 'cannot be determined (a layout such as --separate-git-dir; check and pass the location of the main repository)';
|
|
22
|
+
// lockage matches repos by path string, so passing just a name or a worktree path drops it from listings and checks
|
|
23
|
+
const head = header +
|
|
24
|
+
`- value to pass as repo_path: ${repoPath}\n` +
|
|
25
|
+
'- pass the same absolute path as repo_path to open_ticket\'s repos, create_task\'s repo, and lockage-publish\'s --repo-path as well\n' +
|
|
26
|
+
`- current working tree: ${st.toplevel} (${tree})\n`;
|
|
27
|
+
if (inUse(st)) {
|
|
28
|
+
return (head +
|
|
29
|
+
'- this repository is operating under lockage. Before starting work that changes files, or work ' +
|
|
30
|
+
`that handles lockage tickets/tasks, read the canonical coordination rules ${agentsMd} with ${readTool} and follow the lockage-workflow skill\'s steps.\n` +
|
|
31
|
+
'- do all editing only inside the worktree returned by assign_worktree, and do not edit the main working tree directly' +
|
|
32
|
+
' (the only exception is "when the user instructs you not to use lockage" at the top of AGENTS.md).\n' +
|
|
33
|
+
'- if the repository\'s AGENTS.md has a lockage section, treat it as that repository\'s specific rules and prefer it over the bundled canonical rules.\n');
|
|
34
|
+
}
|
|
35
|
+
// For a repository not operating under lockage, pass only the entry point to keep per-session overhead small
|
|
36
|
+
return (head +
|
|
37
|
+
'- this repository does not use lockage yet. If the user instructs you to proceed with lockage, ' +
|
|
38
|
+
`read the canonical coordination rules ${agentsMd} with ${readTool} and follow the lockage-workflow skill\'s steps.\n`);
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=context.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI dispatcher for `lockage hook <guard|session>` / `lockage setup kiro`.
|
|
3
|
+
*
|
|
4
|
+
* The entry section of src/index.ts dynamically imports and calls here only when argv[2]==='hook'
|
|
5
|
+
* || 'setup'. With no args or an unknown argv[2], index.ts starts the MCP server (main) as before,
|
|
6
|
+
* so we never reach here.
|
|
7
|
+
*
|
|
8
|
+
* design.md §1/§3 is authoritative. CLI forms:
|
|
9
|
+
* lockage hook guard --client <claude|antigravity|kiro> [--event pre|post] [--agents-md <path>]
|
|
10
|
+
* lockage hook session --client <claude|antigravity|kiro> [--agents-md <path>]
|
|
11
|
+
* lockage setup kiro [--repo <path>] [--force]
|
|
12
|
+
*
|
|
13
|
+
* The per-client exit-code/stdout control (claude/antigravity return a passing response + exit 0 on
|
|
14
|
+
* exception; kiro guard is fail-closed exit 2 on exception; kiro session is exit 0 on exception) is
|
|
15
|
+
* branched by this outermost shell according to --client and the subcommand type (the layer that
|
|
16
|
+
* owns the invariant "only kiro guard is fail-closed").
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* CLI entry point. argv is process.argv.slice(2) (first element is 'hook' or 'setup').
|
|
20
|
+
*/
|
|
21
|
+
export declare function dispatchCli(argv: string[]): Promise<void>;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI dispatcher for `lockage hook <guard|session>` / `lockage setup kiro`.
|
|
3
|
+
*
|
|
4
|
+
* The entry section of src/index.ts dynamically imports and calls here only when argv[2]==='hook'
|
|
5
|
+
* || 'setup'. With no args or an unknown argv[2], index.ts starts the MCP server (main) as before,
|
|
6
|
+
* so we never reach here.
|
|
7
|
+
*
|
|
8
|
+
* design.md §1/§3 is authoritative. CLI forms:
|
|
9
|
+
* lockage hook guard --client <claude|antigravity|kiro> [--event pre|post] [--agents-md <path>]
|
|
10
|
+
* lockage hook session --client <claude|antigravity|kiro> [--agents-md <path>]
|
|
11
|
+
* lockage setup kiro [--repo <path>] [--force]
|
|
12
|
+
*
|
|
13
|
+
* The per-client exit-code/stdout control (claude/antigravity return a passing response + exit 0 on
|
|
14
|
+
* exception; kiro guard is fail-closed exit 2 on exception; kiro session is exit 0 on exception) is
|
|
15
|
+
* branched by this outermost shell according to --client and the subcommand type (the layer that
|
|
16
|
+
* owns the invariant "only kiro guard is fail-closed").
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { parseArgs } from 'node:util';
|
|
20
|
+
import { antigravityGuard, antigravitySession, claudeGuard, claudeSession, KIRO_EXIT_ALLOW, KIRO_EXIT_DENY, kiroGuard, kiroSession, } from './clients.js';
|
|
21
|
+
import { log } from './repo.js';
|
|
22
|
+
import { setupKiro } from './setup-kiro.js';
|
|
23
|
+
const USAGE = `lockage hook <guard|session> --client <claude|antigravity|kiro> [--event pre|post] [--agents-md <path>]\n` +
|
|
24
|
+
`lockage setup kiro [--repo <path>] [--force]\n\n` +
|
|
25
|
+
`hook:\n` +
|
|
26
|
+
` guard Guard for editing the main working tree. Pass the hook payload JSON on stdin.\n` +
|
|
27
|
+
` session Context injection at session start. Pass the hook payload JSON on stdin.\n` +
|
|
28
|
+
` --client claude | antigravity | kiro (required)\n` +
|
|
29
|
+
` --event pre | post (required for antigravity guard; claude/kiro use the input's hook_event_name)\n` +
|
|
30
|
+
` --agents-md path to the canonical coordination rules (AGENTS.md) (only placed in the text)\n\n` +
|
|
31
|
+
`setup kiro:\n` +
|
|
32
|
+
` --repo <path> the target repository root (default cwd)\n` +
|
|
33
|
+
` --force overwrite an existing hook definition without confirmation\n`;
|
|
34
|
+
/** Write usage to stderr and exit non-zero (does not fall back to MCP). */
|
|
35
|
+
function usageError(msg) {
|
|
36
|
+
process.stderr.write(`lockage: ${msg}\n\n${USAGE}`);
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
|
39
|
+
/** Read all of stdin (called only by hook guard/session). Empty becomes an empty string. */
|
|
40
|
+
function readStdin() {
|
|
41
|
+
try {
|
|
42
|
+
return readFileSync(0, { encoding: 'utf8' });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// When there is no stdin / it cannot be read, treat it as an empty payload (clients.ts handles empty safely)
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Validate the --client value. */
|
|
50
|
+
function requireClient(value) {
|
|
51
|
+
if (value === 'claude' || value === 'antigravity' || value === 'kiro')
|
|
52
|
+
return value;
|
|
53
|
+
usageError(`--client must be one of claude|antigravity|kiro (received: ${value ?? '(none)'})`);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* CLI entry point. argv is process.argv.slice(2) (first element is 'hook' or 'setup').
|
|
57
|
+
*/
|
|
58
|
+
export async function dispatchCli(argv) {
|
|
59
|
+
const [group, sub, ...rest] = argv;
|
|
60
|
+
if (group === 'hook') {
|
|
61
|
+
return dispatchHook(sub, rest);
|
|
62
|
+
}
|
|
63
|
+
if (group === 'setup') {
|
|
64
|
+
return dispatchSetup(sub, rest);
|
|
65
|
+
}
|
|
66
|
+
// The entry section of index.ts calls here only for hook/setup, so we normally never reach here
|
|
67
|
+
usageError(`unknown subcommand: ${group ?? '(none)'}`);
|
|
68
|
+
}
|
|
69
|
+
/** Handle `lockage hook <guard|session> ...`. */
|
|
70
|
+
function dispatchHook(sub, rest) {
|
|
71
|
+
if (sub !== 'guard' && sub !== 'session') {
|
|
72
|
+
usageError(`the hook subcommand must be guard|session (received: ${sub ?? '(none)'})`);
|
|
73
|
+
}
|
|
74
|
+
const { values } = parseArgs({
|
|
75
|
+
args: rest,
|
|
76
|
+
options: {
|
|
77
|
+
client: { type: 'string' },
|
|
78
|
+
event: { type: 'string' },
|
|
79
|
+
'agents-md': { type: 'string' },
|
|
80
|
+
help: { type: 'boolean', short: 'h' },
|
|
81
|
+
},
|
|
82
|
+
allowPositionals: false,
|
|
83
|
+
});
|
|
84
|
+
if (values.help) {
|
|
85
|
+
process.stdout.write(USAGE);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const client = requireClient(values.client);
|
|
89
|
+
const agentsMd = values['agents-md'] ?? '';
|
|
90
|
+
if (sub === 'guard') {
|
|
91
|
+
dispatchGuard(client, values.event, agentsMd);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
dispatchSession(client, agentsMd);
|
|
95
|
+
}
|
|
96
|
+
/** Handle the guard subcommand per client. The outermost shell controls exit code and stdout. */
|
|
97
|
+
function dispatchGuard(client, event, _agentsMd) {
|
|
98
|
+
// Disable switch. Returns a path-dependent "pass" response and does not even read stdin (design.md §3)
|
|
99
|
+
const disabled = process.env.LOCKAGE_GUARD_DISABLE === '1';
|
|
100
|
+
if (client === 'claude') {
|
|
101
|
+
try {
|
|
102
|
+
if (disabled)
|
|
103
|
+
return; // no output, no record, exit immediately
|
|
104
|
+
const out = claudeGuard(readStdin());
|
|
105
|
+
if (out !== null)
|
|
106
|
+
process.stdout.write(JSON.stringify(out));
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
// When it cannot decide, pass (do not stop the edit itself due to a hook defect). No output + exit 0
|
|
110
|
+
log(`guard(claude) failed, so passed without deciding: ${String(e)}`);
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (client === 'antigravity') {
|
|
115
|
+
if (event !== 'pre' && event !== 'post') {
|
|
116
|
+
usageError('antigravity guard requires --event pre|post');
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
if (disabled) {
|
|
120
|
+
// pre→{decision:'ask'}, post→{} (mode-dependent, no record, does not read stdin)
|
|
121
|
+
process.stdout.write(JSON.stringify(event === 'pre' ? { decision: 'ask' } : {}));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const out = antigravityGuard(readStdin(), event);
|
|
125
|
+
process.stdout.write(JSON.stringify(out));
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
// When it cannot decide, leave it to the usual permission check (pre=ask / post={}) + exit 0
|
|
129
|
+
log(`guard(antigravity) failed, so passed without deciding: ${String(e)}`);
|
|
130
|
+
process.stdout.write(JSON.stringify(event === 'pre' ? { decision: 'ask' } : {}));
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// kiro: decide via exit code (fail-closed). Does not read stdin if disabled either
|
|
135
|
+
try {
|
|
136
|
+
if (disabled) {
|
|
137
|
+
process.exit(KIRO_EXIT_ALLOW);
|
|
138
|
+
}
|
|
139
|
+
process.exit(kiroGuard(readStdin()));
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
// On exception, fail safe and deny (exit 2)
|
|
143
|
+
log(`guard(kiro) failed, so failed safe and denied: ${String(e)}`);
|
|
144
|
+
process.exit(KIRO_EXIT_DENY);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Handle the session subcommand per client. On exception, all inject nothing + exit 0. */
|
|
148
|
+
function dispatchSession(client, agentsMd) {
|
|
149
|
+
if (client === 'claude') {
|
|
150
|
+
try {
|
|
151
|
+
const out = claudeSession(readStdin(), agentsMd);
|
|
152
|
+
if (out !== null)
|
|
153
|
+
process.stdout.write(JSON.stringify(out));
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
log(`session(claude) failed, so injected nothing: ${String(e)}`);
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (client === 'antigravity') {
|
|
161
|
+
try {
|
|
162
|
+
const out = antigravitySession(readStdin(), agentsMd);
|
|
163
|
+
process.stdout.write(JSON.stringify(out));
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
// Do not hinder the model invocation (emit {})
|
|
167
|
+
log(`session(antigravity) failed, so injected nothing: ${String(e)}`);
|
|
168
|
+
process.stdout.write(JSON.stringify({}));
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// kiro: text to stdout (not JSON). On exception, no output, exit 0
|
|
173
|
+
try {
|
|
174
|
+
const text = kiroSession(readStdin(), agentsMd);
|
|
175
|
+
if (text !== null)
|
|
176
|
+
process.stdout.write(text);
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
log(`session(kiro) failed, so injected nothing: ${String(e)}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Handle `lockage setup kiro ...`. */
|
|
183
|
+
function dispatchSetup(sub, rest) {
|
|
184
|
+
if (sub !== 'kiro') {
|
|
185
|
+
usageError(`the setup subcommand must be kiro (received: ${sub ?? '(none)'})`);
|
|
186
|
+
}
|
|
187
|
+
const { values } = parseArgs({
|
|
188
|
+
args: rest,
|
|
189
|
+
options: {
|
|
190
|
+
repo: { type: 'string' },
|
|
191
|
+
force: { type: 'boolean' },
|
|
192
|
+
help: { type: 'boolean', short: 'h' },
|
|
193
|
+
},
|
|
194
|
+
allowPositionals: false,
|
|
195
|
+
});
|
|
196
|
+
if (values.help) {
|
|
197
|
+
process.stdout.write(USAGE);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const result = setupKiro({ repo: values.repo, force: values.force });
|
|
202
|
+
for (const line of result.lines)
|
|
203
|
+
process.stdout.write(line + '\n');
|
|
204
|
+
process.exitCode = result.exitCode;
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
// A mkdir/write failure or a --repo pointing to a nonexistent directory is fatal (stderr + non-zero exit)
|
|
208
|
+
process.stderr.write(`lockage setup kiro: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=dispatch.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision for the main-working-tree edit guard. The input/output is normalized to the claude shape
|
|
3
|
+
* { tool_input, cwd, session_id } by each client's adapter (clients.ts) before being passed in.
|
|
4
|
+
*
|
|
5
|
+
* In a repository operating under lockage, if an edit targets a file in the main working tree rather
|
|
6
|
+
* than a worktree, interpose a confirmation. We use a confirmation instead of deny so as not to
|
|
7
|
+
* block the case where a person works on the main tree without using lockage (Kiro, which cannot
|
|
8
|
+
* show a confirmation, falls back to deny).
|
|
9
|
+
*
|
|
10
|
+
* The confirmation keeps appearing until it is allowed. Once allowed and the edit actually happens
|
|
11
|
+
* (PostToolUse), we no longer ask for that session and that repository. An operating repository
|
|
12
|
+
* always leaves traces such as a lockage/ branch or .lockage/, so asking every time would cause
|
|
13
|
+
* confirmation fatigue — leading to always allowing it, or disabling the guard entirely so it
|
|
14
|
+
* becomes a mere formality. We do not place the mark at PreToolUse time because the hook cannot
|
|
15
|
+
* distinguish allow from deny, which would let even a post-deny edit slip through.
|
|
16
|
+
*/
|
|
17
|
+
export declare const STATE_DIR: string;
|
|
18
|
+
/** The claude-shaped hook payload. antigravity / kiro inputs are normalized to this shape by the adapters. */
|
|
19
|
+
export interface GuardPayload {
|
|
20
|
+
tool_input?: {
|
|
21
|
+
file_path?: string | null;
|
|
22
|
+
notebook_path?: string | null;
|
|
23
|
+
} | null;
|
|
24
|
+
cwd?: string | null;
|
|
25
|
+
session_id?: string | null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* PreToolUse: returns the reason string if a confirmation should be interposed, or null if not.
|
|
29
|
+
*/
|
|
30
|
+
export declare function decide(payload: GuardPayload, stateDir?: string): string | null;
|
|
31
|
+
/**
|
|
32
|
+
* PostToolUse: record that an edit to the main working tree actually happened (= was allowed).
|
|
33
|
+
*/
|
|
34
|
+
export declare function record(payload: GuardPayload, stateDir?: string): void;
|