lockage 0.0.0-stage → 0.9.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +355 -2
  3. package/dist/db/index.d.ts +52 -0
  4. package/dist/db/index.js +238 -0
  5. package/dist/db/schema.sql +238 -0
  6. package/dist/hook/clients.d.ts +56 -0
  7. package/dist/hook/clients.js +258 -0
  8. package/dist/hook/context.d.ts +22 -0
  9. package/dist/hook/context.js +40 -0
  10. package/dist/hook/dispatch.d.ts +21 -0
  11. package/dist/hook/dispatch.js +212 -0
  12. package/dist/hook/guard.d.ts +34 -0
  13. package/dist/hook/guard.js +106 -0
  14. package/dist/hook/repo.d.ts +40 -0
  15. package/dist/hook/repo.js +173 -0
  16. package/dist/hook/setup-kiro.d.ts +30 -0
  17. package/dist/hook/setup-kiro.js +118 -0
  18. package/dist/index.d.ts +47 -0
  19. package/dist/index.js +694 -0
  20. package/dist/launcher/client.d.ts +22 -0
  21. package/dist/launcher/client.js +57 -0
  22. package/dist/launcher/publish-adapter.d.ts +88 -0
  23. package/dist/launcher/publish-adapter.js +233 -0
  24. package/dist/launcher/publish-cli.d.ts +2 -0
  25. package/dist/launcher/publish-cli.js +123 -0
  26. package/dist/launcher/publish.d.ts +140 -0
  27. package/dist/launcher/publish.js +522 -0
  28. package/dist/lockage/board.d.ts +247 -0
  29. package/dist/lockage/board.js +407 -0
  30. package/dist/lockage/claim.d.ts +307 -0
  31. package/dist/lockage/claim.js +580 -0
  32. package/dist/lockage/files.d.ts +229 -0
  33. package/dist/lockage/files.js +403 -0
  34. package/dist/lockage/git.d.ts +92 -0
  35. package/dist/lockage/git.js +183 -0
  36. package/dist/lockage/globs.d.ts +26 -0
  37. package/dist/lockage/globs.js +125 -0
  38. package/dist/lockage/merge.d.ts +132 -0
  39. package/dist/lockage/merge.js +831 -0
  40. package/dist/lockage/publish-lock.d.ts +38 -0
  41. package/dist/lockage/publish-lock.js +73 -0
  42. package/dist/lockage/tickets.d.ts +213 -0
  43. package/dist/lockage/tickets.js +340 -0
  44. package/dist/lockage/worktree.d.ts +212 -0
  45. package/dist/lockage/worktree.js +563 -0
  46. package/package.json +42 -3
