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,106 @@
|
|
|
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
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { closeSync, mkdirSync, openSync, statSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { log, mainTreeRoot } from './repo.js';
|
|
22
|
+
// Windows has no getuid. Do not crash on read; fall back to asking every time without using the mark (usableStateDir).
|
|
23
|
+
const UID = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
24
|
+
// Where the "editing this repo's main working tree was allowed in this session" mark is placed. It
|
|
25
|
+
// is the OS temp directory, so it is cleared on reboot. Linux's /tmp is shared across all users, so
|
|
26
|
+
// separate it per user.
|
|
27
|
+
export const STATE_DIR = process.env.LOCKAGE_GUARD_STATE_DIR || join(tmpdir(), `lockage-plugin-guard-${UID}`);
|
|
28
|
+
/**
|
|
29
|
+
* true if a state directory writable only by us could be prepared.
|
|
30
|
+
*
|
|
31
|
+
* If another user created the same name first, they could place the mark and disable the guard, so
|
|
32
|
+
* we do not use it (we ask every time then). We also do not use it in an environment where we
|
|
33
|
+
* cannot verify ownership (no getuid).
|
|
34
|
+
*/
|
|
35
|
+
function usableStateDir(stateDir) {
|
|
36
|
+
if (UID === null)
|
|
37
|
+
return false;
|
|
38
|
+
let st;
|
|
39
|
+
try {
|
|
40
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
41
|
+
st = statSync(stateDir);
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
log(`Cannot prepare the state directory, so not using the confirmed record: ${String(e)}`);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (st.uid !== UID || (st.mode & 0o077) !== 0) {
|
|
48
|
+
log(`The state directory is not exclusively ours, so not using the confirmed record: ${stateDir}`);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
/** Resolve the main-working-tree edit target (the main repository root) from the payload. */
|
|
54
|
+
function mainTreeTarget(payload) {
|
|
55
|
+
const toolInput = payload.tool_input ?? {};
|
|
56
|
+
const path = toolInput.file_path || toolInput.notebook_path;
|
|
57
|
+
if (!path)
|
|
58
|
+
return null;
|
|
59
|
+
return mainTreeRoot(path, payload.cwd ?? undefined);
|
|
60
|
+
}
|
|
61
|
+
/** Determine the mark file path from the session_id and the main repository path. */
|
|
62
|
+
function markPath(sessionId, mainRoot, stateDir) {
|
|
63
|
+
const key = createHash('sha256').update(`${sessionId}\0${mainRoot}`).digest('hex');
|
|
64
|
+
return join(stateDir, key);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* PreToolUse: returns the reason string if a confirmation should be interposed, or null if not.
|
|
68
|
+
*/
|
|
69
|
+
export function decide(payload, stateDir = STATE_DIR) {
|
|
70
|
+
const mainRoot = mainTreeTarget(payload);
|
|
71
|
+
if (mainRoot === null)
|
|
72
|
+
return null;
|
|
73
|
+
const sessionId = payload.session_id;
|
|
74
|
+
if (sessionId &&
|
|
75
|
+
usableStateDir(stateDir) &&
|
|
76
|
+
existsSyncSafe(markPath(sessionId, mainRoot, stateDir))) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return (`${mainRoot} is a repository operating under lockage, and the edit targets its main working tree. ` +
|
|
80
|
+
'If you are working as a lockage task, edit inside the worktree_path returned by assign_worktree. ' +
|
|
81
|
+
'If you are editing the main tree directly as work that does not use lockage, go ahead and allow it. ' +
|
|
82
|
+
'If you allow it, this session will no longer confirm edits to this repository\'s main working tree.');
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* PostToolUse: record that an edit to the main working tree actually happened (= was allowed).
|
|
86
|
+
*/
|
|
87
|
+
export function record(payload, stateDir = STATE_DIR) {
|
|
88
|
+
const sessionId = payload.session_id;
|
|
89
|
+
if (!sessionId)
|
|
90
|
+
return; // when the session cannot be identified, do not record; ask every time
|
|
91
|
+
const mainRoot = mainTreeTarget(payload);
|
|
92
|
+
if (mainRoot === null || !usableStateDir(stateDir))
|
|
93
|
+
return;
|
|
94
|
+
closeSync(openSync(markPath(sessionId, mainRoot, stateDir), 'w'));
|
|
95
|
+
}
|
|
96
|
+
/** Check the mark file's existence (statSync throws on a nonexistent path, so swallow it). */
|
|
97
|
+
function existsSyncSafe(path) {
|
|
98
|
+
try {
|
|
99
|
+
statSync(path);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=guard.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository and lockage state decisions shared by the hooks.
|
|
3
|
+
*
|
|
4
|
+
* Each client's adapter (claude / antigravity / kiro) calls this pure logic and only handles the
|
|
5
|
+
* input/output fitting (the adapters themselves are in clients.ts).
|
|
6
|
+
*
|
|
7
|
+
* Since a hook does not stop the work even if it fails, git uses a swallowing wrapper (git() below)
|
|
8
|
+
* that returns null for non-zero exit, timeout, or being outside git. The git in src/lockage/git.ts
|
|
9
|
+
* is throw-based and serves a different purpose, so it is not reused here.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Repository state.
|
|
13
|
+
* in_use calls git, so it is split into the inUse(state) function to keep the policy of calling it
|
|
14
|
+
* only when needed.
|
|
15
|
+
*/
|
|
16
|
+
export interface RepoState {
|
|
17
|
+
mainRoot: string | null;
|
|
18
|
+
toplevel: string;
|
|
19
|
+
isLinked: boolean;
|
|
20
|
+
worktreeList: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Since a hook does not stop the work even if it fails, leave a clue on stderr when it silently
|
|
24
|
+
* becomes a no-op. Emits `[lockage-plugin] ...`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function log(msg: string): void;
|
|
27
|
+
/**
|
|
28
|
+
* Return whether the state has lockage operation traces.
|
|
29
|
+
* It calls git, so it is factored into a function to preserve the optimization where the caller
|
|
30
|
+
* (mainTreeRoot) looks at isLinked first and does not call it when unnecessary.
|
|
31
|
+
*/
|
|
32
|
+
export declare function inUse(state: RepoState): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Derive the state of the repository containing path. null if it cannot be determined, e.g. outside a git repository.
|
|
35
|
+
*/
|
|
36
|
+
export declare function repoState(path: string): RepoState | null;
|
|
37
|
+
/**
|
|
38
|
+
* If path is inside the main working tree of a repository operating under lockage, return that main repo's path.
|
|
39
|
+
*/
|
|
40
|
+
export declare function mainTreeRoot(path: string, cwd?: string): string | null;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository and lockage state decisions shared by the hooks.
|
|
3
|
+
*
|
|
4
|
+
* Each client's adapter (claude / antigravity / kiro) calls this pure logic and only handles the
|
|
5
|
+
* input/output fitting (the adapters themselves are in clients.ts).
|
|
6
|
+
*
|
|
7
|
+
* Since a hook does not stop the work even if it fails, git uses a swallowing wrapper (git() below)
|
|
8
|
+
* that returns null for non-zero exit, timeout, or being outside git. The git in src/lockage/git.ts
|
|
9
|
+
* is throw-based and serves a different purpose, so it is not reused here.
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
15
|
+
// Per-git-call limit. We call git at most 3 times per repository (twice in repoState, once in
|
|
16
|
+
// inUse), so keep the total under the hook timeout (10 seconds).
|
|
17
|
+
const GIT_TIMEOUT_MS = 2000;
|
|
18
|
+
/**
|
|
19
|
+
* Since a hook does not stop the work even if it fails, leave a clue on stderr when it silently
|
|
20
|
+
* becomes a no-op. Emits `[lockage-plugin] ...`.
|
|
21
|
+
*/
|
|
22
|
+
export function log(msg) {
|
|
23
|
+
process.stderr.write(`[lockage-plugin] ${msg}\n`);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The hook-only swallowing git wrapper. Runs `git -C <cwd> <args...>` and returns null for all of
|
|
27
|
+
* non-zero exit, timeout, and being outside git.
|
|
28
|
+
*
|
|
29
|
+
* execFileSync throws on both non-zero exit and timeout, so catch with try/catch.
|
|
30
|
+
* Log to stderr only on timeout (error.code === 'ETIMEDOUT' or error.killed).
|
|
31
|
+
*/
|
|
32
|
+
function git(cwd, ...args) {
|
|
33
|
+
try {
|
|
34
|
+
return execFileSync('git', ['-C', cwd, ...args], {
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
timeout: GIT_TIMEOUT_MS,
|
|
37
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
const err = e;
|
|
42
|
+
if (err.code === 'ETIMEDOUT' || err.killed) {
|
|
43
|
+
log(`git ${args[0]} did not finish within ${GIT_TIMEOUT_MS / 1000} seconds: ${cwd}`);
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Walk up to the nearest existing parent directory.
|
|
50
|
+
* A file newly created via Write may not even have its parent directory yet. Returns null if it reaches the root.
|
|
51
|
+
*/
|
|
52
|
+
function nearestExistingDir(path) {
|
|
53
|
+
let d = isDir(path) ? path : dirname(path);
|
|
54
|
+
while (d && !isDir(d)) {
|
|
55
|
+
const parent = dirname(d);
|
|
56
|
+
if (parent === d)
|
|
57
|
+
return null;
|
|
58
|
+
d = parent;
|
|
59
|
+
}
|
|
60
|
+
return d || null;
|
|
61
|
+
}
|
|
62
|
+
/** Whether path is an existing directory (statSync throws on a nonexistent path, so swallow it). */
|
|
63
|
+
function isDir(path) {
|
|
64
|
+
try {
|
|
65
|
+
return statSync(path).isDirectory();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* realpathSync may throw on a nonexistent path. We want to return a nonexistent path as-is, so if it
|
|
73
|
+
* throws, return the original path.
|
|
74
|
+
*/
|
|
75
|
+
function realpathSafe(path) {
|
|
76
|
+
try {
|
|
77
|
+
return realpathSync(path);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return path;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Return the main working tree. null if it cannot be derived.
|
|
85
|
+
*
|
|
86
|
+
* The head of the worktree list points to the main working tree. However, in a --separate-git-dir
|
|
87
|
+
* layout git itself does not remember the main working tree, and the .git entity (gitdir) appears at
|
|
88
|
+
* the head. A bare repo has no working tree.
|
|
89
|
+
*
|
|
90
|
+
* The "bare check" is done by exact-match membership against the line list. It is not a substring,
|
|
91
|
+
* so a worktree path containing the string bare is not misjudged.
|
|
92
|
+
*/
|
|
93
|
+
function mainWorktree(worktreeList, toplevel, isLinked) {
|
|
94
|
+
// Split the first block (up to the first blank line) into lines.
|
|
95
|
+
const firstBlock = worktreeList.split('\n\n', 1)[0];
|
|
96
|
+
const first = firstBlock.length === 0 ? [] : firstBlock.split('\n');
|
|
97
|
+
if (first.length > 0 && first[0].startsWith('worktree ') && !first.includes('bare')) {
|
|
98
|
+
const candidate = first[0].slice('worktree '.length);
|
|
99
|
+
if (existsSync(join(candidate, '.git'))) {
|
|
100
|
+
return candidate;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// If we are inside the main working tree, that is the main repo
|
|
104
|
+
return isLinked ? null : toplevel;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Whether there is evidence that lockage has been used in this repository.
|
|
108
|
+
* It calls git, so call it only when needed (the guard does not look for edits inside a worktree).
|
|
109
|
+
*/
|
|
110
|
+
function lockageInUse(toplevel, mainRoot, worktreeList) {
|
|
111
|
+
// Among the traces, look first at the one that survives worktree release (reconcile).
|
|
112
|
+
// release_worktree does not delete branches by default, so branches under lockage/ remain the longest
|
|
113
|
+
const refs = git(toplevel, 'for-each-ref', '--count=1', '--format=x', 'refs/heads/lockage/');
|
|
114
|
+
if (refs)
|
|
115
|
+
return true;
|
|
116
|
+
// assign_worktree digs a worktree under <repo parent>/.lockage-worktrees/
|
|
117
|
+
if (worktreeList.includes('/.lockage-worktrees/'))
|
|
118
|
+
return true;
|
|
119
|
+
// Treat a .lockage/ directly under the main working tree as a supplementary trace.
|
|
120
|
+
// The .lockage/ in the home directory is where lockage's ledger DB lives, so do not count it.
|
|
121
|
+
if (mainRoot === null || realpathSafe(mainRoot) === realpathSafe(homedir())) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return isDir(join(mainRoot, '.lockage'));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Return whether the state has lockage operation traces.
|
|
128
|
+
* It calls git, so it is factored into a function to preserve the optimization where the caller
|
|
129
|
+
* (mainTreeRoot) looks at isLinked first and does not call it when unnecessary.
|
|
130
|
+
*/
|
|
131
|
+
export function inUse(state) {
|
|
132
|
+
return lockageInUse(state.toplevel, state.mainRoot, state.worktreeList);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Derive the state of the repository containing path. null if it cannot be determined, e.g. outside a git repository.
|
|
136
|
+
*/
|
|
137
|
+
export function repoState(path) {
|
|
138
|
+
const d = nearestExistingDir(resolve(path));
|
|
139
|
+
if (d === null)
|
|
140
|
+
return null;
|
|
141
|
+
const out = git(d, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir');
|
|
142
|
+
if (out === null)
|
|
143
|
+
return null; // outside a git repository
|
|
144
|
+
if (out.startsWith('--')) {
|
|
145
|
+
// git before 2.31 does not know --path-format and, instead of erroring, prints it as-is and
|
|
146
|
+
// succeeds. The subsequent lines shift by one and return wrong paths, so give up the decision here
|
|
147
|
+
log(`git rev-parse did not interpret --path-format (possibly git before 2.31): ${d}`);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const lines = out.split('\n').filter((_, i, arr) => i < arr.length - 1 || arr[i].length > 0);
|
|
151
|
+
if (lines.length < 3)
|
|
152
|
+
return null; // no working tree, e.g. inside a bare repository
|
|
153
|
+
const [toplevel, gitDir, commonDir] = lines;
|
|
154
|
+
// Do not use commonDir's parent directory: in submodule, --separate-git-dir, or bare + worktree
|
|
155
|
+
// layouts it does not point to the main repo
|
|
156
|
+
const isLinked = realpathSafe(gitDir) !== realpathSafe(commonDir);
|
|
157
|
+
const worktreeList = git(toplevel, 'worktree', 'list', '--porcelain') ?? '';
|
|
158
|
+
const mainRoot = mainWorktree(worktreeList, toplevel, isLinked);
|
|
159
|
+
return { mainRoot, toplevel, isLinked, worktreeList };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* If path is inside the main working tree of a repository operating under lockage, return that main repo's path.
|
|
163
|
+
*/
|
|
164
|
+
export function mainTreeRoot(path, cwd) {
|
|
165
|
+
const abs = isAbsolute(path) ? path : join(cwd ?? process.cwd(), path);
|
|
166
|
+
const st = repoState(abs);
|
|
167
|
+
// Look at isLinked first. For edits inside a worktree (the most common path) this skips the inUse git call
|
|
168
|
+
if (st === null || st.isLinked || !inUse(st)) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return st.mainRoot;
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=repo.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The body of `lockage setup kiro`. Generates lockage-guard.json and lockage-session.json in the
|
|
3
|
+
* target repository's .kiro/hooks/ with npx-form commands (folding in a manual copy + absolute-path
|
|
4
|
+
* substitution).
|
|
5
|
+
*
|
|
6
|
+
* design.md §5 is authoritative. The install base is mainRoot ?? toplevel (the main repository root)
|
|
7
|
+
* from running --repo (default cwd) through repoState. If repoState is null (outside git), it is
|
|
8
|
+
* directly under --repo's real path. Even when run inside a worktree, the correct place for the hook
|
|
9
|
+
* is the main repository, so it is generated in .kiro/hooks/ directly under the main root.
|
|
10
|
+
*
|
|
11
|
+
* The outer schema of the generated JSON is identical to the bundled templates plugins/kiro/hooks/*.json
|
|
12
|
+
* (version/name/trigger/matcher/action). Kiro reads the hook via this outer shape, so dropping the
|
|
13
|
+
* structure makes it silently a no-op.
|
|
14
|
+
*/
|
|
15
|
+
/** Options for setup kiro. */
|
|
16
|
+
export interface SetupKiroOptions {
|
|
17
|
+
repo?: string;
|
|
18
|
+
force?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** The result of setup kiro (dispatch.ts maps it to an exit code). */
|
|
21
|
+
export interface SetupKiroResult {
|
|
22
|
+
exitCode: number;
|
|
23
|
+
lines: string[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The body of setup kiro. Creates .kiro/hooks/ and generates lockage-guard.json / lockage-session.json.
|
|
27
|
+
* A mkdir/write failure or a --repo pointing to a nonexistent directory throws (the outermost shell
|
|
28
|
+
* in dispatch.ts turns it into stderr + non-zero).
|
|
29
|
+
*/
|
|
30
|
+
export declare function setupKiro(opts: SetupKiroOptions): SetupKiroResult;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The body of `lockage setup kiro`. Generates lockage-guard.json and lockage-session.json in the
|
|
3
|
+
* target repository's .kiro/hooks/ with npx-form commands (folding in a manual copy + absolute-path
|
|
4
|
+
* substitution).
|
|
5
|
+
*
|
|
6
|
+
* design.md §5 is authoritative. The install base is mainRoot ?? toplevel (the main repository root)
|
|
7
|
+
* from running --repo (default cwd) through repoState. If repoState is null (outside git), it is
|
|
8
|
+
* directly under --repo's real path. Even when run inside a worktree, the correct place for the hook
|
|
9
|
+
* is the main repository, so it is generated in .kiro/hooks/ directly under the main root.
|
|
10
|
+
*
|
|
11
|
+
* The outer schema of the generated JSON is identical to the bundled templates plugins/kiro/hooks/*.json
|
|
12
|
+
* (version/name/trigger/matcher/action). Kiro reads the hook via this outer shape, so dropping the
|
|
13
|
+
* structure makes it silently a no-op.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
import { repoState } from './repo.js';
|
|
18
|
+
/** Build the contents of lockage-guard.json (2 PreToolUse matchers; no --agents-md). */
|
|
19
|
+
function guardDef() {
|
|
20
|
+
const command = 'npx -y lockage hook guard --client kiro';
|
|
21
|
+
return {
|
|
22
|
+
version: 'v1',
|
|
23
|
+
hooks: [
|
|
24
|
+
{
|
|
25
|
+
name: 'lockage-guard-fs-write',
|
|
26
|
+
trigger: 'PreToolUse',
|
|
27
|
+
matcher: 'fs_write',
|
|
28
|
+
action: { type: 'command', command },
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'lockage-guard-str-replace',
|
|
32
|
+
trigger: 'PreToolUse',
|
|
33
|
+
matcher: 'str_replace',
|
|
34
|
+
action: { type: 'command', command },
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Build the contents of lockage-session.json (1 SessionStart hook; embed base's steering path in --agents-md). */
|
|
40
|
+
function sessionDef(base) {
|
|
41
|
+
const agentsMd = join(base, '.kiro', 'steering', 'AGENTS.md');
|
|
42
|
+
return {
|
|
43
|
+
version: 'v1',
|
|
44
|
+
hooks: [
|
|
45
|
+
{
|
|
46
|
+
name: 'lockage-session',
|
|
47
|
+
trigger: 'SessionStart',
|
|
48
|
+
action: {
|
|
49
|
+
type: 'command',
|
|
50
|
+
command: `npx -y lockage hook session --client kiro --agents-md "${agentsMd}"`,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Turn the JSON into formatted text with a trailing newline (use the same formatting for existing-file comparison and writing). */
|
|
57
|
+
function serialize(def) {
|
|
58
|
+
return JSON.stringify(def, null, 2) + '\n';
|
|
59
|
+
}
|
|
60
|
+
/** Generate/compare one file. Returns the result (created/updated/unchanged/skipped-needs-force). */
|
|
61
|
+
function writeHookFile(path, content, force) {
|
|
62
|
+
if (existsSync(path)) {
|
|
63
|
+
const existing = readFileSync(path, { encoding: 'utf8' });
|
|
64
|
+
if (existing === content)
|
|
65
|
+
return 'unchanged';
|
|
66
|
+
if (!force)
|
|
67
|
+
return 'skipped-needs-force';
|
|
68
|
+
writeFileSync(path, content);
|
|
69
|
+
return 'updated';
|
|
70
|
+
}
|
|
71
|
+
writeFileSync(path, content);
|
|
72
|
+
return 'created';
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Derive the install base directory. mainRoot ?? toplevel from running --repo (default cwd) through
|
|
76
|
+
* repoState; if repoState is null, directly under --repo's real path (§5). Throws if --repo is a
|
|
77
|
+
* nonexistent directory.
|
|
78
|
+
*/
|
|
79
|
+
function resolveBase(repo) {
|
|
80
|
+
const abs = resolve(repo);
|
|
81
|
+
// A --repo pointing to a nonexistent directory is fatal (stderr + non-zero)
|
|
82
|
+
if (!existsSync(abs) || !statSync(abs).isDirectory()) {
|
|
83
|
+
throw new Error(`--repo is not an existing directory: ${abs}`);
|
|
84
|
+
}
|
|
85
|
+
const st = repoState(abs);
|
|
86
|
+
if (st === null)
|
|
87
|
+
return abs; // can also be placed in a directory outside git
|
|
88
|
+
return st.mainRoot ?? st.toplevel;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The body of setup kiro. Creates .kiro/hooks/ and generates lockage-guard.json / lockage-session.json.
|
|
92
|
+
* A mkdir/write failure or a --repo pointing to a nonexistent directory throws (the outermost shell
|
|
93
|
+
* in dispatch.ts turns it into stderr + non-zero).
|
|
94
|
+
*/
|
|
95
|
+
export function setupKiro(opts) {
|
|
96
|
+
const base = resolveBase(opts.repo ?? process.cwd());
|
|
97
|
+
const force = opts.force ?? false;
|
|
98
|
+
const hooksDir = join(base, '.kiro', 'hooks');
|
|
99
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
100
|
+
const targets = [
|
|
101
|
+
{ path: join(hooksDir, 'lockage-guard.json'), content: serialize(guardDef()) },
|
|
102
|
+
{ path: join(hooksDir, 'lockage-session.json'), content: serialize(sessionDef(base)) },
|
|
103
|
+
];
|
|
104
|
+
const lines = [];
|
|
105
|
+
let needsForce = false;
|
|
106
|
+
for (const { path, content } of targets) {
|
|
107
|
+
const result = writeHookFile(path, content, force);
|
|
108
|
+
if (result === 'skipped-needs-force') {
|
|
109
|
+
needsForce = true;
|
|
110
|
+
lines.push(`${path}: skipped-needs-force (differs from the existing file; overwrite with --force)`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
lines.push(`${path}: ${result}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { exitCode: needsForce ? 1 : 0, lines };
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=setup-kiro.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { type CoordDb } from './db/index.js';
|
|
4
|
+
/** Shared helper that returns a tool result as MCP text content (JSON). */
|
|
5
|
+
declare function ok(data: unknown): {
|
|
6
|
+
content: {
|
|
7
|
+
type: "text";
|
|
8
|
+
text: string;
|
|
9
|
+
}[];
|
|
10
|
+
};
|
|
11
|
+
declare function fail(message: string): {
|
|
12
|
+
isError: boolean;
|
|
13
|
+
content: {
|
|
14
|
+
type: "text";
|
|
15
|
+
text: string;
|
|
16
|
+
}[];
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Toolsets not registered by default (#65). Enabled at server startup via the LOCKAGE_TOOLSETS
|
|
20
|
+
* environment variable (comma-separated). Tools that belong to no toolset (core) are always
|
|
21
|
+
* registered. lockage-publish also uses core tools such as read_ticket and list_tasks, so there
|
|
22
|
+
* is no option to drop core.
|
|
23
|
+
*
|
|
24
|
+
* - publisher: cleanup tools called only by lockage-publish. If an agent calls them it could
|
|
25
|
+
* close tickets or clear integration branch names, so they are not shown in the agent's list.
|
|
26
|
+
* - maintenance: for maintenance/diagnostics. Enabled only when a human needs it.
|
|
27
|
+
*
|
|
28
|
+
* The actual registration split is the `if (toolsets.has(...))` in buildServer; this list exists
|
|
29
|
+
* to cross-check that against the README (drift is detected by tests). We do not register
|
|
30
|
+
* everything and then remove some, because the MCP SDK has no public API to look up a registered
|
|
31
|
+
* tool by name and remove it.
|
|
32
|
+
*/
|
|
33
|
+
export declare const OPTIONAL_TOOLSETS: {
|
|
34
|
+
readonly publisher: readonly ["acquire_publish_lock", "release_publish_lock", "clear_integration_branch", "close_ticket_on_merge", "release_ticket_worktrees", "release_ticket_claims"];
|
|
35
|
+
readonly maintenance: readonly ["reclaim_orphan_worktrees", "process_merge_queue", "list_merge_queue"];
|
|
36
|
+
};
|
|
37
|
+
export type OptionalToolset = keyof typeof OPTIONAL_TOOLSETS;
|
|
38
|
+
/**
|
|
39
|
+
* Turns the LOCKAGE_TOOLSETS value into a set of toolsets. `core` is always enabled, so it is
|
|
40
|
+
* ignored even if written. An unknown name is a configuration error (e.g. a typo), so instead of
|
|
41
|
+
* silently ignoring it we stop startup.
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseToolsets(value: string | undefined): Set<OptionalToolset>;
|
|
44
|
+
export declare function buildServer(cdb: CoordDb, opts?: {
|
|
45
|
+
toolsets?: Iterable<OptionalToolset>;
|
|
46
|
+
}): McpServer;
|
|
47
|
+
export { fail, ok };
|