@warren-dean/code-kit 0.6.0 → 0.8.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,224 @@
1
+ // The journal (docs/specs/15-decisions-and-journal.md, JOURNAL-1): the build's lineage in plain
2
+ // sentences, oldest first: what the person asked for, the decisions that shaped it, the specification,
3
+ // the rules being switched on, each story handed out, sent back and merged, each approval and who gave
4
+ // it, and each milestone finished. Read from git and the project's own records, so it's the same for
5
+ // every session and survives a restart.
6
+ import { execFileSync } from 'node:child_process';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { APPROVAL_LOG } from './rules.mjs';
10
+ import { sendBacks } from './reviews.mjs';
11
+ import { storyTimes } from './timeline.mjs';
12
+
13
+ const gitIn =
14
+ (root) =>
15
+ (...args) => {
16
+ try {
17
+ return execFileSync('git', ['-C', root, ...args], {
18
+ encoding: 'utf8',
19
+ stdio: ['ignore', 'pipe', 'ignore'],
20
+ maxBuffer: 32 * 1024 * 1024,
21
+ }).trim();
22
+ } catch {
23
+ return '';
24
+ }
25
+ };
26
+
27
+ /** When a file was first committed, in ms, or null. */
28
+ const addedAt = (git, file) => {
29
+ const t = git('log', '--diff-filter=A', '--format=%ct', '--', file)
30
+ .split('\n')
31
+ .filter(Boolean)
32
+ .at(-1);
33
+ return t ? Number(t) * 1000 : null;
34
+ };
35
+
36
+ /** The brief's title and its problem, in a sentence or two: what the person asked for. */
37
+ export function briefRequest(text) {
38
+ const title = text.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? null;
39
+ const section = (name) => {
40
+ const m = text.match(
41
+ new RegExp(`^##\\s+${name}\\s*\\n([\\s\\S]*?)(?=^##\\s|$(?![\\s\\S]))`, 'm'),
42
+ );
43
+ return m ? m[1].trim() : '';
44
+ };
45
+ const firstParagraph = (s) =>
46
+ s
47
+ .split(/\n\s*\n/)[0]
48
+ .replace(/\s+/g, ' ')
49
+ .trim();
50
+ const problem = firstParagraph(section('Problem')) || firstParagraph(section('Outcome'));
51
+ return { title, problem: problem || null };
52
+ }
53
+
54
+ /** The brief's decision log: [{ date, decision, why }], from its `## Decision log` table. */
55
+ export function briefDecisions(text) {
56
+ const m = text.match(/^##\s+Decision log\s*\n([\s\S]*?)(?=^##\s|$(?![\s\S]))/m);
57
+ if (!m) return [];
58
+ return m[1]
59
+ .split('\n')
60
+ .filter((l) => /^\|/.test(l) && !/^\|\s*-/.test(l) && !/^\|\s*Date\s*\|/i.test(l))
61
+ .map((l) => l.split('|').map((c) => c.trim()))
62
+ .map((c) => ({ date: c[1], decision: c[2], why: c[3] ?? '' }))
63
+ .filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d.date) && d.decision);
64
+ }
65
+
66
+ /** Who gave an approval, from the reason its log entry carries: the person, or the lead within limits. */
67
+ export function approverOf(entry) {
68
+ const reason = String(entry.reason ?? '');
69
+ if (/approved by the lead within the delegated rules/.test(reason)) return 'lead';
70
+ if (entry.approval === 'lead') return 'lead';
71
+ return 'person';
72
+ }
73
+
74
+ /** The words an approval's name stands for: "dep-pdfjs-dist" → "adding the package pdfjs-dist". */
75
+ export function approvalWords(name) {
76
+ if (name.startsWith('dep-')) return `adding the package ${name.slice(4)}`;
77
+ if (name === 'dependency') return 'adding a new package';
78
+ if (name === 'kit') return "a change to code-kit's own rules or agents";
79
+ if (name === 'merge') return 'a merge';
80
+ return `a change to the protected ${name.replace(/-/g, ' ')}`;
81
+ }
82
+
83
+ /** The person's own words, without the note the hooks add about how it was given. */
84
+ const ownWords = (reason) =>
85
+ String(reason ?? '')
86
+ .replace(
87
+ /\s*\((given in chat, recorded by the lead|approved in the [\w ]+ pane|approved by the lead within the delegated rules[^)]*)\)\s*$/,
88
+ '',
89
+ )
90
+ .trim();
91
+
92
+ /**
93
+ * JOURNAL-1: the events, oldest first: [{ at, kind, text, story? }]. Kinds: request, decision, spec,
94
+ * setup, dispatch, sentBack, merge, approval, milestone. `status` is buildStatus's; `base` the branch
95
+ * stories merge into; `docs` the config's docs (brief beside the specs).
96
+ */
97
+ export function journal(root, status, base, docs = {}) {
98
+ const git = gitIn(root);
99
+ const events = [];
100
+ const at = (iso) => (iso ? Date.parse(`${iso}T12:00:00Z`) : null);
101
+ // What the person asked for, and the decisions the brief records.
102
+ const briefPath = ['docs/brief.md', docs.specs ? join(docs.specs, '..', 'brief.md') : null]
103
+ .filter(Boolean)
104
+ .find((p) => existsSync(join(root, p)));
105
+ if (briefPath) {
106
+ const text = readFileSync(join(root, briefPath), 'utf8');
107
+ const { title, problem } = briefRequest(text);
108
+ const when = addedAt(git, briefPath);
109
+ if (title || problem)
110
+ events.push({
111
+ at: when,
112
+ kind: 'request',
113
+ text: `You asked for ${title ? `“${title}”` : 'a build'}${problem ? `: ${problem}` : ''}`,
114
+ });
115
+ // The decisions were made while the brief was written: they follow the request, in the log's order.
116
+ briefDecisions(text).forEach((d, n) =>
117
+ events.push({
118
+ at: (when ?? at(d.date)) + n + 1,
119
+ kind: 'decision',
120
+ text: `Decided: ${d.decision}${d.why ? `, because ${d.why.replace(/\.$/, '').replace(/^./, (c) => c.toLowerCase())}` : ''}.`,
121
+ }),
122
+ );
123
+ }
124
+ // The specification and the plan.
125
+ const reqs = status.requirements.filter((r) => !r.removed).length;
126
+ const milestones = [...new Set(status.stories.map((s) => s.milestone).filter(Boolean))];
127
+ if (docs.plan && status.stories.length)
128
+ events.push({
129
+ at: addedAt(git, docs.plan),
130
+ kind: 'spec',
131
+ text: `The specification was written: ${reqs} requirement${reqs === 1 ? '' : 's'}, split into ${status.stories.length} stor${status.stories.length === 1 ? 'y' : 'ies'}${milestones.length ? ` across ${milestones.length} milestone${milestones.length === 1 ? '' : 's'}` : ''}.`,
132
+ });
133
+ const switchedOn = addedAt(git, '.claude/code-kit.json');
134
+ if (switchedOn)
135
+ events.push({
136
+ at: switchedOn,
137
+ kind: 'setup',
138
+ text: "code-kit's rules were switched on, as you approved them.",
139
+ });
140
+ // Each story: handed out, sent back, merged.
141
+ const times = storyTimes(root, status.stories, base);
142
+ const byId = new Map(status.stories.map((s) => [s.id, s]));
143
+ const backs = sendBacks(root);
144
+ for (const t of times) {
145
+ const s = byId.get(t.id);
146
+ const name = `${t.id} ${s?.title ?? ''}`.trim();
147
+ const reqText = s?.requirements?.length ? ` (${s.requirements.join(', ')})` : '';
148
+ if (t.started)
149
+ events.push({
150
+ at: t.started,
151
+ kind: 'dispatch',
152
+ story: t.id,
153
+ text:
154
+ t.lane === 'lead'
155
+ ? `The lead started ${name}${reqText} itself.`
156
+ : `${name}${reqText} was handed to the ${t.lane} lane.`,
157
+ });
158
+ for (const b of backs.filter((x) => x.branch === t.branch))
159
+ events.push({
160
+ at: Date.parse(b.at),
161
+ kind: 'sentBack',
162
+ story: t.id,
163
+ text:
164
+ t.lane === 'lead'
165
+ ? `${t.id} needed more work from the lead${b.reason ? `: ${b.reason}` : '.'}`
166
+ : `The lead sent ${t.id} back to the ${t.lane} lane${b.reason ? `: ${b.reason}` : '.'}`,
167
+ });
168
+ if (t.merged)
169
+ events.push({
170
+ at: t.merged,
171
+ kind: 'merge',
172
+ story: t.id,
173
+ text: `${name} was merged into ${base}.`,
174
+ });
175
+ }
176
+ // Approvals on the base, once each (one approval can log several files), and who gave them.
177
+ const seen = new Set();
178
+ for (const line of git('show', `${base}:${APPROVAL_LOG}`).split('\n').filter(Boolean)) {
179
+ let e;
180
+ try {
181
+ e = JSON.parse(line);
182
+ } catch {
183
+ continue;
184
+ }
185
+ if (!e.approval || e.approval === 'lead') continue;
186
+ const key = `${e.approval}|${e.grantedAt ?? e.at}`;
187
+ if (seen.has(key)) continue;
188
+ seen.add(key);
189
+ const who = approverOf(e);
190
+ const words = ownWords(e.reason);
191
+ events.push({
192
+ at: Date.parse(e.grantedAt ?? e.at),
193
+ kind: 'approval',
194
+ who,
195
+ text:
196
+ who === 'lead'
197
+ ? `The lead approved ${approvalWords(e.approval)}, within the limits you set${words ? `: ${words}` : '.'}`
198
+ : `You approved ${approvalWords(e.approval)}${words ? `: “${words}”` : '.'}`,
199
+ });
200
+ }
201
+ // Milestones finished: when the last of their stories merged.
202
+ for (const m of milestones) {
203
+ const mine = status.stories.filter((s) => s.milestone === m);
204
+ if (!mine.every((s) => s.state === 'done')) continue;
205
+ const last = Math.max(...mine.map((s) => times.find((t) => t.id === s.id)?.merged ?? 0));
206
+ if (last)
207
+ events.push({
208
+ at: last + 1,
209
+ kind: 'milestone',
210
+ text: `${m} was finished: all ${mine.length} of its stories merged.`,
211
+ });
212
+ }
213
+ // The request, its decisions, the spec and the switch-on come first, whatever the commits' times: work
214
+ // can start on a branch before the brief that asked for it is committed.
215
+ const ORDER = ['request', 'decision', 'spec', 'setup'];
216
+ const opening = events.filter((e) => ORDER.includes(e.kind));
217
+ const rest = events.filter((e) => !ORDER.includes(e.kind) && e.at);
218
+ const firstWork = rest.length ? Math.min(...rest.map((e) => e.at)) : Infinity;
219
+ const opened = opening
220
+ .filter((e) => e.at)
221
+ .sort((a, b) => ORDER.indexOf(a.kind) - ORDER.indexOf(b.kind) || a.at - b.at)
222
+ .map((e, n, all) => ({ ...e, at: Math.min(e.at, firstWork - (all.length - n)) }));
223
+ return [...opened, ...rest.sort((a, b) => a.at - b.at)];
224
+ }
@@ -0,0 +1,26 @@
1
+ // Whether the person has paused the lead loop (spec 05, LOOP-6), kept beside the merge queue so other
2
+ // tools sharing the project can hold their own background work while it's paused. The mod records it
3
+ // through the CLI; a session that starts begins unpaused.
4
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { projectWorktrees } from './rules.mjs';
7
+
8
+ export const LOOP_FILE = '.claude/state/loop.json';
9
+
10
+ const loopFile = (root) => join(projectWorktrees(root)[0] ?? root, LOOP_FILE);
11
+
12
+ /** { paused, at }: not paused when nothing was recorded. */
13
+ export function readLoop(root) {
14
+ try {
15
+ const state = JSON.parse(readFileSync(loopFile(root), 'utf8'));
16
+ return { paused: state?.paused === true, at: state?.at ?? null };
17
+ } catch {
18
+ return { paused: false, at: null };
19
+ }
20
+ }
21
+
22
+ export function writeLoop(root, paused, now = Date.now()) {
23
+ const file = loopFile(root);
24
+ mkdirSync(dirname(file), { recursive: true });
25
+ writeFileSync(file, `${JSON.stringify({ paused, at: new Date(now).toISOString() })}\n`);
26
+ }
@@ -4,8 +4,9 @@
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
6
6
  import { join, posix } from 'node:path';
