@warren-dean/code-kit 0.11.0 → 0.12.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.
@@ -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
+ }