claude-code-kanban 5.3.0 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/cli.js +10 -5
- package/install.js +43 -123
- package/lib/approvals.js +16 -17
- package/lib/git-branch.js +47 -0
- package/lib/parsers.js +96 -59
- package/lib/plugin-status.js +1 -1
- package/lib/session-cache.js +1 -1
- package/lib/terminal.js +2 -1
- package/package.json +1 -2
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +4 -4
- package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +300 -0
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +72 -0
- package/plugin/plugins/claude-code-kanban/tsconfig.json +3 -0
- package/public/app.js +8 -8
- package/server.js +106 -95
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
- package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +0 -18
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Watch the tour on YouTube: [light](https://youtu.be/QbvDBFyfC7s), [dark](https:/
|
|
|
19
19
|
|
|
20
20
|
## Getting started
|
|
21
21
|
|
|
22
|
-
You need Node.js 20 or later
|
|
22
|
+
You need Node.js 20 or later and the `claude` CLI.
|
|
23
23
|
|
|
24
24
|
### 1. Install the integration (one time)
|
|
25
25
|
|
|
@@ -27,7 +27,7 @@ You need Node.js 20 or later, the `claude` CLI, and `jq` for the hook scripts.
|
|
|
27
27
|
npx claude-code-kanban --install
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
The installer adds a Claude Code plugin with hooks
|
|
30
|
+
The installer adds a Claude Code plugin with hooks, skills and a mod for context use and cost (Claude Code 2.1.287 or later). It asks before it installs and keeps your other settings. Without the hooks, the board shows tasks only: no agent log, no live activity, no waiting prompts.
|
|
31
31
|
|
|
32
32
|
To remove it, run `npx claude-code-kanban --uninstall`. For another Claude config dir, pass the same `--dir=<path>` (or set `CLAUDE_CONFIG_DIR`) to `--install`, `--uninstall` and the server. See [Getting started](https://nikiforovall.blog/claude-code-kanban/getting-started/) for each install step.
|
|
33
33
|
|
package/cli.js
CHANGED
|
@@ -331,8 +331,8 @@ function printTopHelp() {
|
|
|
331
331
|
console.log(' --port <n> Port to listen on (default 3541)');
|
|
332
332
|
console.log(' --dir <path> Override Claude config dir (default ~/.claude); also targets --install/--uninstall');
|
|
333
333
|
console.log(' --open Open browser on start');
|
|
334
|
-
console.log(' --install, --uninstall Install or remove the plugin
|
|
335
|
-
console.log(' --
|
|
334
|
+
console.log(' --install, --uninstall Install or remove the plugin');
|
|
335
|
+
console.log(' --yes With --install: install without a prompt');
|
|
336
336
|
console.log('\nEnvironment:');
|
|
337
337
|
console.log(' CCK_URL Server base URL, e.g. http://127.0.0.1:4795 (wins over PORT)');
|
|
338
338
|
console.log(' PORT Server port (default: the one this config dir\'s server reports, else 3541)');
|
|
@@ -431,6 +431,8 @@ class CliUnreachable extends Error { constructor() { super(unreachable()); this.
|
|
|
431
431
|
// connects run at once. Nothing reached the server, so even a POST is safe to send again.
|
|
432
432
|
const CONNECT_RETRY_MS = [250, 500, 1000, 2000];
|
|
433
433
|
const isConnectTimeout = (e) => e.cause?.code === 'ETIMEDOUT' && e.cause?.syscall === 'connect';
|
|
434
|
+
// A loopback connection can also be reset after it opens. A GET is safe to send again; a write may have landed.
|
|
435
|
+
const isRetryable = (e, init) => isConnectTimeout(e) || (!init?.method && e.cause?.code === 'ECONNRESET');
|
|
434
436
|
|
|
435
437
|
async function cliFetch(urlPath, init) {
|
|
436
438
|
const base = cliBaseUrl();
|
|
@@ -439,7 +441,7 @@ async function cliFetch(urlPath, init) {
|
|
|
439
441
|
try {
|
|
440
442
|
return await fetch(`${base}${urlPath}`, init);
|
|
441
443
|
} catch (e) {
|
|
442
|
-
if (
|
|
444
|
+
if (isRetryable(e, init) && i < CONNECT_RETRY_MS.length) {
|
|
443
445
|
await new Promise((r) => setTimeout(r, CONNECT_RETRY_MS[i]));
|
|
444
446
|
continue;
|
|
445
447
|
}
|
|
@@ -810,9 +812,12 @@ async function runSessionViewCli(args) {
|
|
|
810
812
|
].filter(Boolean).join(' · ');
|
|
811
813
|
lines.push(` Model: ${modelName}${modelExtras ? ` (${modelExtras})` : ''}`);
|
|
812
814
|
if (cw.used_percentage != null) {
|
|
813
|
-
lines.push(` Context: ${cw.used_percentage}% used · ${fmtTok(cw.total_input_tokens)} in / ${fmtTok(cw.total_output_tokens)} out · cache ${fmtTok(cw.current_usage?.cache_read_input_tokens)} read`);
|
|
815
|
+
lines.push(` Context: ${cw.used_percentage}% used · ${fmtTok(cw.total_input_tokens)} in${cw.total_output_tokens != null ? ` / ${fmtTok(cw.total_output_tokens)} out` : ''} · cache ${fmtTok(cw.current_usage?.cache_read_input_tokens)} read`);
|
|
814
816
|
}
|
|
815
|
-
|
|
817
|
+
const timing = cost.total_duration_ms != null
|
|
818
|
+
? ` · ${cost.total_api_duration_ms != null ? formatAge(cost.total_api_duration_ms) : '-'} api / ${formatAge(cost.total_duration_ms)} total · +${cost.total_lines_added || 0}/-${cost.total_lines_removed || 0}`
|
|
819
|
+
: '';
|
|
820
|
+
lines.push(` Cost: ${fmtCost(cost.total_cost_usd)}${timing}`);
|
|
816
821
|
if (rl.five_hour || rl.seven_day) {
|
|
817
822
|
lines.push(` Limits: 5h ${rl.five_hour?.used_percentage ?? '-'}% · 7d ${rl.seven_day?.used_percentage ?? '-'}%`);
|
|
818
823
|
}
|
package/install.js
CHANGED
|
@@ -10,14 +10,13 @@ const { PLUGIN_ID } = require('./lib/plugin-status');
|
|
|
10
10
|
const CLAUDE_DIR = getClaudeDir();
|
|
11
11
|
const CLI_ENV = claudeCliEnv(CLAUDE_DIR);
|
|
12
12
|
const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
|
|
13
|
-
const HOOKS_DIR = path.join(CLAUDE_DIR, 'hooks');
|
|
14
13
|
const SETTINGS_PATH = path.join(CLAUDE_DIR, 'settings.json');
|
|
15
14
|
const PLUGIN_SRC = path.join(__dirname, 'plugin');
|
|
16
15
|
const PLUGIN_DEST = path.join(CCK_DIR, 'plugin');
|
|
16
|
+
// Earlier versions piped the statusLine through this script to capture context use; the plugin's mod does it now.
|
|
17
17
|
const CTX_SCRIPT_NAME = 'context-status.sh';
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const CTX_COMMAND = displayPath(CTX_SCRIPT_DEST);
|
|
18
|
+
const CTX_SCRIPT_DEST = path.join(CLAUDE_DIR, 'hooks', CTX_SCRIPT_NAME);
|
|
19
|
+
const MIN_CLAUDE_VERSION = [2, 1, 287];
|
|
21
20
|
|
|
22
21
|
// ANSI helpers
|
|
23
22
|
const green = s => `\x1b[32m${s}\x1b[0m`;
|
|
@@ -47,10 +46,37 @@ function runCLI(cmd, okPatterns = []) {
|
|
|
47
46
|
}
|
|
48
47
|
}
|
|
49
48
|
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
function isBelowMinVersion(versionOutput) {
|
|
50
|
+
const parts = /(\d+)\.(\d+)\.(\d+)/.exec(versionOutput)?.slice(1).map(Number);
|
|
51
|
+
if (!parts) return false;
|
|
52
|
+
const i = parts.findIndex((n, k) => n !== MIN_CLAUDE_VERSION[k]);
|
|
53
|
+
return i !== -1 && parts[i] < MIN_CLAUDE_VERSION[i];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function removeContextSpy() {
|
|
57
|
+
if (fs.existsSync(CTX_SCRIPT_DEST)) {
|
|
58
|
+
fs.unlinkSync(CTX_SCRIPT_DEST);
|
|
59
|
+
console.log(` Old context spy: ${green('✓')} Removed ${dim(displayPath(CTX_SCRIPT_DEST))}`);
|
|
60
|
+
}
|
|
61
|
+
if (!fs.existsSync(SETTINGS_PATH)) return;
|
|
62
|
+
let settings;
|
|
63
|
+
try {
|
|
64
|
+
settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
|
|
65
|
+
} catch {
|
|
66
|
+
console.log(` Settings: ${red('✗')} Could not parse settings.json`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const cmd = settings.statusLine?.command;
|
|
70
|
+
if (!cmd?.includes(CTX_SCRIPT_NAME)) return;
|
|
71
|
+
const stripped = cmd.replace(/\S*context-status\.sh\s*\|?\s*/, '').trim();
|
|
72
|
+
if (stripped) {
|
|
73
|
+
settings.statusLine.command = stripped;
|
|
74
|
+
console.log(` StatusLine: ${green('✓')} Restored to "${stripped}"`);
|
|
75
|
+
} else {
|
|
76
|
+
delete settings.statusLine;
|
|
77
|
+
console.log(` StatusLine: ${green('✓')} Removed`);
|
|
78
|
+
}
|
|
79
|
+
fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`);
|
|
54
80
|
}
|
|
55
81
|
|
|
56
82
|
// Deletes files but never directories: on Windows the running Claude Code process holds
|
|
@@ -80,8 +106,8 @@ function copyDirSync(src, dest) {
|
|
|
80
106
|
}
|
|
81
107
|
}
|
|
82
108
|
|
|
83
|
-
async function runInstall({
|
|
84
|
-
console.log(`\n ${bold('claude-code-kanban')} —
|
|
109
|
+
async function runInstall({ yes = false } = {}) {
|
|
110
|
+
console.log(`\n ${bold('claude-code-kanban')} — Plugin installer\n`);
|
|
85
111
|
console.log(` Claude config dir: ${dim(displayPath(CLAUDE_DIR))}\n`);
|
|
86
112
|
let failed = false;
|
|
87
113
|
|
|
@@ -90,23 +116,18 @@ async function runInstall({ pluginOnly = false } = {}) {
|
|
|
90
116
|
const claude = runCLI('claude --version');
|
|
91
117
|
if (claude.ok) {
|
|
92
118
|
console.log(green(`✓ found (${claude.output})`));
|
|
119
|
+
if (isBelowMinVersion(claude.output)) {
|
|
120
|
+
console.log(` ${yellow(`⚠ context use and cost tracking needs Claude Code ${MIN_CLAUDE_VERSION.join('.')} or later — run \`claude update\``)}`);
|
|
121
|
+
}
|
|
93
122
|
} else {
|
|
94
123
|
console.log(red('✗ claude CLI not found'));
|
|
95
124
|
console.log(` ${dim('Install Claude Code CLI first: https://docs.anthropic.com/en/docs/claude-code')}`);
|
|
96
125
|
return;
|
|
97
126
|
}
|
|
98
127
|
|
|
99
|
-
process.stdout.write(' Checking jq... ');
|
|
100
|
-
const jq = runCLI('jq --version');
|
|
101
|
-
if (jq.ok) {
|
|
102
|
-
console.log(green(`✓ found (${jq.output})`));
|
|
103
|
-
} else {
|
|
104
|
-
console.log(yellow('⚠ not found — hook scripts require jq for JSON parsing'));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
128
|
// 2. Copy plugin to stable location & register marketplace
|
|
108
129
|
console.log(`\n Plugin: ${dim(PLUGIN_DEST)}`);
|
|
109
|
-
if (
|
|
130
|
+
if (yes || await prompt(` Install claude-code-kanban plugin? [Y/n] `)) {
|
|
110
131
|
process.stdout.write(` Copying plugin to ${displayPath(PLUGIN_DEST)}... `);
|
|
111
132
|
try {
|
|
112
133
|
clearFilesRecursive(PLUGIN_DEST);
|
|
@@ -155,75 +176,8 @@ async function runInstall({ pluginOnly = false } = {}) {
|
|
|
155
176
|
console.log(` ${dim('Skipped')}`);
|
|
156
177
|
}
|
|
157
178
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
printSummary(failed);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// 3. StatusLine setup (context-status.sh must be copied globally since statusLine is not plugin-scoped)
|
|
165
|
-
console.log(`\n Context spy: ${dim(CTX_SCRIPT_DEST)}`);
|
|
166
|
-
let ctxInstalled = false;
|
|
167
|
-
if (fs.existsSync(CTX_SCRIPT_DEST)) {
|
|
168
|
-
const existing = fs.readFileSync(CTX_SCRIPT_DEST, 'utf8');
|
|
169
|
-
const bundled = fs.readFileSync(CTX_SCRIPT_SRC, 'utf8');
|
|
170
|
-
if (existing === bundled) {
|
|
171
|
-
console.log(` ${green('✓')} Up to date`);
|
|
172
|
-
ctxInstalled = true;
|
|
173
|
-
} else if (await prompt(` Different version found. Update? [Y/n] `)) {
|
|
174
|
-
copyScript(CTX_SCRIPT_SRC, CTX_SCRIPT_DEST);
|
|
175
|
-
console.log(` ${green('✓')} Updated`);
|
|
176
|
-
ctxInstalled = true;
|
|
177
|
-
} else {
|
|
178
|
-
console.log(` ${dim('Skipped')}`);
|
|
179
|
-
}
|
|
180
|
-
} else if (await prompt(` Not found. Install? [Y/n] `)) {
|
|
181
|
-
copyScript(CTX_SCRIPT_SRC, CTX_SCRIPT_DEST);
|
|
182
|
-
console.log(` ${green('✓')} Installed`);
|
|
183
|
-
ctxInstalled = true;
|
|
184
|
-
} else {
|
|
185
|
-
console.log(` ${dim('Skipped')}`);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// 4. StatusLine config in settings.json
|
|
189
|
-
if (ctxInstalled) {
|
|
190
|
-
let settings;
|
|
191
|
-
try {
|
|
192
|
-
settings = fs.existsSync(SETTINGS_PATH)
|
|
193
|
-
? JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'))
|
|
194
|
-
: {};
|
|
195
|
-
} catch {
|
|
196
|
-
console.log(` ${red('✗')} Malformed JSON in settings.json — skipping statusline config`);
|
|
197
|
-
printSummary(true);
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const hasCtx = settings.statusLine?.command?.includes(CTX_SCRIPT_NAME);
|
|
202
|
-
if (hasCtx) {
|
|
203
|
-
console.log(`\n StatusLine: ${green('✓')} Already configured`);
|
|
204
|
-
} else if (!settings.statusLine) {
|
|
205
|
-
console.log(`\n StatusLine: ${dim('not configured')}`);
|
|
206
|
-
if (await prompt(` Set up context tracking statusline? [Y/n] `)) {
|
|
207
|
-
settings.statusLine = { type: 'command', command: CTX_COMMAND };
|
|
208
|
-
fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`);
|
|
209
|
-
console.log(` ${green('✓')} StatusLine configured`);
|
|
210
|
-
} else {
|
|
211
|
-
console.log(` ${dim('Skipped')}`);
|
|
212
|
-
}
|
|
213
|
-
} else {
|
|
214
|
-
const existing = settings.statusLine.command;
|
|
215
|
-
console.log(`\n StatusLine: ${dim(`current: ${existing}`)}`);
|
|
216
|
-
if (await prompt(` Prepend context spy to existing statusline? [Y/n] `)) {
|
|
217
|
-
settings.statusLine.type = 'command';
|
|
218
|
-
settings.statusLine.command = `${CTX_COMMAND} | ${existing}`;
|
|
219
|
-
fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`);
|
|
220
|
-
console.log(` ${green('✓')} StatusLine updated`);
|
|
221
|
-
} else {
|
|
222
|
-
console.log(` ${dim('Skipped')}`);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
179
|
+
console.log('');
|
|
180
|
+
removeContextSpy();
|
|
227
181
|
printSummary(failed);
|
|
228
182
|
}
|
|
229
183
|
|
|
@@ -265,41 +219,7 @@ async function runUninstall() {
|
|
|
265
219
|
console.log(` Plugin copy: ${dim('Not found')}`);
|
|
266
220
|
}
|
|
267
221
|
|
|
268
|
-
|
|
269
|
-
if (fs.existsSync(CTX_SCRIPT_DEST)) {
|
|
270
|
-
fs.unlinkSync(CTX_SCRIPT_DEST);
|
|
271
|
-
console.log(` Context spy: ${green('✓')} Removed`);
|
|
272
|
-
} else {
|
|
273
|
-
console.log(` Context spy: ${dim('Not found')}`);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// 5. Clean up settings.json (statusLine)
|
|
277
|
-
if (fs.existsSync(SETTINGS_PATH)) {
|
|
278
|
-
try {
|
|
279
|
-
const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
|
|
280
|
-
let changed = false;
|
|
281
|
-
|
|
282
|
-
// Strip context-status.sh from statusLine
|
|
283
|
-
if (settings.statusLine?.command?.includes(CTX_SCRIPT_NAME)) {
|
|
284
|
-
const cmd = settings.statusLine.command;
|
|
285
|
-
const stripped = cmd.replace(new RegExp(`\\S*${CTX_SCRIPT_NAME.replace('.', '\\.')}\\s*\\|\\s*`), '').trim();
|
|
286
|
-
if (stripped && stripped !== cmd) {
|
|
287
|
-
settings.statusLine.command = stripped;
|
|
288
|
-
console.log(` StatusLine: ${green('✓')} Restored to "${stripped}"`);
|
|
289
|
-
} else {
|
|
290
|
-
delete settings.statusLine;
|
|
291
|
-
console.log(` StatusLine: ${green('✓')} Removed`);
|
|
292
|
-
}
|
|
293
|
-
changed = true;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
if (changed) {
|
|
297
|
-
fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`);
|
|
298
|
-
}
|
|
299
|
-
} catch {
|
|
300
|
-
console.log(` Settings: ${red('✗')} Could not parse settings.json`);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
222
|
+
removeContextSpy();
|
|
303
223
|
|
|
304
224
|
console.log(`\n ${green('Uninstall complete.')}\n`);
|
|
305
225
|
}
|
package/lib/approvals.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
// UI-driven approvals (_plans/cck-ui-approvals/):
|
|
1
|
+
// UI-driven approvals (_plans/cck-ui-approvals/): the plugin's mod writes the
|
|
2
2
|
// _waiting.json marker with a request id (D8) and polls for _decision-<id>.json.
|
|
3
3
|
// buildDecision validates a board response against the live marker and shapes
|
|
4
|
-
// the decision file; the route writes it.
|
|
5
|
-
//
|
|
4
|
+
// the decision file; the route writes it. Mods cannot delete files, so the mod
|
|
5
|
+
// clears the marker and the sweep removes decision files.
|
|
6
6
|
|
|
7
|
-
// Ids are
|
|
8
|
-
//
|
|
7
|
+
// Ids are mod-minted UUIDs — anything outside this alphabet is either
|
|
8
|
+
// corruption or a path-traversal attempt (3.5).
|
|
9
9
|
function sanitizeRequestId(raw) {
|
|
10
10
|
return typeof raw === 'string' && /^[a-zA-Z0-9-]{1,64}$/.test(raw) ? raw : null;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
// A decision whose shape doesn't match the ask's kind
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// A decision whose shape doesn't match the ask's kind cannot answer the call,
|
|
14
|
+
// surfacing as an opaque stall — reject at the API boundary instead (D7).
|
|
15
|
+
// Returns { error, status } or { decision }.
|
|
16
16
|
function buildDecision(marker, body) {
|
|
17
17
|
if (!marker || marker.status !== 'waiting') {
|
|
18
18
|
return { error: 'No pending ask', status: 410 };
|
|
@@ -45,7 +45,6 @@ function buildDecision(marker, body) {
|
|
|
45
45
|
const decision = { behavior: body.behavior };
|
|
46
46
|
if (typeof body.message === 'string' && body.message) decision.message = body.message;
|
|
47
47
|
if (body.updatedInput && typeof body.updatedInput === 'object') decision.updatedInput = body.updatedInput;
|
|
48
|
-
if (Array.isArray(body.updatedPermissions)) decision.updatedPermissions = body.updatedPermissions;
|
|
49
48
|
return { decision };
|
|
50
49
|
}
|
|
51
50
|
|
|
@@ -58,14 +57,14 @@ function isDecisionFile(name) {
|
|
|
58
57
|
return name.startsWith('_decision-') && name.endsWith('.json');
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// (
|
|
64
|
-
//
|
|
65
|
-
//
|
|
60
|
+
// The mod only polls for a decision for waitSeconds — after that the ask
|
|
61
|
+
// belongs to the terminal. Default and clamp mirror the mod's own parse
|
|
62
|
+
// (boardWaitMs in the plugin's hooks/activity.ts; PERMISSION_TTL_MS hides the
|
|
63
|
+
// card at 30 min, so waiting longer than the UI can show the ask is strictly
|
|
64
|
+
// worse — D11); keep them in sync.
|
|
66
65
|
const WAIT_SECONDS_DEFAULT = 1800;
|
|
67
66
|
const WAIT_SECONDS_MAX = 1800;
|
|
68
|
-
// Small grace over the
|
|
67
|
+
// Small grace over the mod's own deadline so a race never 410s a live ask
|
|
69
68
|
const LAPSE_GRACE_MS = 5000;
|
|
70
69
|
function waitSecondsFrom(cfg) {
|
|
71
70
|
const raw = cfg?.waitSeconds;
|
|
@@ -75,7 +74,7 @@ function waitSecondsFrom(cfg) {
|
|
|
75
74
|
|
|
76
75
|
// Normalizes the `approvals` section of <CCK_DIR>/config.json. The gate is on
|
|
77
76
|
// by default: only an explicit `enabled: false` turns it off, and anything
|
|
78
|
-
// unparseable falls back to defaults — the same parse
|
|
77
|
+
// unparseable falls back to defaults — the same parse the mod does.
|
|
79
78
|
function approvalsFrom(cfg) {
|
|
80
79
|
const a = cfg && typeof cfg.approvals === 'object' && cfg.approvals ? cfg.approvals : {};
|
|
81
80
|
return {
|
|
@@ -85,7 +84,7 @@ function approvalsFrom(cfg) {
|
|
|
85
84
|
};
|
|
86
85
|
}
|
|
87
86
|
|
|
88
|
-
// Whether the
|
|
87
|
+
// Whether the mod waits for a board decision on a marker of this kind
|
|
89
88
|
function isKindGated(kind, approvals) {
|
|
90
89
|
if (!approvals.enabled) return false;
|
|
91
90
|
if (kind === 'question') return approvals.mode === 'permission+question';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Read from HEAD rather than by spawning git: a sync spawn on the session-list path blocks the
|
|
2
|
+
// event loop for up to half a second per cwd on Windows, and terminal input waits behind it.
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const REF_RE = /^ref:\s*refs\/heads\/(.+)$/;
|
|
8
|
+
const GITDIR_RE = /^gitdir:\s*(.+)$/;
|
|
9
|
+
|
|
10
|
+
// `.git` is a directory in an ordinary checkout and a `gitdir:` pointer file in a linked
|
|
11
|
+
// worktree or a submodule.
|
|
12
|
+
function headFile(dir, readFile) {
|
|
13
|
+
const dotGit = path.join(dir, '.git');
|
|
14
|
+
let text;
|
|
15
|
+
try {
|
|
16
|
+
text = readFile(dotGit);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
if (e.code === 'EISDIR') return path.join(dotGit, 'HEAD');
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const m = GITDIR_RE.exec(text.trim());
|
|
22
|
+
return m ? path.join(path.resolve(dir, m[1]), 'HEAD') : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} cwd
|
|
27
|
+
* @param {(file: string) => string} [readFile]
|
|
28
|
+
* @returns {string|null} null outside a repo and on a detached HEAD
|
|
29
|
+
*/
|
|
30
|
+
function readGitBranch(cwd, readFile = (f) => fs.readFileSync(f, 'utf8')) {
|
|
31
|
+
for (let dir = path.resolve(cwd); ; ) {
|
|
32
|
+
const head = headFile(dir, readFile);
|
|
33
|
+
if (head) {
|
|
34
|
+
try {
|
|
35
|
+
const m = REF_RE.exec(readFile(head).trim());
|
|
36
|
+
return m ? m[1] : null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const up = path.dirname(dir);
|
|
42
|
+
if (up === dir) return null;
|
|
43
|
+
dir = up;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { readGitBranch };
|