7
- import { loadConfig } from './config.mjs';
7
+ import { loadConfig, mergeDelegation } from './config.mjs';
8
8
  import { buildStatus, readStories, LEAD } from './plan.mjs';
9
+ import { readQueue, waiting } from './queue.mjs';
9
10
 
10
11
  export const DRAFT_FILE = '.claude/code-kit.draft.json';
11
12
  export const BRIEF_STATES = ['framed', 'explored', 'scoped', 'specified', 'planned', 'ready'];
@@ -52,7 +53,8 @@ const hasMarkdown = (dir) => {
52
53
 
53
54
  /**
54
55
  * { step, why, args, then, attention }. `step` is a skill (spec-design, init, check, dispatch,
55
- * review), `wait` (lanes are building) or `done`. `base(config)` resolves the branch lanes merge into.
56
+ * review), `merge` (the head of the merge queue, where the lead merges), `wait` (lanes are
57
+ * building) or `done`. `base(config)` resolves the branch lanes merge into.
56
58
  */
57
59
  export function nextStep(root, base) {
58
60
  const attention = [];
@@ -149,7 +151,12 @@ export function nextStep(root, base) {
149
151
  `The plan's status lines are out of date for ${status.drift.map((d) => d.story).join(', ')}.`,
150
152
  );
151
153
 
152
- const ids = (state) => status.stories.filter((s) => s.state === state).map((s) => s.id);
154
+ // A branch already in the merge queue has passed its review: it isn't reviewed again.
155
+ const queued = waiting(readQueue(root)).map((e) => e.branch);
156
+ const ids = (state) =>
157
+ status.stories
158
+ .filter((s) => s.state === state && !(state === 'review' && queued.includes(s.branch)))
159
+ .map((s) => s.id);
153
160
  const [review, building, blocked, fixes] = ['review', 'in progress', 'blocked', 'sent back'].map(
154
161
  ids,
155
162
  );
@@ -168,6 +175,25 @@ export function nextStep(root, base) {
168
175
  attention.push(
169
176
  `${unbuildable.join(', ')} can't be dispatched until the plan's gaps are fixed.`,
170
177
  );
178
+ // The merge queue (spec 09): branches the lead's review passed. Under autonomy, where the base's
179
+ // committed rules delegate merges, the lead merges the head first, so dependents see it; otherwise
180
+ // the person does, and always the first merge, which brings code-kit's rules onto the base.
181
+ const delegation = queued.length ? mergeDelegation(root, config) : { delegated: false };
182
+ const leadMerges = delegation.delegated && config.harness.autonomy === 'autonomous';
183
+ if (queued.length && delegation.first)
184
+ attention.push(
185
+ `${queued[0]} passed review and is next to merge. ${delegation.into} has no code-kit config committed yet, so this first merge, which brings the rules onto it, is the person's (code-kit queue merge --person, or Merge in the code-kit band); after it, the lead merges.`,
186
+ );
187
+ else if (queued.length && !leadMerges)
188
+ attention.push(
189
+ `${queued.join(', ')} passed review and ${queued.length === 1 ? 'waits' : 'wait'} for the person to merge (code-kit queue merge --person, or Merge in the code-kit band).`,
190
+ );
191
+ if (queued.length && leadMerges)
192
+ return result(
193
+ 'merge',
194
+ `${queued[0]} passed review and is next in the merge queue${queued.length > 1 ? ` (then ${queued.slice(1).join(', ')})` : ''}.`,
195
+ { args: queued[0] },
196
+ );
171
197
  const then = [];
172
198
  // A sent-back story goes back to its lane with the review's findings, before new work starts.
173
199
  const toDispatch = [...fixes, ...ready];
@@ -211,6 +237,11 @@ export function nextStep(root, base) {
211
237
  'spec-design',
212
238
  `${blocked.join(', ')} wait on stories that can't finish: the plan has a dependency cycle or a missing story.`,
213
239
  );
240
+ if (queued.length)
241
+ return result(
242
+ 'wait',
243
+ `${queued.join(', ')} ${queued.length === 1 ? 'waits' : 'wait'} to be merged.`,
244
+ );
214
245
  return result(
215
246
  'done',
216
247
  `All ${status.stories.length} stories are done; spec-design plans the next milestone or feature.`,
@@ -47,12 +47,18 @@ const field = (lines, name) =>
47
47
  .find(Boolean)?.[1]
48
48
  .trim() ?? '';
49
49
 
50
- /** Every story in the plan: [{ id, title, lane, requirements, dependsOn, status }]. */
50
+ /**
51
+ * Every story in the plan: [{ id, title, lane, requirements, dependsOn, status, milestone }]. A story's
52
+ * milestone is the `## ` heading above it ("Milestone 5: Your own key"), or null.
53
+ */
51
54
  export function readStories(root, planPath) {
52
55
  const stories = [];
53
56
  for (const file of markdownFiles(root, planPath)) {
54
57
  const lines = readFileSync(join(root, file), 'utf8').split('\n');
58
+ let milestone = null;
55
59
  lines.forEach((line, i) => {
60
+ const heading = line.match(/^##\s+(.+?)\s*$/);
61
+ if (heading) milestone = heading[1];
56
62
  const m = line.match(STORY_HEADING);
57
63
  if (!m) return;
58
64
  const end = lines.findIndex((l, j) => j > i && /^#{1,3}\s/.test(l));
@@ -67,6 +73,7 @@ export function readStories(root, planPath) {
67
73
  noRequirements: /^none\b/i.test(field(body, 'Requirements')),
68
74
  dependsOn: [...field(body, 'Depends on').matchAll(/\bST-\d+\b/g)].map((r) => r[0]),
69
75
  status: STATES.includes(status) ? status : 'todo',
76
+ milestone,
70
77
  });
71
78
  });
72
79
  }
@@ -221,3 +228,32 @@ export function buildStatus(root, config, base, files) {
221
228
  .map((s) => ({ story: s.id, plan: s.status, actual: s.state }));
222
229
  return { stories, requirements: byRequirement, problems, drift };
223
230
  }
231
+
232
+ /**
233
+ * The rows of a spec-check report's table (skills/spec-check): [{ requirement, status, where, note }],
234
+ * the first column's requirement id, the status as written (done, partial, missing or conflicts).
235
+ */
236
+ export function specCheckRows(text) {
237
+ const lines = String(text).split('\n');
238
+ const head = lines.findIndex((l) => /^\s*\|\s*Requirement\s*\|/i.test(l));
239
+ if (head < 0) return [];
240
+ const rows = [];
241
+ for (const line of lines.slice(head + 1)) {
242
+ if (!/^\s*\|/.test(line)) break;
243
+ const cells = line
244
+ .trim()
245
+ .replace(/^\||\|$/g, '')
246
+ .split('|')
247
+ .map((c) => c.trim());
248
+ if (cells.every((c) => /^:?-+:?$/.test(c) || c === '')) continue;
249
+ const [requirement = '', status = '', where = '', note = ''] = cells;
250
+ rows.push({
251
+ requirement: requirement.match(REQUIREMENT_ID)?.[0] ?? requirement,
252
+ status: status.toLowerCase(),
253
+ where,
254
+ note,
255
+ });
256
+ }
257
+ return rows;
258
+ }
259
+ const REQUIREMENT_ID = /\b[A-Z][A-Z0-9]*-\d+\b/;
@@ -0,0 +1,75 @@
1
+ // The merge queue (docs/specs/09-merge-queue.md): branches that passed the lead's review, merged one at
2
+ // a time in the order they passed, each verified against the base as it is then. Kept in the main
3
+ // checkout, beside the requests and reviews, so every worktree and a restarted session see the same.
4
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { projectWorktrees } from './rules.mjs';
7
+
8
+ export const QUEUE_FILE = '.claude/state/merge-queue.json';
9
+ /** Merged and sent-back entries kept for the pane, newest last. */
10
+ const DONE_KEPT = 20;
11
+
12
+ const queueFile = (root) => join(projectWorktrees(root)[0] ?? root, QUEUE_FILE);
13
+
14
+ /** The queue's entries: [{ branch, passed, state, at?, reason? }], waiting ones in merge order. */
15
+ export function readQueue(root) {
16
+ try {
17
+ const entries = JSON.parse(readFileSync(queueFile(root), 'utf8'));
18
+ return Array.isArray(entries) ? entries.filter((e) => e && typeof e.branch === 'string') : [];
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
23
+
24
+ function writeQueue(root, entries) {
25
+ const done = entries.filter((e) => !['waiting', 'merging'].includes(e.state));
26
+ const kept = [
27
+ ...done.slice(-DONE_KEPT),
28
+ ...entries.filter((e) => ['waiting', 'merging'].includes(e.state)),
29
+ ];
30
+ const file = queueFile(root);
31
+ mkdirSync(dirname(file), { recursive: true });
32
+ writeFileSync(file, `${JSON.stringify(kept, null, 2)}\n`);
33
+ }
34
+
35
+ /** The branches still to merge, in order (MQ-1). */
36
+ export const waiting = (entries) => entries.filter((e) => ['waiting', 'merging'].includes(e.state));
37
+
38
+ /** Adds a branch the lead's review passed, at the back; a branch already waiting keeps its place. */
39
+ export function enqueue(root, branch, now = Date.now()) {
40
+ const entries = readQueue(root);
41
+ if (waiting(entries).some((e) => e.branch === branch)) return false;
42
+ writeQueue(root, [
43
+ ...entries.filter((e) => e.branch !== branch),
44
+ { branch, passed: new Date(now).toISOString(), state: 'waiting' },
45
+ ]);
46
+ return true;
47
+ }
48
+
49
+ /** Takes a waiting branch out of the queue. */
50
+ export function dequeue(root, branch) {
51
+ const entries = readQueue(root);
52
+ if (!waiting(entries).some((e) => e.branch === branch)) return false;
53
+ writeQueue(
54
+ root,
55
+ entries.filter((e) => !(e.branch === branch && ['waiting', 'merging'].includes(e.state))),
56
+ );
57
+ return true;
58
+ }
59
+
60
+ /** Sets a waiting branch's state: merging, merged, sent back (with why), or waiting again. */
61
+ export function settle(root, branch, state, reason = null, now = Date.now()) {
62
+ const entries = readQueue(root).map((e) =>
63
+ e.branch === branch && ['waiting', 'merging'].includes(e.state)
64
+ ? { ...e, state, at: new Date(now).toISOString(), ...(reason ? { reason } : {}) }
65
+ : e,
66
+ );
67
+ // Merged and sent-back entries move behind the waiting ones' history, in the order they settled.
68
+ const settled = entries.filter((e) => e.branch === branch && e.state === state);
69
+ writeQueue(root, [...entries.filter((e) => !settled.includes(e)), ...settled]);
70
+ }
71
+
72
+ /** The branch merged last, for a send-back's reason ("conflicts with main after web/st-4"). */
73
+ export function lastMerged(entries) {
74
+ return [...entries].reverse().find((e) => e.state === 'merged')?.branch ?? null;
75
+ }
@@ -37,3 +37,21 @@ export function sentBack(root) {
37
37
  }
38
38
  return found;
39
39
  }
40
+
41
+ /** Every send-back recorded, oldest first: [{ at, branch, sha, reason }]. */
42
+ export function sendBacks(root) {
43
+ let lines = [];
44
+ try {
45
+ lines = readFileSync(reviewsFile(root), 'utf8').split('\n').filter(Boolean);
46
+ } catch {
47
+ return [];
48
+ }
49
+ return lines.flatMap((line) => {
50
+ try {
51
+ const r = JSON.parse(line);
52
+ return r.verdict === 'sent back' && r.branch ? [r] : [];
53
+ } catch {
54
+ return [];
55
+ }
56
+ });
57
+ }
@@ -2,7 +2,8 @@
2
2
  //
3
3
  // Actors:
4
4
  // - a lane agent (its agent_type is a lane's "agent") writes only that lane's paths;
5
- // - any other subagent (research, planning, review) is read-only;
5
+ // - any other subagent (research, planning, review) is read-only, but for the paths any actor may write
6
+ // (a card or a decision it records, code-kit's own state);
6
7
  // - the main session in a worktree with a .lane file acts as that lane (a person running a lane);
7
8
  // - the main session otherwise is the lead, who writes the lead's paths and delegates the rest.
8
9
  // Protected paths are recorded in the approval log whenever they are committed; only the lead may
@@ -219,8 +220,9 @@ function ownedPaths(actor, config) {
219
220
 
220
221
  /** Whether `rel` is the actor's own to write: its paths, or the paths any actor may write. */
221
222
  export function owns(actor, rel, config) {
222
- if (actor.kind === 'readonly') return false;
223
+ // The paths any actor may write are a read-only agent's too: a card or a decision it records.
223
224
  if (matchesAny(rel, config.anyActor)) return true;
225
+ if (actor.kind === 'readonly') return false;
224
226
  const excluded = actor.kind === 'lane' ? config.lanes[actor.lane]?.exclude : [];
225
227
  return matchesAny(rel, ownedPaths(actor, config)) && !matchesAny(rel, excluded);
226
228
  }
@@ -408,8 +410,10 @@ export function protectedEntry(rel, config) {
408
410
  return config.protected.find((p) => globToRegExp(p.glob).test(rel));
409
411
  }
410
412
 
413
+ /** Files that may hold secrets. An env file's template (`.env.example`, `.env.local.sample`) names
414
+ * the variables without their values, so it's committed like any other file. */
411
415
  export const SECRETS = (rel) =>
412
- /(^|\/)\.env(\.|$)/.test(rel) ||
416
+ (/(^|\/)\.env(\.|$)/.test(rel) && !/\.(example|sample|template|dist)$/.test(rel)) ||
413
417
  /(^|\/)secrets?\//.test(rel) ||
414
418
  /\.(pem|p8|p12|keystore|jks)$/.test(rel);
415
419
 
@@ -436,8 +440,8 @@ export function writeProblem(actor, rel, root, config) {
436
440
  recordRequest(root, actor, guarded.approval, rel, guarded.why);
437
441
  return `${rel} is ${guarded.why} and needs a person's approval. Ask the lead (a person) to run\n${approvalHowTo(guarded.approval, actor, root, undefined, config)}`;
438
442
  }
439
- if (actor.kind === 'readonly') return `${actor.label} is read-only; it may not write ${rel}.`;
440
443
  if (matchesAny(rel, config.anyActor)) return null;
444
+ if (actor.kind === 'readonly') return `${actor.label} is read-only; it may not write ${rel}.`;
441
445
  if (!owns(actor, rel, config)) return ownershipProblem(actor, rel, config);
442
446
  const specs = actor.kind === 'lane' && config.docs?.specs;
443
447
  return (specs && specCheckProblem(rel, root)) || screenProblem(rel, root, config);
@@ -33,7 +33,7 @@ export function maskQuotes(text) {
33
33
  } else if (c === quote) {
34
34
  quote = null;
35
35
  out += c;
36
- } else out += c === '\n' ? '\n' : '_';
36
+ } else out += '_'; // a newline inside quotes too: it ends no command
37
37
  } else if (c === '\\' && i + 1 < text.length) {
38
38
  out += '__';
39
39
  i++;