@warren-dean/code-kit 0.11.1 → 0.12.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +17 -0
- package/README.md +14 -5
- package/bin/code-kit.mjs +164 -4
- package/hooks/guard-bash.mjs +17 -0
- package/hooks/lib/harness.mjs +6 -0
- package/hooks/lib/next.mjs +19 -2
- package/hooks/lib/plan.mjs +16 -4
- package/hooks/lib/questions.mjs +140 -0
- package/hooks/lib/report.mjs +246 -0
- package/hooks/lib/rules.mjs +48 -11
- package/hooks/mod/art.mjs +2 -2
- package/hooks/mod/decisions.mjs +117 -3
- package/hooks/mod/panes.mjs +3 -1
- package/hooks/mod/register.mjs +242 -3
- package/hooks/mod/register.test.ts +344 -3
- package/hooks/mod/view.mjs +179 -4
- package/package.json +1 -1
- package/skills/init/SKILL.md +2 -1
- package/skills/next/SKILL.md +14 -0
- package/templates/agent.md +1 -0
- package/templates/claude-md.md +3 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Questions for the person (docs/specs/15-decisions-and-journal.md, ASK-1 to ASK-4): what the lead can't
|
|
2
|
+
// settle itself (a product choice, an account only the person can create, wording only they can sign off),
|
|
3
|
+
// asked through code-kit so it waits in the Decisions tab instead of scrolling away in the chat. A question
|
|
4
|
+
// may hold its story ("waiting on you") until it's answered. Kept in the main checkout, so every lane's
|
|
5
|
+
// worktree sees the same questions.
|
|
6
|
+
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { projectWorktrees } from './rules.mjs';
|
|
9
|
+
|
|
10
|
+
export const QUESTIONS_FILE = '.claude/state/questions.jsonl';
|
|
11
|
+
|
|
12
|
+
const fileOf = (root) => join(projectWorktrees(root)[0] ?? root, QUESTIONS_FILE);
|
|
13
|
+
|
|
14
|
+
function records(root) {
|
|
15
|
+
try {
|
|
16
|
+
return readFileSync(fileOf(root), 'utf8')
|
|
17
|
+
.split('\n')
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.flatMap((l) => {
|
|
20
|
+
try {
|
|
21
|
+
return [JSON.parse(l)];
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function append(root, record) {
|
|
32
|
+
const file = fileOf(root);
|
|
33
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
34
|
+
appendFileSync(file, `${JSON.stringify(record)}\n`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every question, oldest first: { id, at, by, question, why, story, blocks, options: [{ label, effect }],
|
|
39
|
+
* state: 'open' | 'answered' | 'withdrawn', answer: { text, at, by, via } | null, withdrawn: { at, reason } | null }.
|
|
40
|
+
*/
|
|
41
|
+
export function readQuestions(root) {
|
|
42
|
+
const byId = new Map();
|
|
43
|
+
for (const r of records(root)) {
|
|
44
|
+
if (r.kind === 'ask')
|
|
45
|
+
byId.set(r.id, { ...r, kind: undefined, state: 'open', answer: null, withdrawn: null });
|
|
46
|
+
const q = byId.get(r.id);
|
|
47
|
+
if (!q || q.state !== 'open') continue;
|
|
48
|
+
if (r.kind === 'answer') {
|
|
49
|
+
q.state = 'answered';
|
|
50
|
+
q.answer = { text: r.answer, at: r.at, by: r.by, via: r.via };
|
|
51
|
+
}
|
|
52
|
+
if (r.kind === 'withdraw') {
|
|
53
|
+
q.state = 'withdrawn';
|
|
54
|
+
q.withdrawn = { at: r.at, reason: r.reason ?? null };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return [...byId.values()].map(({ kind, ...q }) => q);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const openQuestions = (root) => readQuestions(root).filter((q) => q.state === 'open');
|
|
61
|
+
|
|
62
|
+
/** "Label: what it does" as an option; a label alone has no effect said. */
|
|
63
|
+
export function parseOption(text) {
|
|
64
|
+
const at = String(text).indexOf(':');
|
|
65
|
+
return at > 0
|
|
66
|
+
? { label: text.slice(0, at).trim(), effect: text.slice(at + 1).trim() || null }
|
|
67
|
+
: { label: String(text).trim(), effect: null };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* ASK-1: records a question for the person; the same question still open is asked once, and returned.
|
|
72
|
+
* `blocks` holds its story until it's answered (ASK-3).
|
|
73
|
+
*/
|
|
74
|
+
export function askQuestion(
|
|
75
|
+
root,
|
|
76
|
+
{ by, question, why = null, story = null, blocks = false, options = [] },
|
|
77
|
+
now = Date.now(),
|
|
78
|
+
) {
|
|
79
|
+
const text = String(question).trim();
|
|
80
|
+
const open = openQuestions(root).find(
|
|
81
|
+
(q) => q.question === text && (q.story ?? null) === (story ?? null),
|
|
82
|
+
);
|
|
83
|
+
if (open) return { question: open, existed: true };
|
|
84
|
+
const n = readQuestions(root).length + 1;
|
|
85
|
+
const record = {
|
|
86
|
+
kind: 'ask',
|
|
87
|
+
id: `Q-${n}`,
|
|
88
|
+
at: new Date(now).toISOString(),
|
|
89
|
+
by,
|
|
90
|
+
question: text,
|
|
91
|
+
why: why?.trim() || null,
|
|
92
|
+
story: story ?? null,
|
|
93
|
+
blocks: Boolean(blocks && story),
|
|
94
|
+
options: options
|
|
95
|
+
.map((o) => (typeof o === 'string' ? parseOption(o) : o))
|
|
96
|
+
.filter((o) => o.label),
|
|
97
|
+
};
|
|
98
|
+
append(root, record);
|
|
99
|
+
return { question: readQuestions(root).find((q) => q.id === record.id), existed: false };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** ASK-2: the person's answer, given in the pane (`via: 'pane'`) or in chat and recorded by the lead (`via: 'chat'`). */
|
|
103
|
+
export function answerQuestion(root, id, { answer, by, via }, now = Date.now()) {
|
|
104
|
+
const q = readQuestions(root).find((x) => x.id === id);
|
|
105
|
+
if (!q) return { ok: false, why: `There's no question ${id}. code-kit ask lists the open ones.` };
|
|
106
|
+
if (q.state !== 'open') return { ok: false, why: `${id} isn't open: it was ${q.state} already.` };
|
|
107
|
+
if (!String(answer ?? '').trim())
|
|
108
|
+
return { ok: false, why: 'Give the answer: the lead builds on it.' };
|
|
109
|
+
append(root, {
|
|
110
|
+
kind: 'answer',
|
|
111
|
+
id,
|
|
112
|
+
at: new Date(now).toISOString(),
|
|
113
|
+
answer: String(answer).trim(),
|
|
114
|
+
by,
|
|
115
|
+
via,
|
|
116
|
+
});
|
|
117
|
+
return { ok: true, question: readQuestions(root).find((x) => x.id === id) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The lead takes a question back once it no longer needs the person (it was settled another way). */
|
|
121
|
+
export function withdrawQuestion(root, id, reason, now = Date.now()) {
|
|
122
|
+
const q = readQuestions(root).find((x) => x.id === id);
|
|
123
|
+
if (!q) return { ok: false, why: `There's no question ${id}.` };
|
|
124
|
+
if (q.state !== 'open') return { ok: false, why: `${id} isn't open: it was ${q.state} already.` };
|
|
125
|
+
append(root, {
|
|
126
|
+
kind: 'withdraw',
|
|
127
|
+
id,
|
|
128
|
+
at: new Date(now).toISOString(),
|
|
129
|
+
reason: reason?.trim() || null,
|
|
130
|
+
});
|
|
131
|
+
return { ok: true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** ASK-3: the stories an open question holds: Map(story id → the question). */
|
|
135
|
+
export function waitingStories(root) {
|
|
136
|
+
const out = new Map();
|
|
137
|
+
for (const q of openQuestions(root))
|
|
138
|
+
if (q.blocks && q.story && !out.has(q.story)) out.set(q.story, q);
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Bug reports for code-kit itself (docs/specs/15-decisions-and-journal.md, REPORT-1 to REPORT-3). With
|
|
2
|
+
// `harness.report.on`, an agent that code-kit got in the way of (a refusal that's wrong, a crash, a pane
|
|
3
|
+
// showing the wrong thing) drafts a report. The draft carries code-kit's own facts only (its version, the
|
|
4
|
+
// rule, the refusal, the command, what was expected), with the project's paths, home folder and email
|
|
5
|
+
// addresses taken out, and waits for the person: they read the exact text and send it, or discard it.
|
|
6
|
+
// Sending files a GitHub issue on code-kit's repository through their own `gh`, or, without it, gives
|
|
7
|
+
// them the new-issue link to open; a report someone already filed gets a comment instead of a second issue.
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { appendFileSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
11
|
+
import { homedir, platform, release } from 'node:os';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { projectWorktrees } from './rules.mjs';
|
|
15
|
+
|
|
16
|
+
export const REPORTS_FILE = '.claude/state/reports.jsonl';
|
|
17
|
+
const PLUGIN = JSON.parse(
|
|
18
|
+
readFileSync(fileURLToPath(new URL('../../.claude-plugin/plugin.json', import.meta.url)), 'utf8'),
|
|
19
|
+
);
|
|
20
|
+
/** code-kit's own repository, where reports go: `owner/name`. */
|
|
21
|
+
export const REPO = PLUGIN.repository.replace(/^https:\/\/github\.com\//, '').replace(/\.git$/, '');
|
|
22
|
+
|
|
23
|
+
const fileOf = (root) => join(projectWorktrees(root)[0] ?? root, REPORTS_FILE);
|
|
24
|
+
|
|
25
|
+
function records(root) {
|
|
26
|
+
try {
|
|
27
|
+
return readFileSync(fileOf(root), 'utf8')
|
|
28
|
+
.split('\n')
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.flatMap((l) => {
|
|
31
|
+
try {
|
|
32
|
+
return [JSON.parse(l)];
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
} catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const append = (root, record) => {
|
|
43
|
+
const file = fileOf(root);
|
|
44
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
45
|
+
appendFileSync(file, `${JSON.stringify(record)}\n`);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* REPORT-2: text as it may leave the machine: the project's folders, the home folder and email
|
|
50
|
+
* addresses replaced, and cut to `max` characters.
|
|
51
|
+
*/
|
|
52
|
+
export function scrub(text, roots = [], max = 1500) {
|
|
53
|
+
let out = String(text ?? '');
|
|
54
|
+
const home = homedir();
|
|
55
|
+
for (const r of [...roots].filter(Boolean).sort((a, b) => b.length - a.length))
|
|
56
|
+
out = out.split(r).join('<project>');
|
|
57
|
+
if (home) out = out.split(home).join('~');
|
|
58
|
+
out = out.replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, '<email>');
|
|
59
|
+
return out.length > max ? `${out.slice(0, max)}…` : out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** REPORT-3: what makes two reports the same bug: the title's words and the rule, as a short hash. */
|
|
63
|
+
export const fingerprint = (title, rule) =>
|
|
64
|
+
`ck-${createHash('sha1')
|
|
65
|
+
.update(
|
|
66
|
+
`${String(title)
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
69
|
+
.trim()}|${rule ?? ''}`,
|
|
70
|
+
)
|
|
71
|
+
.digest('hex')
|
|
72
|
+
.slice(0, 8)}`;
|
|
73
|
+
|
|
74
|
+
/** The issue's body, as the person will read it before it's sent. */
|
|
75
|
+
function bodyOf(r) {
|
|
76
|
+
return [
|
|
77
|
+
`**What happened:** ${r.what}`,
|
|
78
|
+
...(r.expected ? [`**Expected:** ${r.expected}`] : []),
|
|
79
|
+
...(r.command ? ['**Command or call:**', '```', r.command, '```'] : []),
|
|
80
|
+
...(r.refusal ? ['**What code-kit said:**', '```', r.refusal, '```'] : []),
|
|
81
|
+
...(r.rule ? [`**Rule:** ${r.rule}`] : []),
|
|
82
|
+
`**code-kit:** ${r.version} · Node ${r.node} · ${r.os}`,
|
|
83
|
+
'',
|
|
84
|
+
`<sub>Drafted by a code-kit agent, read and sent by a person. Fingerprint: ${r.fingerprint}</sub>`,
|
|
85
|
+
].join('\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every report, oldest first: { id, at, by, title, body, fingerprint, state: 'draft' | 'sent' | 'discarded',
|
|
90
|
+
* sent: { at, url, comment } | null }.
|
|
91
|
+
*/
|
|
92
|
+
export function readReports(root) {
|
|
93
|
+
const byId = new Map();
|
|
94
|
+
for (const r of records(root)) {
|
|
95
|
+
if (r.kind === 'draft') byId.set(r.id, { ...r, state: 'draft', sent: null });
|
|
96
|
+
const d = byId.get(r.id);
|
|
97
|
+
if (!d || d.state !== 'draft') continue;
|
|
98
|
+
if (r.kind === 'sent')
|
|
99
|
+
Object.assign(d, {
|
|
100
|
+
state: 'sent',
|
|
101
|
+
sent: { at: r.at, url: r.url, comment: Boolean(r.comment) },
|
|
102
|
+
});
|
|
103
|
+
if (r.kind === 'discarded') d.state = 'discarded';
|
|
104
|
+
}
|
|
105
|
+
return [...byId.values()].map(({ kind, ...r }) => r);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const draftReports = (root) => readReports(root).filter((r) => r.state === 'draft');
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* REPORT-1: a draft from an agent: { title, what, expected, command, refusal, rule }. The same bug still
|
|
112
|
+
* waiting as a draft is drafted once.
|
|
113
|
+
*/
|
|
114
|
+
export function draftReport(root, input, { by = 'lead', now = Date.now() } = {}) {
|
|
115
|
+
// Each checkout as written and as the system resolves it (/var and /private/var on macOS).
|
|
116
|
+
const roots = [
|
|
117
|
+
...new Set(
|
|
118
|
+
[...projectWorktrees(root), root].flatMap((r) => {
|
|
119
|
+
let real = r;
|
|
120
|
+
try {
|
|
121
|
+
real = realpathSync(r);
|
|
122
|
+
} catch {
|
|
123
|
+
// gone: as written
|
|
124
|
+
}
|
|
125
|
+
return [r, real, real.replace(/^\/private(?=\/)/, '')];
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
];
|
|
129
|
+
const clean = (v, max) => (v ? scrub(v, roots, max) : null);
|
|
130
|
+
const title = clean(input.title, 120);
|
|
131
|
+
if (!title?.trim())
|
|
132
|
+
return { ok: false, why: 'Give the report a title: what went wrong, in a few words.' };
|
|
133
|
+
if (!input.what?.trim()) return { ok: false, why: 'Say what happened, with --what "…".' };
|
|
134
|
+
const fp = fingerprint(title, input.rule);
|
|
135
|
+
const existing = draftReports(root).find((r) => r.fingerprint === fp);
|
|
136
|
+
if (existing) return { ok: true, report: existing, existed: true };
|
|
137
|
+
const r = {
|
|
138
|
+
title: title.trim(),
|
|
139
|
+
what: clean(input.what, 1500),
|
|
140
|
+
expected: clean(input.expected, 600),
|
|
141
|
+
command: clean(input.command, 600),
|
|
142
|
+
refusal: clean(input.refusal, 1500),
|
|
143
|
+
rule: clean(input.rule, 120),
|
|
144
|
+
version: PLUGIN.version,
|
|
145
|
+
node: process.version,
|
|
146
|
+
os: `${platform()} ${release()}`,
|
|
147
|
+
fingerprint: fp,
|
|
148
|
+
};
|
|
149
|
+
const record = {
|
|
150
|
+
kind: 'draft',
|
|
151
|
+
id: `R-${readReports(root).length + 1}`,
|
|
152
|
+
at: new Date(now).toISOString(),
|
|
153
|
+
by,
|
|
154
|
+
title: r.title,
|
|
155
|
+
fingerprint: fp,
|
|
156
|
+
body: bodyOf(r),
|
|
157
|
+
};
|
|
158
|
+
append(root, record);
|
|
159
|
+
return { ok: true, report: readReports(root).find((x) => x.id === record.id), existed: false };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The new-issue link, for someone without `gh`: the draft filled in, for them to submit. */
|
|
163
|
+
export const issueLink = (r) =>
|
|
164
|
+
`https://github.com/${REPO}/issues/new?title=${encodeURIComponent(r.title)}&body=${encodeURIComponent(r.body)}`;
|
|
165
|
+
|
|
166
|
+
const gh = (args) =>
|
|
167
|
+
execFileSync('gh', args, {
|
|
168
|
+
encoding: 'utf8',
|
|
169
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
170
|
+
timeout: 60000,
|
|
171
|
+
}).trim();
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* REPORT-3: the person sends a draft: a comment on the open issue with its fingerprint, if someone filed
|
|
175
|
+
* it already, otherwise a new issue; without a working `gh`, nothing is sent and the link is returned.
|
|
176
|
+
*/
|
|
177
|
+
export function sendReport(root, id, now = Date.now()) {
|
|
178
|
+
const r = readReports(root).find((x) => x.id === id);
|
|
179
|
+
if (!r) return { ok: false, why: `There's no report ${id}.` };
|
|
180
|
+
if (r.state !== 'draft') return { ok: false, why: `${id} was ${r.state} already.` };
|
|
181
|
+
let url;
|
|
182
|
+
let comment = false;
|
|
183
|
+
try {
|
|
184
|
+
gh(['auth', 'status']);
|
|
185
|
+
} catch {
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
link: issueLink(r),
|
|
189
|
+
why: "gh isn't installed or signed in, so nothing was sent.",
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const found = JSON.parse(
|
|
194
|
+
gh([
|
|
195
|
+
'issue',
|
|
196
|
+
'list',
|
|
197
|
+
'--repo',
|
|
198
|
+
REPO,
|
|
199
|
+
'--state',
|
|
200
|
+
'open',
|
|
201
|
+
'--search',
|
|
202
|
+
`${r.fingerprint} in:body`,
|
|
203
|
+
'--json',
|
|
204
|
+
'number,url',
|
|
205
|
+
'--limit',
|
|
206
|
+
'1',
|
|
207
|
+
]) || '[]',
|
|
208
|
+
);
|
|
209
|
+
if (found.length) {
|
|
210
|
+
gh([
|
|
211
|
+
'issue',
|
|
212
|
+
'comment',
|
|
213
|
+
String(found[0].number),
|
|
214
|
+
'--repo',
|
|
215
|
+
REPO,
|
|
216
|
+
'--body',
|
|
217
|
+
`Seen again, on code-kit ${PLUGIN.version}.\n\n${r.body}`,
|
|
218
|
+
]);
|
|
219
|
+
url = found[0].url;
|
|
220
|
+
comment = true;
|
|
221
|
+
} else
|
|
222
|
+
url = gh(['issue', 'create', '--repo', REPO, '--title', r.title, '--body', r.body])
|
|
223
|
+
.split('\n')
|
|
224
|
+
.at(-1);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
link: issueLink(r),
|
|
229
|
+
why: `GitHub didn't take it (${
|
|
230
|
+
String(err.stderr || err.message)
|
|
231
|
+
.trim()
|
|
232
|
+
.split('\n')[0]
|
|
233
|
+
}).`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
append(root, { kind: 'sent', id, at: new Date(now).toISOString(), url, comment });
|
|
237
|
+
return { ok: true, url, comment };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function discardReport(root, id, now = Date.now()) {
|
|
241
|
+
const r = readReports(root).find((x) => x.id === id);
|
|
242
|
+
if (!r) return { ok: false, why: `There's no report ${id}.` };
|
|
243
|
+
if (r.state !== 'draft') return { ok: false, why: `${id} was ${r.state} already.` };
|
|
244
|
+
append(root, { kind: 'discarded', id, at: new Date(now).toISOString() });
|
|
245
|
+
return { ok: true };
|
|
246
|
+
}
|
package/hooks/lib/rules.mjs
CHANGED
|
@@ -35,6 +35,8 @@ import { globToRegExp, matchesAny } from './glob.mjs';
|
|
|
35
35
|
export const APPROVAL_LOG = '.claude/approval-log.jsonl';
|
|
36
36
|
export const APPROVALS_DIR = '.claude/approvals';
|
|
37
37
|
export const APPROVAL_MINUTES = 60;
|
|
38
|
+
// CARD-7: a request waits until it's answered or dropped, and at most a week, so a forgotten one doesn't linger.
|
|
39
|
+
export const REQUEST_DAYS = 7;
|
|
38
40
|
// A dependency approval lasts until the lane commits the install it allows (the story may resume
|
|
39
41
|
// hours later), and at most a week, so a forgotten one doesn't linger.
|
|
40
42
|
export const DEPENDENCY_APPROVAL_DAYS = 7;
|
|
@@ -315,8 +317,8 @@ const sameRequest = (a, b) =>
|
|
|
315
317
|
a.what === b.what &&
|
|
316
318
|
[...a.names].sort().join() === [...b.names].sort().join();
|
|
317
319
|
|
|
318
|
-
/** Every
|
|
319
|
-
|
|
320
|
+
/** Every line of the requests file: the requests, and the drops (`{ drop: true, of }`). */
|
|
321
|
+
function requestRecords(root) {
|
|
320
322
|
try {
|
|
321
323
|
return readFileSync(requestsFile(root), 'utf8')
|
|
322
324
|
.split('\n')
|
|
@@ -333,10 +335,40 @@ export function readRequests(root) {
|
|
|
333
335
|
}
|
|
334
336
|
}
|
|
335
337
|
|
|
338
|
+
/** Every request recorded, oldest first; unreadable lines are skipped. */
|
|
339
|
+
export const readRequests = (root) => requestRecords(root).filter((r) => !r.drop);
|
|
340
|
+
|
|
341
|
+
/** CARD-7: whether a request was dropped (the person dismissed it, or the lead said it's no longer needed). */
|
|
342
|
+
const droppedIn = (drops, r) => drops.some((d) => d.of === r.at && sameRequest(d, r));
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* CARD-7: drops open requests: `which` is a request's number in `openRequests`' order (1 is the
|
|
346
|
+
* newest), or 'all'. By the person (`by: 'person'`, from the pane) or the lead, once nobody needs it.
|
|
347
|
+
*/
|
|
348
|
+
export function dropRequests(root, which, { by, reason = null }, now = Date.now()) {
|
|
349
|
+
const open = openRequests(root, now);
|
|
350
|
+
const picked = which === 'all' ? open : [open[Number(which) - 1]].filter(Boolean);
|
|
351
|
+
if (!picked.length)
|
|
352
|
+
return {
|
|
353
|
+
ok: false,
|
|
354
|
+
why: open.length
|
|
355
|
+
? `There's no open request ${which}: there are ${open.length} (code-kit requests lists them, newest first).`
|
|
356
|
+
: 'No approval requests are open.',
|
|
357
|
+
};
|
|
358
|
+
const file = requestsFile(root);
|
|
359
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
360
|
+
for (const r of picked)
|
|
361
|
+
appendFileSync(
|
|
362
|
+
file,
|
|
363
|
+
`${JSON.stringify({ drop: true, of: r.at, actor: r.actor, names: r.names, what: r.what, by, reason, at: new Date(now).toISOString() })}\n`,
|
|
364
|
+
);
|
|
365
|
+
return { ok: true, dropped: picked };
|
|
366
|
+
}
|
|
367
|
+
|
|
336
368
|
/**
|
|
337
369
|
* Records that `actor` was refused something the approvals `names` would allow, once: the same
|
|
338
|
-
* actor, names and command or file
|
|
339
|
-
*
|
|
370
|
+
* actor, names and command or file while one is still open count as one request. Recording never
|
|
371
|
+
* changes the refusal, so any failure here is ignored.
|
|
340
372
|
*/
|
|
341
373
|
export function recordRequest(root, actor, names, what, why, now = Date.now()) {
|
|
342
374
|
// A read-only agent (a reviewer) is never approved to write: what it changed goes back, not to the
|
|
@@ -351,10 +383,7 @@ export function recordRequest(root, actor, names, what, why, now = Date.now()) {
|
|
|
351
383
|
what: String(what).slice(0, 200),
|
|
352
384
|
why: String(why).slice(0, 300),
|
|
353
385
|
};
|
|
354
|
-
|
|
355
|
-
(r) => now - Date.parse(r.at) < APPROVAL_MINUTES * 60_000,
|
|
356
|
-
);
|
|
357
|
-
if (fresh.some((r) => sameRequest(r, request))) return;
|
|
386
|
+
if (openRequests(root, now).some((r) => sameRequest(r, request))) return;
|
|
358
387
|
const file = requestsFile(root);
|
|
359
388
|
mkdirSync(dirname(file), { recursive: true });
|
|
360
389
|
appendFileSync(file, `${JSON.stringify(request)}\n`);
|
|
@@ -363,13 +392,21 @@ export function recordRequest(root, actor, names, what, why, now = Date.now()) {
|
|
|
363
392
|
}
|
|
364
393
|
}
|
|
365
394
|
|
|
366
|
-
/**
|
|
395
|
+
/**
|
|
396
|
+
* CARD-7: requests nobody has answered yet, newest first: each waits until an approval answers it, its
|
|
397
|
+
* file is committed by someone who could, or it's dropped, and at most REQUEST_DAYS.
|
|
398
|
+
*/
|
|
367
399
|
export function openRequests(root, now = Date.now()) {
|
|
368
400
|
const open = [];
|
|
369
401
|
const given = approvalsGiven(root);
|
|
402
|
+
const drops = requestRecords(root).filter((r) => r.drop);
|
|
403
|
+
// The newest of the same request speaks for it: an older copy doesn't come back once that's dropped.
|
|
404
|
+
const seen = [];
|
|
370
405
|
for (const r of readRequests(root).reverse()) {
|
|
371
|
-
if (now - Date.parse(r.at) >=
|
|
372
|
-
if (
|
|
406
|
+
if (now - Date.parse(r.at) >= REQUEST_DAYS * 24 * 60 * 60_000) continue;
|
|
407
|
+
if (seen.some((o) => sameRequest(o, r))) continue;
|
|
408
|
+
seen.push(r);
|
|
409
|
+
if (droppedIn(drops, r)) continue;
|
|
373
410
|
// From before read-only agents stopped asking: only the lead and the lanes are ever approved.
|
|
374
411
|
if (!r.lane && r.actor !== 'lead session') continue;
|
|
375
412
|
const actor = r.lane ? { kind: 'lane', lane: r.lane } : { kind: 'lead' };
|
package/hooks/mod/art.mjs
CHANGED
|
@@ -107,7 +107,7 @@ export function spriteSvg(pose, tint, { lead = false } = {}) {
|
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
109
|
* A lane's pose (VIEW-3): stalled when flagged or quiet past the restart time, waiting while a call
|
|
110
|
-
* of its is held
|
|
110
|
+
* of its is held, its request is open or its story waits on the person's answer, working while its agent is at work, quiet when it has
|
|
111
111
|
* gone quiet, done once its branch is finished, idle otherwise.
|
|
112
112
|
*/
|
|
113
113
|
export function poseOf(
|
|
@@ -115,7 +115,7 @@ export function poseOf(
|
|
|
115
115
|
{ live = null, waiting = false, flagged = false, shown = lane.state } = {},
|
|
116
116
|
) {
|
|
117
117
|
if (flagged || live?.level === 'stalled') return 'stalled';
|
|
118
|
-
if (waiting) return 'waiting';
|
|
118
|
+
if (waiting || shown === 'waiting on you') return 'waiting';
|
|
119
119
|
if (live?.level === 'quiet') return 'quiet';
|
|
120
120
|
if (lane.active) return 'working';
|
|
121
121
|
if (['in review', 'queued'].includes(shown)) return 'done';
|
package/hooks/mod/decisions.mjs
CHANGED
|
@@ -18,6 +18,10 @@ export const DECISION_KINDS = new Set([
|
|
|
18
18
|
'teamIntent',
|
|
19
19
|
'teamReview',
|
|
20
20
|
'teamDrift',
|
|
21
|
+
'question',
|
|
22
|
+
'brief',
|
|
23
|
+
'denied',
|
|
24
|
+
'report',
|
|
21
25
|
]);
|
|
22
26
|
|
|
23
27
|
/** What each answer does, in the person's words. */
|
|
@@ -41,8 +45,97 @@ const EFFECT = {
|
|
|
41
45
|
'The issue closes as agreed, with your words; then put it in the specs or decisions so every agent builds on it.',
|
|
42
46
|
dropIntent: 'The issue closes as dropped, and the team carries on as agreed before.',
|
|
43
47
|
team: 'Opens the Team tab: who is building what, and what changed since you last looked.',
|
|
48
|
+
openDecision: 'Opens it here, with what each answer does.',
|
|
49
|
+
dropRequest: "Nobody needs it any more: it stops waiting, and nothing's approved.",
|
|
50
|
+
answer: 'Type your answer; the lead is told it and carries on.',
|
|
51
|
+
answerBrief: "Type your answer; the lead records it in the brief's decisions and builds on it.",
|
|
52
|
+
laterBrief: 'It waits; it comes back next session.',
|
|
53
|
+
runDenied:
|
|
54
|
+
'Puts it in your prompt after !, so you run it yourself; press Enter to run it, then Done it.',
|
|
55
|
+
deniedDone: 'The lead is told you ran it, or allowed it, and carries on.',
|
|
56
|
+
deniedNo: "The lead is told you won't run it now, and carries on with other work.",
|
|
57
|
+
sendReport:
|
|
58
|
+
"Files it on code-kit's GitHub under your account (a comment instead, if it's filed already).",
|
|
59
|
+
discardReport: "It's not sent, and goes.",
|
|
44
60
|
};
|
|
45
61
|
|
|
62
|
+
/**
|
|
63
|
+
* CARD-8: one card per decision. A band line can stand for several (approvals waiting, held calls, the
|
|
64
|
+
* lead's questions, the brief's, Claude Code's refusals, reports); each becomes its own line here, with
|
|
65
|
+
* the decision it's about, so each card's options answer that one.
|
|
66
|
+
*/
|
|
67
|
+
function eachDecision(line) {
|
|
68
|
+
if (line.kind === 'approvals' && line.asks?.length > 1)
|
|
69
|
+
return line.asks.map((a) => ({
|
|
70
|
+
...line,
|
|
71
|
+
text: `Approve ${askWords(a.request, a.files)}`,
|
|
72
|
+
request: a.request,
|
|
73
|
+
files: a.files,
|
|
74
|
+
}));
|
|
75
|
+
if (line.kind === 'approvals')
|
|
76
|
+
return [{ ...line, text: `Approve ${askWords(line.request, line.files)}` }];
|
|
77
|
+
if (line.kind === 'held' && line.calls?.length > 1)
|
|
78
|
+
return line.calls.map((c) => ({ ...line, text: c.text, held: c.held }));
|
|
79
|
+
if (line.kind === 'question')
|
|
80
|
+
return (line.questions ?? [line.question]).map((q) => ({
|
|
81
|
+
...line,
|
|
82
|
+
text: q.question,
|
|
83
|
+
question: q,
|
|
84
|
+
}));
|
|
85
|
+
if (line.kind === 'brief')
|
|
86
|
+
return (line.briefs ?? [line.brief]).map((b) => ({
|
|
87
|
+
...line,
|
|
88
|
+
text: `From your brief: ${b.question}`,
|
|
89
|
+
brief: b,
|
|
90
|
+
}));
|
|
91
|
+
if (line.kind === 'denied')
|
|
92
|
+
return (line.denials ?? [line.denial]).map((d) => ({
|
|
93
|
+
...line,
|
|
94
|
+
text: `Claude Code refused ${d.who}'s ${d.what}`,
|
|
95
|
+
denial: d,
|
|
96
|
+
actions: [
|
|
97
|
+
...(d.command ? [{ id: 'runDenied', label: 'Put it in the prompt' }] : []),
|
|
98
|
+
{ id: 'deniedDone', label: 'Done it' },
|
|
99
|
+
{ id: 'deniedNo', label: 'Not now' },
|
|
100
|
+
],
|
|
101
|
+
}));
|
|
102
|
+
if (line.kind === 'report')
|
|
103
|
+
return (line.reports ?? [line.report]).map((r) => ({ ...line, text: r.title, report: r }));
|
|
104
|
+
return [line];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A card's options: the line's own actions, or, for the kinds a card answers differently, its own. */
|
|
108
|
+
function optionsOf(line) {
|
|
109
|
+
if (line.kind === 'approvals')
|
|
110
|
+
return [
|
|
111
|
+
{ id: 'approve', label: 'Approve…' },
|
|
112
|
+
{ id: 'dropRequest', label: 'Not needed' },
|
|
113
|
+
];
|
|
114
|
+
// ASK-2: each of the lead's options answers it at once; any answer can be typed instead.
|
|
115
|
+
if (line.kind === 'question')
|
|
116
|
+
return [
|
|
117
|
+
...line.question.options.map((o, i) => ({
|
|
118
|
+
id: `answerWith-${i}`,
|
|
119
|
+
act: 'answerWith',
|
|
120
|
+
label: o.label,
|
|
121
|
+
effect: o.effect,
|
|
122
|
+
line: { ...line, choice: o.label },
|
|
123
|
+
})),
|
|
124
|
+
{ id: 'answer', label: line.question.options.length ? 'Something else…' : 'Answer…' },
|
|
125
|
+
];
|
|
126
|
+
if (line.kind === 'brief')
|
|
127
|
+
return [
|
|
128
|
+
{ id: 'answerBrief', label: 'Answer…' },
|
|
129
|
+
{ id: 'laterBrief', label: 'Later' },
|
|
130
|
+
];
|
|
131
|
+
if (line.kind === 'report')
|
|
132
|
+
return [
|
|
133
|
+
{ id: 'sendReport', label: 'Send' },
|
|
134
|
+
{ id: 'discardReport', label: 'Discard' },
|
|
135
|
+
];
|
|
136
|
+
return line.actions;
|
|
137
|
+
}
|
|
138
|
+
|
|
46
139
|
/** Why a line needs the person, when the line itself doesn't say. */
|
|
47
140
|
function whyOf(line, check) {
|
|
48
141
|
if (line.kind === 'held') {
|
|
@@ -76,6 +169,17 @@ function whyOf(line, check) {
|
|
|
76
169
|
if (line.kind === 'teamReview') return "It's in a lane you own, so its review is yours.";
|
|
77
170
|
if (line.kind === 'teamDrift')
|
|
78
171
|
return "The team's drift check found your work building on something that has since changed, or heading into someone else's: settle it before it merges.";
|
|
172
|
+
if (line.kind === 'question')
|
|
173
|
+
return (
|
|
174
|
+
line.question.why ??
|
|
175
|
+
"The lead can't settle this itself: it's yours to decide, and the lead builds on your answer."
|
|
176
|
+
);
|
|
177
|
+
if (line.kind === 'brief')
|
|
178
|
+
return `It's an open question in ${line.brief.path}: what gets built depends on your answer.`;
|
|
179
|
+
if (line.kind === 'denied')
|
|
180
|
+
return `Claude Code's own permission check refused it${line.denial.reason ? ` (${line.denial.reason})` : ''}, which code-kit can't approve past: only you can run it or allow it.`;
|
|
181
|
+
if (line.kind === 'report')
|
|
182
|
+
return "code-kit got in an agent's way. It's sent under your GitHub account to a public repository, so read what it says first.";
|
|
79
183
|
return null;
|
|
80
184
|
}
|
|
81
185
|
|
|
@@ -103,6 +207,7 @@ export function limitsWhy(outside) {
|
|
|
103
207
|
function storyOf(line, state) {
|
|
104
208
|
const stories = state?.stories ?? [];
|
|
105
209
|
const lane = line.held?.lane ?? line.request?.lane ?? line.stall?.lane ?? null;
|
|
210
|
+
if (line.question?.story) return stories.find((s) => s.id === line.question.story) ?? null;
|
|
106
211
|
if (line.story) return stories.find((s) => s.id === line.story.id) ?? line.story;
|
|
107
212
|
if (line.stall?.story) return stories.find((s) => s.id === line.stall.story) ?? null;
|
|
108
213
|
if (line.branch) return stories.find((s) => s.branch === line.branch) ?? null;
|
|
@@ -124,7 +229,8 @@ export function decisionCards(lines, context = {}) {
|
|
|
124
229
|
const titles = new Map((status?.requirements ?? []).map((r) => [r.id, r.title]));
|
|
125
230
|
return lines
|
|
126
231
|
.filter((l) => DECISION_KINDS.has(l.kind) && !l.mission)
|
|
127
|
-
.map((line
|
|
232
|
+
.flatMap((source) => eachDecision(source).map((line) => ({ source, line })))
|
|
233
|
+
.map(({ source, line }, i) => {
|
|
128
234
|
const story = storyOf(line, state);
|
|
129
235
|
const lane = line.held?.lane ?? line.request?.lane ?? line.stall?.lane ?? story?.lane ?? null;
|
|
130
236
|
const settled = [
|
|
@@ -141,6 +247,8 @@ export function decisionCards(lines, context = {}) {
|
|
|
141
247
|
id: `${line.kind}-${i}`,
|
|
142
248
|
kind: line.kind,
|
|
143
249
|
line,
|
|
250
|
+
// The band line it came from: one line can stand for several decisions (CARD-8).
|
|
251
|
+
source,
|
|
144
252
|
title: line.text,
|
|
145
253
|
asked: [request, goal].filter(Boolean).join(' · ') || null,
|
|
146
254
|
story: story
|
|
@@ -157,10 +265,16 @@ export function decisionCards(lines, context = {}) {
|
|
|
157
265
|
(line.intent
|
|
158
266
|
? `Intent #${line.intent.id}: ${line.intent.about}${line.intent.touches.length ? ` (touches ${line.intent.touches.join(', ')})` : ''}`
|
|
159
267
|
: null) ??
|
|
160
|
-
(line.pull ? `Pull request #${line.pull.number} on ${line.pull.branch}` : null)
|
|
268
|
+
(line.pull ? `Pull request #${line.pull.number} on ${line.pull.branch}` : null) ??
|
|
269
|
+
(line.question?.blocks ? `${line.question.story} waits on your answer.` : null) ??
|
|
270
|
+
(line.denial ? (line.denial.command ?? line.denial.what) : null) ??
|
|
271
|
+
(line.report ? line.report.body : null),
|
|
161
272
|
settled,
|
|
162
273
|
why: whyOf(line, check),
|
|
163
|
-
options: line.
|
|
274
|
+
options: optionsOf(line).map((a) => ({
|
|
275
|
+
...a,
|
|
276
|
+
effect: a.effect ?? EFFECT[a.act ?? a.id] ?? null,
|
|
277
|
+
})),
|
|
164
278
|
};
|
|
165
279
|
});
|
|
166
280
|
}
|