@@ -0,0 +1,22 @@
1
+ export declare class LockageClient {
2
+ private client;
3
+ private transport;
4
+ /**
5
+ * @param args.toolsets the toolsets to enable on the server (#65). lockage-publish passes
6
+ * ['publisher']. If omitted, the caller environment's LOCKAGE_TOOLSETS is inherited as-is.
7
+ */
8
+ constructor(args: {
9
+ serverEntry: string;
10
+ dbPath: string;
11
+ nodePath?: string;
12
+ toolsets?: string[];
13
+ });
14
+ connect(): Promise<void>;
15
+ close(): Promise<void>;
16
+ private call;
17
+ /**
18
+ * The generic path to call any lockage tool. lockage-publish uses this path to call tools such as
19
+ * read_board / read_ticket / list_tasks / acquire_publish_lock.
20
+ */
21
+ callTool(name: string, args?: Record<string, unknown>): Promise<any>;
22
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The Lockage MCP client for lockage-publish (loosely coupled: it does not import the core and
3
+ * drives it over stdio instead).
4
+ *
5
+ * lockage-publish starts the Lockage server as a **separate process** and calls MCP tools. This
6
+ * preserves the separation that "even if publish crashes the core ledger is intact" and "the core
7
+ * does not know about publish".
8
+ */
9
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
10
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
11
+ /** Parse a tool result (the JSON in text content). */
12
+ function parseResult(result) {
13
+ const r = result;
14
+ if (r.isError) {
15
+ throw new Error(`lockage tool error: ${r.content?.[0]?.text ?? 'unknown'}`);
16
+ }
17
+ const text = r.content?.[0]?.text;
18
+ if (text == null)
19
+ throw new Error('lockage tool returned no text content');
20
+ return JSON.parse(text);
21
+ }
22
+ export class LockageClient {
23
+ client;
24
+ transport;
25
+ /**
26
+ * @param args.toolsets the toolsets to enable on the server (#65). lockage-publish passes
27
+ * ['publisher']. If omitted, the caller environment's LOCKAGE_TOOLSETS is inherited as-is.
28
+ */
29
+ constructor(args) {
30
+ const env = { ...process.env, LOCKAGE_DB: args.dbPath };
31
+ if (args.toolsets)
32
+ env.LOCKAGE_TOOLSETS = args.toolsets.join(',');
33
+ this.transport = new StdioClientTransport({
34
+ command: args.nodePath ?? process.execPath,
35
+ args: [args.serverEntry],
36
+ env,
37
+ });
38
+ this.client = new Client({ name: 'lockage-launcher', version: '0.0.0' });
39
+ }
40
+ async connect() {
41
+ await this.client.connect(this.transport);
42
+ }
43
+ async close() {
44
+ await this.client.close().catch(() => { });
45
+ }
46
+ async call(name, args) {
47
+ return parseResult(await this.client.callTool({ name, arguments: args }));
48
+ }
49
+ /**
50
+ * The generic path to call any lockage tool. lockage-publish uses this path to call tools such as
51
+ * read_board / read_ticket / list_tasks / acquire_publish_lock.
52
+ */
53
+ async callTool(name, args = {}) {
54
+ return this.call(name, args);
55
+ }
56
+ }
57
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,88 @@
1
+ /** The PR/MR state (the forge-independent normalized form). */
2
+ export type PrState = 'OPEN' | 'MERGED' | 'CLOSED';
3
+ /** The minimal PR info returned by findPrByHead / openPr. */
4
+ export interface PrRef {
5
+ number: number;
6
+ state: PrState;
7
+ url: string;
8
+ }
9
+ /**
10
+ * Indicates that openPr could not create a PR because there is "no diff from base".
11
+ * The caller (publishReadyTickets) folds this into a per-ticket skip (does not crash the loop).
12
+ */
13
+ export declare class PublishNoDiffError extends Error {
14
+ readonly head: string;
15
+ readonly base: string;
16
+ constructor(head: string, base: string);
17
+ }
18
+ /**
19
+ * Provider-independent publish operations. github=gh / gitlab=glab are handled through the same
20
+ * interface. design v1 §3 point 4.
21
+ */
22
+ export interface PublishAdapter {
23
+ readonly id: 'github' | 'gitlab';
24
+ /** Push the integration branch to the remote (a feature-branch push = not protected). */
25
+ pushBranch(repo: string, branch: string): Promise<void>;
26
+ /** Create a PR/MR and return the number and URL. The body goes via a tmp file (--body-file). */
27
+ openPr(args: {
28
+ repo: string;
29
+ base: string;
30
+ head: string;
31
+ title: string;
32
+ bodyFile: string;
33
+ }): Promise<PrRef>;
34
+ /**
35
+ * ★ The key to idempotency (design v1 §3 point 5-2). Find an existing PR tied to the head branch.
36
+ * Prevents double creation even if there is no pr_opened on the board (crash resilience). null if none.
37
+ */
38
+ findPrByHead(repo: string, branch: string): Promise<PrRef | null>;
39
+ /** Look up the current state from the PR number (merge detection = the cleanup trigger). */
40
+ prState(repo: string, number: number): Promise<PrState>;
41
+ /** Delete the remote integration branch (post-merge cleanup). */
42
+ deleteRemoteBranch(repo: string, branch: string): Promise<void>;
43
+ }
44
+ /**
45
+ * The GitHub implementation (Phase 1's first provider). Delegated to the gh CLI's existing login.
46
+ * push / deletion use git(); PR operations use gh.
47
+ */
48
+ export declare class GithubPublishAdapter implements PublishAdapter {
49
+ private readonly ghCmd;
50
+ readonly id: "github";
51
+ /** Allow the gh executable to be swapped in tests/environments (default 'gh'). */
52
+ constructor(ghCmd?: string);
53
+ /** Run gh repo-scoped (resolving owner/repo from the CWD's origin remote). */
54
+ private gh;
55
+ pushBranch(repo: string, branch: string): Promise<void>;
56
+ openPr(args: {
57
+ repo: string;
58
+ base: string;
59
+ head: string;
60
+ title: string;
61
+ bodyFile: string;
62
+ }): Promise<PrRef>;
63
+ findPrByHead(repo: string, branch: string): Promise<PrRef | null>;
64
+ prState(repo: string, number: number): Promise<PrState>;
65
+ deleteRemoteBranch(repo: string, branch: string): Promise<void>;
66
+ }
67
+ /**
68
+ * GitLab stub (not implemented in Phase 1; glab implementation in #4).
69
+ * design v1 §3 point 4 / D-B4. Satisfies only the interface and fails explicitly if called.
70
+ */
71
+ export declare class GitlabPublishAdapter implements PublishAdapter {
72
+ readonly id: "gitlab";
73
+ private notImplemented;
74
+ pushBranch(): Promise<void>;
75
+ openPr(): Promise<PrRef>;
76
+ findPrByHead(): Promise<PrRef | null>;
77
+ prState(): Promise<PrState>;
78
+ deleteRemoteBranch(): Promise<void>;
79
+ }
80
+ /**
81
+ * Determine the provider from the origin remote's URL (design v1 §3 point 4).
82
+ * An explicit value takes precedence. github.com→github / gitlab→gitlab.
83
+ */
84
+ export declare function detectProvider(repoPath: string, explicit?: 'github' | 'gitlab'): 'github' | 'gitlab';
85
+ /** Create a PublishAdapter from the provider. */
86
+ export declare function makePublishAdapter(provider: 'github' | 'gitlab', opts?: {
87
+ ghCmd?: string;
88
+ }): PublishAdapter;
@@ -0,0 +1,233 @@
1
+ /**
2
+ * The ③ publish adapter (problem B, B-1) — abstracts integration-branch push and PR/MR operations
3
+ * in a provider-independent way.
4
+ *
5
+ * design v1 §3 point 4 / §4.1. In line with NFR-11 (assumes GitHub/GitLab), it cuts a thin
6
+ * abstraction in the same style as ToolAdapter. To uphold "the core does not publish"
7
+ * (requirements-v1 §4.2), the publish/PR operations live here (the launcher), not in the core.
8
+ *
9
+ * - push / branch deletion are `git -C <repo>` scoped (reusing git() from src/lockage/git.ts).
10
+ * - PR operations are delegated to the existing login of the forge CLI (gh / glab) (lockage does not
11
+ * handle tokens).
12
+ * - Safety policy (lessons): push writes the branch name as a literal. No shell variable expansion
13
+ * or heredoc fusion. execFileSync passes argv directly, so it does not go through a shell and does
14
+ * not take a git-publish-floor shape.
15
+ */
16
+ import { execFileSync } from 'node:child_process';
17
+ import { git } from '../lockage/git.js';
18
+ /**
19
+ * Indicates that openPr could not create a PR because there is "no diff from base".
20
+ * The caller (publishReadyTickets) folds this into a per-ticket skip (does not crash the loop).
21
+ */
22
+ export class PublishNoDiffError extends Error {
23
+ head;
24
+ base;
25
+ constructor(head, base) {
26
+ super(`no commits between ${base} and ${head} (nothing to open a PR for)`);
27
+ this.head = head;
28
+ this.base = base;
29
+ this.name = 'PublishNoDiffError';
30
+ }
31
+ }
32
+ /** Extract the message string from an exception (execFileSync carries stdout/stderr). */
33
+ function errText(e) {
34
+ const err = e;
35
+ const parts = [err?.stderr?.toString(), err?.stdout?.toString(), err?.message];
36
+ return parts.filter(Boolean).join('\n');
37
+ }
38
+ /** Normalize the raw gh/glab state string into a PrState. */
39
+ function normalizeState(raw) {
40
+ const s = raw.trim().toUpperCase();
41
+ if (s === 'MERGED')
42
+ return 'MERGED';
43
+ if (s === 'CLOSED')
44
+ return 'CLOSED';
45
+ // OPEN / '' / unknown are treated as OPEN (safe side, since cleanup triggers only on MERGED).
46
+ return 'OPEN';
47
+ }
48
+ /**
49
+ * The GitHub implementation (Phase 1's first provider). Delegated to the gh CLI's existing login.
50
+ * push / deletion use git(); PR operations use gh.
51
+ */
52
+ export class GithubPublishAdapter {
53
+ ghCmd;
54
+ id = 'github';
55
+ /** Allow the gh executable to be swapped in tests/environments (default 'gh'). */
56
+ constructor(ghCmd = 'gh') {
57
+ this.ghCmd = ghCmd;
58
+ }
59
+ /** Run gh repo-scoped (resolving owner/repo from the CWD's origin remote). */
60
+ gh(repoPath, args) {
61
+ return execFileSync(this.ghCmd, args, {
62
+ cwd: repoPath, // gh resolves owner/repo from the CWD's git remote
63
+ encoding: 'utf8',
64
+ maxBuffer: 16 * 1024 * 1024,
65
+ }).trim();
66
+ }
67
+ async pushBranch(repo, branch) {
68
+ // Branch name written as a literal. execFileSync passes argv directly, so it does not go through
69
+ // shell expansion. On an existing-PR update (fix option A) this push makes the PR auto-follow too
70
+ // (the integration branch is derived from base and only appended to, so it is a fast-forward and
71
+ // does not need force).
72
+ git(repo, ['push', 'origin', branch]);
73
+ }
74
+ async openPr(args) {
75
+ // On success, gh pr create returns the created PR's URL on a single stdout line.
76
+ // On failure it exits non-zero and throws. Normalize the 2 cases hit in practice so the caller
77
+ // can fold them:
78
+ // (a) a PR with the same head is already open (a race with findPrByHead) → return the existing PR (= idempotent)
79
+ // (b) no diff from base (empty aggregation / already merged) → throw PublishNoDiffError
80
+ let url;
81
+ try {
82
+ url = this.gh(args.repo, [
83
+ 'pr',
84
+ 'create',
85
+ '--base',
86
+ args.base,
87
+ '--head',
88
+ args.head,
89
+ '--title',
90
+ args.title,
91
+ '--body-file',
92
+ args.bodyFile,
93
+ ]);
94
+ }
95
+ catch (e) {
96
+ const msg = errText(e);
97
+ // (a) already exists: re-fetch and return the existing PR (do not create a duplicate)
98
+ if (/already exists/i.test(msg)) {
99
+ const existing = await this.findPrByHead(args.repo, args.head);
100
+ if (existing)
101
+ return existing;
102
+ }
103
+ // (b) no diff: distinguish with a dedicated error so it can be cleaned up / skipped
104
+ if (/No commits between|No commits/i.test(msg)) {
105
+ throw new PublishNoDiffError(args.head, args.base);
106
+ }
107
+ throw e; // anything else is unexpected; the caller (per-ticket try/catch) catches it per ticket
108
+ }
109
+ // Right after creation, re-fetch by head to confirm the number/state (absorbs forge differences where the number is not available from the URL alone).
110
+ const created = await this.findPrByHead(args.repo, args.head);
111
+ if (created)
112
+ return created;
113
+ // Fallback: pick up the number at the end of the URL (even if findPrByHead is empty, creation did succeed).
114
+ const m = url.match(/\/(\d+)(?:[/?#].*)?$/);
115
+ const number = m ? Number(m[1]) : 0;
116
+ if (number === 0) {
117
+ // Not getting a number is a sign of an anomaly (the URL is valid but findPrByHead is also empty). Warn instead of swallowing.
118
+ console.error(`[publish] openPr: could not identify the PR number (head=${args.head}, url=${url})`);
119
+ }
120
+ return { number, state: 'OPEN', url };
121
+ }
122
+ async findPrByHead(repo, branch) {
123
+ // gh pr list --head <branch> --json number,state,url --state all --limit 1
124
+ // --state all also picks up MERGED/CLOSED (idempotent = do not re-create even if already merged).
125
+ // Note: --head matches the head *ref name*. In Phase 1 it is safe because it is the own
126
+ // repository and the integration branch name is ULID-derived and unique, but there is room
127
+ // for false positives with PRs from a fork or environments with same-named branches
128
+ // (if Phase 2 handles forks, the head owner must also be checked).
129
+ const raw = this.gh(repo, [
130
+ 'pr',
131
+ 'list',
132
+ '--head',
133
+ branch,
134
+ '--state',
135
+ 'all',
136
+ '--json',
137
+ 'number,state,url',
138
+ '--limit',
139
+ '1',
140
+ ]);
141
+ if (!raw)
142
+ return null;
143
+ let arr;
144
+ try {
145
+ arr = JSON.parse(raw);
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ if (!Array.isArray(arr) || arr.length === 0)
151
+ return null;
152
+ const pr = arr[0];
153
+ return { number: pr.number, state: normalizeState(pr.state), url: pr.url };
154
+ }
155
+ async prState(repo, number) {
156
+ const raw = this.gh(repo, ['pr', 'view', String(number), '--json', 'state']);
157
+ try {
158
+ const obj = JSON.parse(raw);
159
+ return normalizeState(obj.state ?? '');
160
+ }
161
+ catch {
162
+ return 'OPEN';
163
+ }
164
+ }
165
+ async deleteRemoteBranch(repo, branch) {
166
+ git(repo, ['push', 'origin', '--delete', branch]);
167
+ }
168
+ }
169
+ /**
170
+ * GitLab stub (not implemented in Phase 1; glab implementation in #4).
171
+ * design v1 §3 point 4 / D-B4. Satisfies only the interface and fails explicitly if called.
172
+ */
173
+ export class GitlabPublishAdapter {
174
+ id = 'gitlab';
175
+ notImplemented() {
176
+ throw new Error('GitlabPublishAdapter is not implemented yet (Phase1=github only, see design D-B4 / issue #4)');
177
+ }
178
+ async pushBranch() {
179
+ this.notImplemented();
180
+ }
181
+ async openPr() {
182
+ this.notImplemented();
183
+ }
184
+ async findPrByHead() {
185
+ this.notImplemented();
186
+ }
187
+ async prState() {
188
+ this.notImplemented();
189
+ }
190
+ async deleteRemoteBranch() {
191
+ this.notImplemented();
192
+ }
193
+ }
194
+ /**
195
+ * Determine the provider from the origin remote's URL (design v1 §3 point 4).
196
+ * An explicit value takes precedence. github.com→github / gitlab→gitlab.
197
+ */
198
+ export function detectProvider(repoPath, explicit) {
199
+ if (explicit)
200
+ return explicit;
201
+ let url = '';
202
+ try {
203
+ url = git(repoPath, ['remote', 'get-url', 'origin']);
204
+ }
205
+ catch {
206
+ return 'github'; // when the remote is unknown, default to github (Phase 1's first provider)
207
+ }
208
+ // Decide on the host part only (so a github repo whose path contains 'gitlab' is not misfired).
209
+ // Supported forms: git@gitlab.com:org/repo.git / https://gitlab.example.com/org/repo(.git) / ssh://git@gitlab…/…
210
+ const host = extractHost(url);
211
+ if (/gitlab/i.test(host))
212
+ return 'gitlab';
213
+ return 'github';
214
+ }
215
+ /** Extract the host part from a remote URL (scp-like / https / ssh). Empty string on failure. */
216
+ function extractHost(url) {
217
+ // scp-like: git@host:org/repo.git
218
+ const scp = url.match(/^[^/@]+@([^:/]+):/);
219
+ if (scp)
220
+ return scp[1];
221
+ // URL-like: scheme://[user@]host[:port]/...
222
+ const m = url.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^:/]+)/i);
223
+ if (m)
224
+ return m[1];
225
+ return '';
226
+ }
227
+ /** Create a PublishAdapter from the provider. */
228
+ export function makePublishAdapter(provider, opts) {
229
+ if (provider === 'gitlab')
230
+ return new GitlabPublishAdapter();
231
+ return new GithubPublishAdapter(opts?.ghCmd);
232
+ }
233
+ //# sourceMappingURL=publish-adapter.js.map
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lockage-publish — the ③ publish CLI (problem B, B-4).
4
+ *
5
+ * A 1-shot command that publishes unprocessed ticket_ready (push the integration branch + create a
6
+ * PR) and reconciles merged PRs (clean up the integration branch + reset integration_branch to
7
+ * NULL). It can be called from cron, an orchestrator, or by hand. Since "the core does not publish",
8
+ * it is a separate bin from the main binary (the lockage MCP server).
9
+ *
10
+ * Usage:
11
+ * lockage-publish --repo-path <abs> [--mode both|publish|reconcile]
12
+ * [--ticket <id>] [--base <branch>] [--provider github|gitlab]
13
+ * [--agent-id publisher] [--db <path>] [--server-entry <p>]
14
+ *
15
+ * Defaults:
16
+ * --mode : both (run publish → reconcile in order; the natural sweep for periodic runs)
17
+ * --base : resolveBaseBranch(repo) (infer dev/stg/main/master from what exists)
18
+ * --provider : determined from the origin remote's host (github by default)
19
+ * --agent-id : "publisher" (the author of pr_opened/pr_merged)
20
+ * --db : $LOCKAGE_DB → ~/.lockage/lockage.sqlite (the cross-repo ledger)
21
+ * --server-entry : the index.js in the same dist as this CLI
22
+ */
23
+ import { parseArgs } from 'node:util';
24
+ import { homedir } from 'node:os';
25
+ import { join, dirname, resolve } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { resolveBaseBranch } from '../lockage/git.js';
28
+ import { LockageClient } from './client.js';
29
+ import { publishReadyTickets, reconcileMergedPrs } from './publish.js';
30
+ import { makePublishAdapter, detectProvider } from './publish-adapter.js';
31
+ const USAGE = `lockage-publish — turn ticket_ready into PRs and clean up merged PRs (problem B)\n\n` +
32
+ `Required:\n` +
33
+ ` --repo-path <abs> absolute path to the local repo where the integration branch exists\n\n` +
34
+ `Optional:\n` +
35
+ ` --mode <m> both (default) | publish | reconcile\n` +
36
+ ` --ticket <id> process only this ticket (omit to process all unprocessed)\n` +
37
+ ` --base <branch> the PR base (inferred via resolveBaseBranch if omitted)\n` +
38
+ ` --provider <p> github (default) | gitlab (auto-detected from the origin host)\n` +
39
+ ` --agent-id <id> the author of pr_opened/pr_merged (default publisher)\n` +
40
+ ` --db <path> the cross-repo ledger (default $LOCKAGE_DB → ~/.lockage/lockage.sqlite)\n` +
41
+ ` --server-entry <p> the lockage server's dist/index.js (default: bundled)\n`;
42
+ async function main() {
43
+ const { values } = parseArgs({
44
+ options: {
45
+ 'repo-path': { type: 'string' },
46
+ mode: { type: 'string', default: 'both' },
47
+ ticket: { type: 'string' },
48
+ base: { type: 'string' },
49
+ provider: { type: 'string' },
50
+ 'agent-id': { type: 'string', default: 'publisher' },
51
+ db: { type: 'string' },
52
+ 'server-entry': { type: 'string' },
53
+ help: { type: 'boolean', short: 'h' },
54
+ },
55
+ });
56
+ const mode = values.mode ?? 'both';
57
+ if (values.help ||
58
+ !values['repo-path'] ||
59
+ (mode !== 'both' && mode !== 'publish' && mode !== 'reconcile')) {
60
+ process.stderr.write(USAGE);
61
+ process.exitCode = values.help ? 0 : 2;
62
+ return;
63
+ }
64
+ const repoPath = resolve(values['repo-path']);
65
+ const here = dirname(fileURLToPath(import.meta.url)); // dist/launcher
66
+ const serverEntry = values['server-entry']
67
+ ? resolve(values['server-entry'])
68
+ : join(here, '..', 'index.js'); // dist/index.js
69
+ const dbPath = values.db ?? process.env.LOCKAGE_DB ?? join(homedir(), '.lockage', 'lockage.sqlite');
70
+ const agentId = values['agent-id'];
71
+ const provider = values.provider === 'github' || values.provider === 'gitlab'
72
+ ? values.provider
73
+ : detectProvider(repoPath);
74
+ const adapter = makePublishAdapter(provider);
75
+ // base is only needed for publish. Explicit > resolveBaseBranch(repo).
76
+ let base = values.base;
77
+ if (!base) {
78
+ try {
79
+ base = resolveBaseBranch(repoPath);
80
+ }
81
+ catch {
82
+ base = 'main';
83
+ }
84
+ }
85
+ const client = new LockageClient({ serverEntry, dbPath, toolsets: ['publisher'] });
86
+ await client.connect();
87
+ const result = { provider, mode, repoPath };
88
+ let hadFailure = false;
89
+ try {
90
+ if (mode === 'both' || mode === 'publish') {
91
+ const published = await publishReadyTickets(client, adapter, {
92
+ repoPath,
93
+ base: base,
94
+ agentId,
95
+ onlyTicket: values.ticket,
96
+ });
97
+ result.published = published;
98
+ if (published.some((o) => o.result === 'failed'))
99
+ hadFailure = true;
100
+ }
101
+ if (mode === 'both' || mode === 'reconcile') {
102
+ const reconciled = await reconcileMergedPrs(client, adapter, {
103
+ repoPath,
104
+ agentId,
105
+ onlyTicket: values.ticket,
106
+ });
107
+ result.reconciled = reconciled;
108
+ if (reconciled.some((o) => o.result === 'failed'))
109
+ hadFailure = true;
110
+ }
111
+ }
112
+ finally {
113
+ await client.close();
114
+ }
115
+ process.stdout.write(JSON.stringify(result) + '\n');
116
+ // Non-zero if there is at least one per-ticket failure (so cron can detect an anomaly).
117
+ process.exitCode = hadFailure ? 1 : 0;
118
+ }
119
+ main().catch((e) => {
120
+ process.stderr.write(`lockage-publish fatal: ${e instanceof Error ? e.message : String(e)}\n`);
121
+ process.exitCode = 1;
122
+ });
123
+ //# sourceMappingURL=publish-cli.js.map
@@ -0,0 +1,140 @@
1
+ import type { LockageClient } from './client.js';
2
+ import { type PublishAdapter } from './publish-adapter.js';
3
+ /** The ticket returned by read_ticket (the minimal fields problem B references). */
4
+ interface TicketView {
5
+ id: string;
6
+ title: string;
7
+ body: string | null;
8
+ integration_branch: string | null;
9
+ ticket_ready_at: number | null;
10
+ }
11
+ /** The task returned by list_tasks (the minimal fields used in the PR body list). */
12
+ interface TaskView {
13
+ id: string;
14
+ title: string;
15
+ ticket_id: string | null;
16
+ }
17
+ /** The processing result of one ticket (for logs/tests). */
18
+ export type PublishOutcome = {
19
+ ticketId: string;
20
+ result: 'published';
21
+ prNumber: number;
22
+ url: string;
23
+ } | {
24
+ ticketId: string;
25
+ result: 'already_opened' | 'existing_pr';
26
+ prNumber?: number;
27
+ url?: string;
28
+ } | {
29
+ ticketId: string;
30
+ result: 'updated';
31
+ branch: string;
32
+ } | {
33
+ ticketId: string;
34
+ result: 'skipped';
35
+ reason: 'no_branch' | 'branch_not_local' | 'no_diff' | 'locked';
36
+ } | {
37
+ ticketId: string;
38
+ result: 'failed';
39
+ error: string;
40
+ };
41
+ export interface PublishArgs {
42
+ /** Absolute path to the local repo where the integration branch exists. */
43
+ repoPath: string;
44
+ /** The PR base branch. If omitted, the caller (CLI) resolves it via resolveBaseBranch and passes it. */
45
+ base: string;
46
+ /** The publisher's agent_id (the author of pr_opened). */
47
+ agentId: string;
48
+ /** Process only this ticket (manual/test). Omit to process all unprocessed ticket_ready. */
49
+ onlyTicket?: string;
50
+ /** Whether the integration branch exists locally (§3 point 9). For test substitution. Default = git show-ref. */
51
+ localBranchExists?: (repoPath: string, branch: string) => boolean;
52
+ }
53
+ /** Build the PR body (§3 point 7; Phase 1 omits Closes = recommended scope D-B2). */
54
+ export declare function renderPrBody(ticket: TicketView, tasks: TaskView[]): string;
55
+ /**
56
+ * Publish unprocessed ticket_ready (the implementation of the §4.4 pseudocode).
57
+ * With board's pr_opened as the source of truth, skip tickets that already have a PR.
58
+ * @returns the processing result of each ticket (for logs/tests).
59
+ */
60
+ export declare function publishReadyTickets(client: LockageClient, adapter: PublishAdapter, args: PublishArgs): Promise<PublishOutcome[]>;
61
+ /**
62
+ * The post-merge close result of a ticket (reconcile's MERGED path).
63
+ * - 'closed' : an open/in_progress ticket was flipped to closed.
64
+ * - 'already_closed': already closed (= do nothing).
65
+ * - 'not_found' : close_ticket_on_merge could not fetch the ticket (rare, since read_ticket just ran).
66
+ *
67
+ * close is done via a dedicated path that does not bump the version (close_ticket_on_merge /
68
+ * closeTicketOnMerge), so a version-CAS conflict cannot occur structurally (there is no conflict result).
69
+ */
70
+ export type TicketCloseResult = 'closed' | 'already_closed' | 'not_found';
71
+ /** The per-ticket result of reconcileMergedPrs (for logs/tests). */
72
+ export type ReconcileOutcome = {
73
+ ticketId: string;
74
+ prNumber: number;
75
+ result: 'merged';
76
+ branchDeleted: boolean;
77
+ cleared: boolean;
78
+ /** The result of closing the ticket after merge (fix: do not leave merged work open). */
79
+ ticketClosed: TicketCloseResult;
80
+ /** #32: number of done-task worktrees under the ticket that were released (successful removes). */
81
+ worktreesReleased: number;
82
+ /** Issue #45: number of claims tied to done / abandoned tasks under the ticket that were released. */
83
+ claimsReleased: number;
84
+ } | {
85
+ ticketId: string;
86
+ prNumber: number;
87
+ result: 'still_open';
88
+ } | {
89
+ ticketId: string;
90
+ prNumber: number;
91
+ result: 'skipped_unmerged_tasks';
92
+ } | {
93
+ ticketId: string;
94
+ prNumber: number;
95
+ result: 'failed';
96
+ error: string;
97
+ } | {
98
+ ticketId: string;
99
+ result: 'skipped';
100
+ reason: 'no_pr_number' | 'locked';
101
+ };
102
+ /**
103
+ * Merge-detection cleanup (problem B, B-3, design v1 §3 point 6).
104
+ * For tickets that are "pr_opened ∧ not pr_merged", check prState and, only for MERGED ones:
105
+ * ① delete the remote integration branch (deleteRemoteBranch)
106
+ * ② delete the local integration branch (git branch -D, best effort)
107
+ * ③ reset tickets.integration_branch to NULL (clear_integration_branch, with the expected_branch guard)
108
+ * ④' update tickets.status to closed (close_ticket_on_merge; a dedicated path that does not bump
109
+ * the version flips status one-way to closed = no version conflict. Does not leave merged work
110
+ * in list_tickets(status:"open"). Does nothing if already closed)
111
+ * ④ post pr_merged(ref_ticket_id) to the board ← this ticket is not re-processed afterward (the source of truth for idempotency)
112
+ * The skipped_unmerged_tasks path (unfinished tasks remain) does not do ③④'④ (= the ticket is not closed).
113
+ * It does not merge itself (that is a human/CI). No immediacy is needed = reclaim in bulk on the next run.
114
+ */
115
+ export declare function reconcileMergedPrs(client: LockageClient, adapter: PublishAdapter, args: {
116
+ repoPath: string;
117
+ agentId: string;
118
+ onlyTicket?: string;
119
+ /** Local branch deletion function (for test substitution). Default = git branch -D. */
120
+ deleteLocalBranch?: (repoPath: string, branch: string) => boolean;
121
+ }): Promise<ReconcileOutcome[]>;
122
+ /**
123
+ * Set the ticket to closed after merge (called only from reconcile's MERGED path).
124
+ *
125
+ * close is done via the dedicated path close_ticket_on_merge (the core's closeTicketOnMerge) that
126
+ * does not bump the version. With the same idea as clearIntegrationBranch (D-B1), it aligns
127
+ * reconcile's cleanup (integration_branch release / status close) symmetrically as "system
128
+ * operations that do not bump the version". As a result:
129
+ * - A local CAS that flips status one-way to closed (WHERE status != 'closed'), so a version
130
+ * conflict cannot occur structurally (= no conflict retry). It also does not steal the version
131
+ * of a human/other agent's concurrent edit (body, etc.).
132
+ * - Does nothing if already closed (changes==0) → 'already_closed'.
133
+ * - A missing ticket is 'not_found' (rarely reached since reconcile just ran read_ticket).
134
+ *
135
+ * The source of truth for idempotency remains the pr_merged posted by the caller reconcile. Since
136
+ * pr_merged is posted regardless of whether close succeeds, this ticket is dropped from reconcile
137
+ * targets on the next run, and no double close/cleanup happens.
138
+ */
139
+ export declare function closeTicketAfterMerge(client: LockageClient, ticketId: string): Promise<TicketCloseResult>;
140
+ export {};