@warren-dean/code-kit 0.9.1 → 0.11.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +17 -0
- package/README.md +37 -31
- package/bin/code-kit.mjs +133 -2
- package/hooks/guard-bash.mjs +8 -0
- package/hooks/lib/config.mjs +4 -3
- package/hooks/lib/next.mjs +87 -3
- package/hooks/lib/plan.mjs +2 -1
- package/hooks/lib/security.mjs +180 -0
- package/hooks/lib/team.mjs +62 -7
- package/hooks/mod/decisions.mjs +3 -0
- package/hooks/mod/loop.mjs +14 -1
- package/hooks/mod/panes.mjs +42 -49
- package/hooks/mod/register.mjs +104 -12
- package/hooks/mod/register.test.ts +255 -15
- package/hooks/mod/teamview.mjs +469 -74
- package/hooks/mod/view.mjs +14 -2
- package/package.json +1 -1
- package/skills/init/SKILL.md +13 -5
- package/skills/next/SKILL.md +19 -17
- package/skills/review/SKILL.md +19 -4
- package/skills/spec-design/SKILL.md +5 -2
- package/skills/team-init/SKILL.md +59 -0
- package/templates/security-reviewer.md +57 -0
- package/templates/spec.md +18 -0
package/hooks/lib/next.mjs
CHANGED
|
@@ -9,6 +9,8 @@ import { buildStatus, readStories, DESIGN, LEAD } from './plan.mjs';
|
|
|
9
9
|
import { pendingSetup } from './adapters/index.mjs';
|
|
10
10
|
import { CONFIG_FILE, SETUP_VERSION } from './config.mjs';
|
|
11
11
|
import { readQueue, waiting } from './queue.mjs';
|
|
12
|
+
import { laneOf } from './rules.mjs';
|
|
13
|
+
import { SECURITY_AGENT_FILE } from './security.mjs';
|
|
12
14
|
|
|
13
15
|
export const DRAFT_FILE = '.claude/code-kit.draft.json';
|
|
14
16
|
export const BRIEF_STATES = ['framed', 'explored', 'scoped', 'specified', 'planned', 'ready'];
|
|
@@ -149,11 +151,17 @@ export function nextStep(root, base) {
|
|
|
149
151
|
}
|
|
150
152
|
const [setup] = pendingSetup(raw, root);
|
|
151
153
|
if (setup) return result('setup', setup.why, { args: setup.skill });
|
|
152
|
-
// ONB-5: a project set up before setup did all of this is told what re-running init adds
|
|
153
|
-
|
|
154
|
+
// ONB-5: a project set up before setup did all of this is told what re-running init adds; when
|
|
155
|
+
// ONB-6 finds what it's missing, no new story starts until init has added it.
|
|
156
|
+
const older = (raw?.setup ?? 1) < SETUP_VERSION;
|
|
157
|
+
const gaps = older ? setupGaps(config, readStories(root, plan), files) : [];
|
|
158
|
+
if (older && !gaps.length)
|
|
154
159
|
attention.push(
|
|
155
|
-
"This project's code-kit setup predates what init now sets up: project skills with each lane agent, designs approved before screens are built, a QA lane for end-to-end tests, and companion tools set up before coding. Re-run /code-kit:init to add what's missing; it keeps everything that's still right and shows you the difference first.",
|
|
160
|
+
"This project's code-kit setup predates what init now sets up: project skills with each lane agent, designs approved before screens are built, a QA lane for end-to-end tests, a security reviewer that checks each sensitive change as an attacker would, and companion tools set up before coding. Re-run /code-kit:init to add what's missing; it keeps everything that's still right and shows you the difference first.",
|
|
156
161
|
);
|
|
162
|
+
const missingWords = gaps.length
|
|
163
|
+
? `This project's setup is missing what init now sets up: ${gaps.join('; ')}.`
|
|
164
|
+
: '';
|
|
157
165
|
// ONB-1: a lane agent naming a skill that doesn't exist works without the recipe it was given.
|
|
158
166
|
for (const missing of missingSkills(root)) attention.push(missing);
|
|
159
167
|
|
|
@@ -194,6 +202,16 @@ export function nextStep(root, base) {
|
|
|
194
202
|
// ONB-2: design stories, designed with the person before any screen that depends on them is built.
|
|
195
203
|
const forDesign = readyStories.filter((s) => buildable(s) && s.lane === DESIGN).map((s) => s.id);
|
|
196
204
|
const unbuildable = readyStories.filter((s) => !buildable(s)).map((s) => s.id);
|
|
205
|
+
// ONB-6: work already started finishes; nothing new starts until init has filled the gaps.
|
|
206
|
+
const held = gaps.length
|
|
207
|
+
? [...ready, ...forDesign, ...forLead].sort((a, b) => a.slice(3) - b.slice(3))
|
|
208
|
+
: [];
|
|
209
|
+
if (held.length) {
|
|
210
|
+
ready.length = forDesign.length = forLead.length = 0;
|
|
211
|
+
attention.push(
|
|
212
|
+
`${missingWords} ${held.join(', ')} ${held.length === 1 ? 'waits' : 'wait'} until /code-kit:init has added it.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
197
215
|
if (unbuildable.length)
|
|
198
216
|
attention.push(
|
|
199
217
|
`${unbuildable.join(', ')} can't be dispatched until the plan's gaps are fixed.`,
|
|
@@ -262,6 +280,12 @@ export function nextStep(root, base) {
|
|
|
262
280
|
`${forLead.join(', ')} ${forLead.length === 1 ? 'is' : 'are'} the lead's own: build ${forLead.length === 1 ? 'it' : 'them'} on ${forLead.map((id) => `lead/${id.toLowerCase()}`).join(', ')}, then review like any branch.`,
|
|
263
281
|
{ args: forLead.join(' ') },
|
|
264
282
|
);
|
|
283
|
+
// ONB-6: with nothing in flight, the setup is brought up to date before the next story.
|
|
284
|
+
if (gaps.length && !building.length && !queued.length)
|
|
285
|
+
return result(
|
|
286
|
+
'update',
|
|
287
|
+
`${missingWords} Re-run /code-kit:init before the next story: it shows the person the difference before anything changes.`,
|
|
288
|
+
);
|
|
265
289
|
if (building.length)
|
|
266
290
|
return result(
|
|
267
291
|
'wait',
|
|
@@ -294,6 +318,66 @@ export function nextStep(root, base) {
|
|
|
294
318
|
);
|
|
295
319
|
}
|
|
296
320
|
|
|
321
|
+
const SCREEN_LANE = /(^|-)(web|mobile|frontend|front-end|ui|desktop|ios|android|admin|portal)(-|$)/;
|
|
322
|
+
const SCREEN_FILE = /\.(tsx|jsx|vue|svelte|swift|dart)$/;
|
|
323
|
+
const TEST_FILE = /(^|\/)(__tests__|e2e|tests?)\/|\.(test|spec|stories)\.\w+$/;
|
|
324
|
+
const QA_LANE = /(^|-)(qa|e2e|tests?)(-|$)/;
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* ONB-6: what a setup older than init's is missing, in words, from the config, the plan and the code.
|
|
328
|
+
* A lane owns screens when its name or agent says so (web, mobile, desktop…) or it holds screen
|
|
329
|
+
* files. It's missing the design gate, design stories before the screen stories not yet started, and a
|
|
330
|
+
* QA lane for the end-to-end suites; and, with or without screens, the security reviewer (ONB-7), when
|
|
331
|
+
* its agent file isn't among the tracked files.
|
|
332
|
+
*/
|
|
333
|
+
export function setupGaps(config, stories, files = []) {
|
|
334
|
+
const gaps = screenGaps(config, stories, files);
|
|
335
|
+
if (!files.includes(SECURITY_AGENT_FILE))
|
|
336
|
+
gaps.push(
|
|
337
|
+
`a security reviewer (${SECURITY_AGENT_FILE}) that checks each change touching sign-in, permissions, personal data, payments, secrets or dependencies as an attacker would`,
|
|
338
|
+
);
|
|
339
|
+
return gaps;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** ONB-6: the gaps of a setup whose lanes own screens: the design gate, design stories, a QA lane. */
|
|
343
|
+
function screenGaps(config, stories, files) {
|
|
344
|
+
const lanes = Object.entries(config.lanes);
|
|
345
|
+
const holdsScreens = new Set(
|
|
346
|
+
files.filter((f) => SCREEN_FILE.test(f) && !TEST_FILE.test(f)).map((f) => laneOf(f, config)),
|
|
347
|
+
);
|
|
348
|
+
const screens = lanes
|
|
349
|
+
.filter(
|
|
350
|
+
([name, l]) => SCREEN_LANE.test(name) || SCREEN_LANE.test(l.agent) || holdsScreens.has(name),
|
|
351
|
+
)
|
|
352
|
+
.map(([name]) => name);
|
|
353
|
+
if (!screens.length) return [];
|
|
354
|
+
const gaps = [];
|
|
355
|
+
const list = (names) => names.join(', ').replace(/, ([^,]*)$/, ' and $1');
|
|
356
|
+
if (!config.designGate)
|
|
357
|
+
gaps.push(
|
|
358
|
+
`a design gate for ${list(screens)}, so screens are built only from approved designs`,
|
|
359
|
+
);
|
|
360
|
+
// A screen story not yet started, with no design story among everything it depends on.
|
|
361
|
+
const byId = new Map(stories.map((s) => [s.id, s]));
|
|
362
|
+
const designed = (s, seen = new Set()) =>
|
|
363
|
+
s.dependsOn.some((d) => {
|
|
364
|
+
if (seen.has(d)) return false;
|
|
365
|
+
seen.add(d);
|
|
366
|
+
const dep = byId.get(d);
|
|
367
|
+
return dep && (dep.lane === DESIGN || designed(dep, seen));
|
|
368
|
+
});
|
|
369
|
+
const undesigned = stories
|
|
370
|
+
.filter((s) => screens.includes(s.lane) && ['todo', 'ready'].includes(s.status) && !designed(s))
|
|
371
|
+
.map((s) => s.id);
|
|
372
|
+
if (undesigned.length)
|
|
373
|
+
gaps.push(
|
|
374
|
+
`design stories before the screens: ${list(undesigned)} ${undesigned.length === 1 ? 'is' : 'are'} planned with no design to build from`,
|
|
375
|
+
);
|
|
376
|
+
if (!lanes.some(([name, l]) => QA_LANE.test(name) || QA_LANE.test(l.agent)))
|
|
377
|
+
gaps.push('a QA lane with a test-engineer for the end-to-end tests of the journeys');
|
|
378
|
+
return gaps;
|
|
379
|
+
}
|
|
380
|
+
|
|
297
381
|
/** The code-kit skills every agent may name without a project copy. */
|
|
298
382
|
const KIT_SKILLS = (() => {
|
|
299
383
|
try {
|
package/hooks/lib/plan.mjs
CHANGED
|
@@ -20,7 +20,8 @@ export const DESIGN = 'design';
|
|
|
20
20
|
const TEST =
|
|
21
21
|
/(^|\/)(tests?|__tests__|e2e)\/|\.(test|spec)\.[^/]+$|_test\.(go|py)$|(^|\/)test_[^/]+\.py$/;
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/** The markdown files at `path`: the file itself, or every one under the folder, sorted. */
|
|
24
|
+
export const markdownFiles = (root, path) => {
|
|
24
25
|
const full = join(root, path);
|
|
25
26
|
if (!existsSync(full)) return [];
|
|
26
27
|
if (statSync(full).isFile()) return path.endsWith('.md') ? [path] : [];
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// ONB-7: which branches the security reviewer looks at. Security is a reviewer, not a lane: it owns no
|
|
2
|
+
// paths and checks everyone's work. A story's change gets the security review when it touches sign-in,
|
|
3
|
+
// permissions, outside input, personal data, encryption, payments, secrets or commands built from
|
|
4
|
+
// strings, adds dependencies, or cites a requirement from a spec's `## Security requirements`. Read
|
|
5
|
+
// from git (base...branch): the files changed and the lines added, tests and docs left out.
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { isDependencyFile } from './dependencies.mjs';
|
|
10
|
+
import { markdownFiles, readStories } from './plan.mjs';
|
|
11
|
+
import { storyOfBranch } from './teamfacts.mjs';
|
|
12
|
+
|
|
13
|
+
export const SECURITY_AGENT = 'security-reviewer';
|
|
14
|
+
export const SECURITY_AGENT_FILE = `.claude/agents/${SECURITY_AGENT}.md`;
|
|
15
|
+
|
|
16
|
+
// Tests, docs and design files describe the change; the review looks at the code that ships.
|
|
17
|
+
const NOT_SHIPPED =
|
|
18
|
+
/(^|\/)(tests?|__tests__|e2e|docs?|design)\/|\.(test|spec|stories)\.[^/]+$|_test\.(go|py)$|(^|\/)test_[^/]+\.py$|\.(md|mdx|txt|png|jpe?g|gif|svg|webp)$/i;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What makes a change sensitive, by kind: `path` matches a changed file's path, `code` (one pattern or
|
|
22
|
+
* several) an added line.
|
|
23
|
+
* Each kind reads as the reason a person is given.
|
|
24
|
+
*/
|
|
25
|
+
export const SENSITIVE = [
|
|
26
|
+
{
|
|
27
|
+
kind: 'sign-in',
|
|
28
|
+
why: 'sign-in or sessions',
|
|
29
|
+
path: /(^|[/_.-])(auth\w*|login|logout|sign-?in|sign-?up|sessions?|oauth|sso|jwt|passwords?|mfa|otp)([/_.-]|$)/i,
|
|
30
|
+
code: /\b(signIn|signOut|login|logout|getSession|setSession|jwt\.(sign|verify)|jsonwebtoken|passport\.|bcrypt|argon2|verifyPassword|hashPassword|Set-Cookie|cookies\(\)|httpOnly)\b/,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
kind: 'permissions',
|
|
34
|
+
why: 'permissions',
|
|
35
|
+
path: /(^|[/_.-])(permissions?|roles?|rbac|acl|polic(y|ies)|guards?|middleware|authori[sz]\w*)([/_.-]|$)/i,
|
|
36
|
+
code: [
|
|
37
|
+
/\b(isAdmin|hasRole|hasPermission|can[A-Z]\w*|[Aa]uthori[sz]e\w*|requireRole)\b/,
|
|
38
|
+
/\b(enable row level security|create policy|grant\s+\w+\s+on)\b/i,
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
kind: 'input',
|
|
43
|
+
why: 'input from outside (requests, forms, uploads, webhooks)',
|
|
44
|
+
path: /(^|[/_.-])(webhooks?|uploads?|handlers?)([/_.-]|$)|(^|\/)(api|routes?)\/|(^|\/)route\.[jt]s$/i,
|
|
45
|
+
code: /\b(req\.(body|query|params|files?)|request\.(json|form|args|body)\(?|formData\(\)|searchParams|FormData|multer|busboy|ctx\.request\.body|@Body\(|@Query\(|@Param\()/,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
kind: 'personal data',
|
|
49
|
+
why: 'personal data',
|
|
50
|
+
path: /(^|[/_.-])(users?|profiles?|accounts?|customers?|patients?|members?|children|students?|contacts?)([/_.-]|$)/i,
|
|
51
|
+
code: /\b(email|phone(_?number)?|date_?of_?birth|dob|home_?address|street_?address|passport|tax_?file|national_?id|medicare|ssn|first_?name|last_?name|full_?name)\b/i,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
kind: 'encryption',
|
|
55
|
+
why: 'encryption, hashing or random values',
|
|
56
|
+
path: /(^|[/_.-])(crypto|encrypt\w*|decrypt\w*|ciphers?|hash(ing)?|keys?|kms|signing)([/_.-]|$)/i,
|
|
57
|
+
code: /\b(createCipheriv|createDecipheriv|createHash|createHmac|randomBytes|Math\.random|crypto\.subtle|md5|sha1|AES|RSA|hashlib|Fernet)\b/,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
kind: 'payments',
|
|
61
|
+
why: 'payments',
|
|
62
|
+
path: /(^|[/_.-])(payments?|billing|checkout|invoices?|stripe|subscriptions?|refunds?|payouts?|wallet)([/_.-]|$)/i,
|
|
63
|
+
code: /\b(stripe|paymentIntent|checkout\.sessions|charge|refund|payout|amount_?cents)\b/i,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
kind: 'secrets',
|
|
67
|
+
why: 'secrets or credentials',
|
|
68
|
+
path: /(^|\/)\.env(\.|$)|(^|[/_.-])(secrets?|credentials?|tokens?)([/_.-]|$)/i,
|
|
69
|
+
code: /\b(api_?key|secret_?key|client_?secret|private_?key|access_?token|refresh_?token|password\s*[:=])/i,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
kind: 'injection',
|
|
73
|
+
why: 'queries, commands, templates or redirects built from strings',
|
|
74
|
+
code: /(\$queryRawUnsafe|\$executeRawUnsafe|\.raw\(|sql\.unsafe|execute\(\s*[`'"]?\s*(select|insert|update|delete)\b.*(\$\{|\+)|child_process|\bexec(Sync)?\(|\bspawn(Sync)?\(|subprocess\.|os\.system|\beval\(|new Function\(|dangerouslySetInnerHTML|\.innerHTML\s*=|v-html|\{\{\{|pickle\.loads|yaml\.load\(|unserialize\(|res\.redirect\(|redirect\(\s*(req|request|params|searchParams)|fetch\(\s*(req|request|params|body|url)\b)/i,
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The requirement IDs of each spec's `## Security requirements` section (spec-design writes a spec's
|
|
80
|
+
* security requirements there, as `### AREA-n` headings like any other requirement).
|
|
81
|
+
*/
|
|
82
|
+
export function securityRequirementIds(texts) {
|
|
83
|
+
const ids = new Set();
|
|
84
|
+
for (const text of texts) {
|
|
85
|
+
let inSecurity = false;
|
|
86
|
+
for (const line of text.split('\n')) {
|
|
87
|
+
if (/^##\s/.test(line)) inSecurity = /^##\s+Security requirements\b/i.test(line);
|
|
88
|
+
const m = inSecurity && line.match(/^###\s+((?!ST-)[A-Z][A-Z0-9]*-\d+)\b/);
|
|
89
|
+
if (m) ids.add(m[1]);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return ids;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Added lines of a unified diff (`git diff -U0`): [{ file, line, text }]. */
|
|
96
|
+
export function addedLines(diff) {
|
|
97
|
+
const added = [];
|
|
98
|
+
let file = null;
|
|
99
|
+
let line = 0;
|
|
100
|
+
for (const l of diff.split('\n')) {
|
|
101
|
+
if (l.startsWith('+++ ')) {
|
|
102
|
+
file = l === '+++ /dev/null' ? null : l.slice(4).replace(/^b\//, '');
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const hunk = l.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
106
|
+
if (hunk) {
|
|
107
|
+
line = Number(hunk[1]);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!file) continue;
|
|
111
|
+
if (l.startsWith('+')) added.push({ file, line: line++, text: l.slice(1) });
|
|
112
|
+
else if (l.startsWith(' ')) line++;
|
|
113
|
+
}
|
|
114
|
+
return added;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const examples = (list) =>
|
|
118
|
+
`${list.slice(0, 3).join(', ')}${list.length > 3 ? ` and ${list.length - 3} more` : ''}`;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Why a change needs the security review, in words, one per kind: [{ kind, why, where: [...] }].
|
|
122
|
+
* Empty when it touches nothing sensitive. `files` are the changed files, `added` the added lines,
|
|
123
|
+
* `cites` the story's requirements and `security` the IDs of the specs' security requirements.
|
|
124
|
+
*/
|
|
125
|
+
export function securityReasons({ files = [], added = [], cites = [], security = new Set() }) {
|
|
126
|
+
const reasons = [];
|
|
127
|
+
const shipped = files.filter((f) => !NOT_SHIPPED.test(f));
|
|
128
|
+
const lines = added.filter((a) => !NOT_SHIPPED.test(a.file));
|
|
129
|
+
for (const s of SENSITIVE) {
|
|
130
|
+
const code = [s.code].flat();
|
|
131
|
+
const where = [
|
|
132
|
+
...(s.path ? shipped.filter((f) => s.path.test(f)) : []),
|
|
133
|
+
...lines.filter((a) => code.some((c) => c.test(a.text))).map((a) => `${a.file}:${a.line}`),
|
|
134
|
+
];
|
|
135
|
+
if (where.length) reasons.push({ kind: s.kind, why: s.why, where: [...new Set(where)] });
|
|
136
|
+
}
|
|
137
|
+
const deps = files.filter(isDependencyFile);
|
|
138
|
+
if (deps.length) reasons.push({ kind: 'dependencies', why: 'dependencies', where: deps });
|
|
139
|
+
const cited = cites.filter((id) => security.has(id));
|
|
140
|
+
if (cited.length)
|
|
141
|
+
reasons.push({ kind: 'requirements', why: 'security requirements it cites', where: cited });
|
|
142
|
+
return reasons;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The reasons as one sentence: "sign-in or sessions (src/auth/login.ts), dependencies (package.json)". */
|
|
146
|
+
export const reasonsInWords = (reasons) =>
|
|
147
|
+
reasons.map((r) => `${r.why} (${examples(r.where)})`).join('; ');
|
|
148
|
+
|
|
149
|
+
const git = (root, ...args) =>
|
|
150
|
+
execFileSync('git', ['-C', root, ...args], {
|
|
151
|
+
encoding: 'utf8',
|
|
152
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
153
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Whether `branch` needs the security review against `base`, read from git:
|
|
158
|
+
* { branch, story, needed, reasons, agent } where `agent` says whether the project has the
|
|
159
|
+
* security-reviewer agent.
|
|
160
|
+
*/
|
|
161
|
+
export function securityReview(root, { base, branch, config }) {
|
|
162
|
+
const range = `${base}...${branch}`;
|
|
163
|
+
const files = git(root, 'diff', '--name-only', '--no-renames', range).split('\n').filter(Boolean);
|
|
164
|
+
const added = addedLines(git(root, 'diff', '-U0', '--no-renames', '--no-color', range));
|
|
165
|
+
const story = storyOfBranch(branch);
|
|
166
|
+
const { specs, plan } = config.docs ?? {};
|
|
167
|
+
const cites =
|
|
168
|
+
(plan ? readStories(root, plan) : []).find((s) => s.id === story)?.requirements ?? [];
|
|
169
|
+
const texts = specs
|
|
170
|
+
? markdownFiles(root, specs).map((f) => readFileSync(join(root, f), 'utf8'))
|
|
171
|
+
: [];
|
|
172
|
+
const reasons = securityReasons({ files, added, cites, security: securityRequirementIds(texts) });
|
|
173
|
+
return {
|
|
174
|
+
branch,
|
|
175
|
+
story,
|
|
176
|
+
needed: reasons.length > 0,
|
|
177
|
+
reasons,
|
|
178
|
+
agent: existsSync(join(root, SECURITY_AGENT_FILE)),
|
|
179
|
+
};
|
|
180
|
+
}
|
package/hooks/lib/team.mjs
CHANGED
|
@@ -60,9 +60,16 @@ export function teamProblems(c) {
|
|
|
60
60
|
if (
|
|
61
61
|
t.provider !== undefined &&
|
|
62
62
|
t.provider !== 'github' &&
|
|
63
|
-
!(
|
|
63
|
+
!(
|
|
64
|
+
isObj(t.provider) &&
|
|
65
|
+
typeof t.provider.command === 'string' &&
|
|
66
|
+
t.provider.command.trim() &&
|
|
67
|
+
(t.provider.fallback === undefined || t.provider.fallback === 'github')
|
|
68
|
+
)
|
|
64
69
|
)
|
|
65
|
-
p.push(
|
|
70
|
+
p.push(
|
|
71
|
+
'"team.provider" must be "github" or { "command": "<the provider\'s command>", "fallback"?: "github" }',
|
|
72
|
+
);
|
|
66
73
|
}
|
|
67
74
|
for (const [name, lane] of Object.entries(isObj(c.lanes) ? c.lanes : {})) {
|
|
68
75
|
if (!isObj(lane) || lane.owners === undefined) continue;
|
|
@@ -148,11 +155,44 @@ function claimOf(message) {
|
|
|
148
155
|
}
|
|
149
156
|
}
|
|
150
157
|
|
|
151
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* The provider for this project's team: GitHub's, or the config's command. A command with
|
|
160
|
+
* `fallback: "github"` (a hosted provider, such as the code-kit teams hub) hands each operation it
|
|
161
|
+
* can't reach to the GitHub provider, so claims are still checked while it's down (TEAM-8).
|
|
162
|
+
*/
|
|
152
163
|
export function teamProvider(root, team) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
164
|
+
if (team.provider === 'github') return githubProvider(root, team);
|
|
165
|
+
const command = commandProvider(root, team, team.provider.command);
|
|
166
|
+
if (team.provider.fallback !== 'github') return command;
|
|
167
|
+
const github = githubProvider(root, team);
|
|
168
|
+
const down = (r) => Boolean(r?.unreachable) || r?.reachable === false;
|
|
169
|
+
const either =
|
|
170
|
+
(op) =>
|
|
171
|
+
(...args) => {
|
|
172
|
+
const r = command[op](...args);
|
|
173
|
+
if (!down(r) && r !== null) return r;
|
|
174
|
+
const g = github[op](...args);
|
|
175
|
+
// The state says it came through GitHub, and why, for the band.
|
|
176
|
+
return op === 'state' && g
|
|
177
|
+
? { ...g, fallback: r?.why ?? "The team provider can't be reached." }
|
|
178
|
+
: g;
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
...Object.fromEntries(
|
|
182
|
+
[
|
|
183
|
+
'whoami',
|
|
184
|
+
'claim',
|
|
185
|
+
'release',
|
|
186
|
+
'state',
|
|
187
|
+
'intend',
|
|
188
|
+
'settle',
|
|
189
|
+
'reassign',
|
|
190
|
+
'intent',
|
|
191
|
+
'since',
|
|
192
|
+
].map((op) => [op, either(op)]),
|
|
193
|
+
),
|
|
194
|
+
forget: () => (command.forget(), github.forget()),
|
|
195
|
+
};
|
|
156
196
|
}
|
|
157
197
|
|
|
158
198
|
function githubProvider(root, team) {
|
|
@@ -402,11 +442,26 @@ function githubProvider(root, team) {
|
|
|
402
442
|
'number,title,author,headRefName,isDraft,updatedAt,url',
|
|
403
443
|
]) ?? 'null',
|
|
404
444
|
);
|
|
445
|
+
const who = me();
|
|
405
446
|
const state = {
|
|
406
447
|
at: new Date().toISOString(),
|
|
407
448
|
reachable: Boolean(claims),
|
|
408
449
|
github: Boolean(intents && pulls),
|
|
409
|
-
me:
|
|
450
|
+
me: who,
|
|
451
|
+
// What coordinates the team, for the Team pane: GitHub itself, through refs and discussions.
|
|
452
|
+
provider: {
|
|
453
|
+
name: 'GitHub',
|
|
454
|
+
live: false,
|
|
455
|
+
detail: `claims are refs on origin, intents are discussions in "${team.intents}"`,
|
|
456
|
+
},
|
|
457
|
+
// TEAM-13: what the team still needs set up for its claims and intents to work.
|
|
458
|
+
setup: [
|
|
459
|
+
...(who ? [] : ["gh isn't signed in on this machine: run gh auth login."]),
|
|
460
|
+
...(claims ? [] : ["The shared repository (origin) can't be reached."]),
|
|
461
|
+
...(found && !found.category
|
|
462
|
+
? [noCategory(found).replace(/, then raise the intent again\.$/, '.')]
|
|
463
|
+
: []),
|
|
464
|
+
],
|
|
410
465
|
techLead: team.techLead,
|
|
411
466
|
owners: team.owners,
|
|
412
467
|
staleDays: team.staleDays,
|
package/hooks/mod/decisions.mjs
CHANGED
|
@@ -16,6 +16,7 @@ export const DECISION_KINDS = new Set([
|
|
|
16
16
|
'teamCollision',
|
|
17
17
|
'teamIntent',
|
|
18
18
|
'teamReview',
|
|
19
|
+
'teamDrift',
|
|
19
20
|
]);
|
|
20
21
|
|
|
21
22
|
/** What each answer does, in the person's words. */
|
|
@@ -72,6 +73,8 @@ function whyOf(line, check) {
|
|
|
72
73
|
if (line.kind === 'teamIntent')
|
|
73
74
|
return "You're the tech lead: a change of direction is agreed or dropped by you, and until then everyone builds on what was agreed.";
|
|
74
75
|
if (line.kind === 'teamReview') return "It's in a lane you own, so its review is yours.";
|
|
76
|
+
if (line.kind === 'teamDrift')
|
|
77
|
+
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.";
|
|
75
78
|
return null;
|
|
76
79
|
}
|
|
77
80
|
|
package/hooks/mod/loop.mjs
CHANGED
|
@@ -3,7 +3,16 @@
|
|
|
3
3
|
// existing skills, and the hooks judge every call as before.
|
|
4
4
|
|
|
5
5
|
/** The steps the loop hands the lead; the rest (spec-design, init, check) wait for the person. */
|
|
6
|
-
const LEAD_STEPS = new Set([
|
|
6
|
+
const LEAD_STEPS = new Set([
|
|
7
|
+
'dispatch',
|
|
8
|
+
'review',
|
|
9
|
+
'lead',
|
|
10
|
+
'merge',
|
|
11
|
+
'rules',
|
|
12
|
+
'design',
|
|
13
|
+
'setup',
|
|
14
|
+
'update',
|
|
15
|
+
]);
|
|
7
16
|
|
|
8
17
|
/**
|
|
9
18
|
* The prompt for a step from `code-kit next --json` (LOOP-1), or null when it isn't the lead's to
|
|
@@ -27,6 +36,9 @@ export function leadPrompt(step, { cli = 'code-kit' } = {}) {
|
|
|
27
36
|
return ids.length
|
|
28
37
|
? `Before any code is written, set up ${ids[0].split(':')[0]}: run /${ids[0]}. ${step.why ?? ''}`.trim()
|
|
29
38
|
: null;
|
|
39
|
+
// ONB-6: an older setup brought up to date before the next story; init shows the person the diff.
|
|
40
|
+
if (step.step === 'update')
|
|
41
|
+
return `Before the next story, bring code-kit's setup up to date: run /code-kit:init. ${step.why ?? ''}`.trim();
|
|
30
42
|
// ONB-2: the screens designed, and approved by the person, before they're built.
|
|
31
43
|
if (step.step === 'design')
|
|
32
44
|
return ids.length
|
|
@@ -61,6 +73,7 @@ export function stepLabel(step) {
|
|
|
61
73
|
rules: 'Rules onto',
|
|
62
74
|
design: 'Design',
|
|
63
75
|
setup: 'Set up',
|
|
76
|
+
update: 'Update the setup',
|
|
64
77
|
}[step.step] ?? step.step;
|
|
65
78
|
return ids ? `${verb} ${ids}` : verb;
|
|
66
79
|
}
|
package/hooks/mod/panes.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Panes v2 (docs/specs/11-panes.md): the Lanes pane's frame. A counts header, tabs, what needs the
|
|
2
2
|
// person, the tab's body and the keys, as pure functions of what the mod already holds. The Lanes
|
|
3
3
|
// tab's body is view.mjs's lanesPane; the Queue and Usage tabs reuse its sections.
|
|
4
|
-
import { teamTab } from './teamview.mjs';
|
|
5
4
|
import { trailRows as trailRowsDrawn, trailSvg } from './trail.mjs';
|
|
6
5
|
import {
|
|
7
6
|
MAP_LEGEND,
|
|
@@ -32,7 +31,6 @@ export const TABS = [
|
|
|
32
31
|
{ id: 'map', label: 'Trail', hotkey: '5' },
|
|
33
32
|
{ id: 'queue', label: 'Queue', hotkey: '6' },
|
|
34
33
|
{ id: 'usage', label: 'Usage', hotkey: '7' },
|
|
35
|
-
{ id: 'team', label: 'Team', hotkey: '8' },
|
|
36
34
|
];
|
|
37
35
|
|
|
38
36
|
/** The pane's own state, kept by the mod between draws. */
|
|
@@ -246,8 +244,7 @@ export function lanesFrame(view, els, on) {
|
|
|
246
244
|
key: 'tabs',
|
|
247
245
|
flexDirection: 'row',
|
|
248
246
|
columnGap: 1,
|
|
249
|
-
|
|
250
|
-
children: TABS.filter((t) => t.id !== 'team' || view.team).map((t) =>
|
|
247
|
+
children: TABS.map((t) =>
|
|
251
248
|
Button({
|
|
252
249
|
key: `tab-${t.id}`,
|
|
253
250
|
label: t.label,
|
|
@@ -272,52 +269,48 @@ export function lanesFrame(view, els, on) {
|
|
|
272
269
|
|
|
273
270
|
const selected = state.lanes.find((l) => l.name === ui.selected) ?? null;
|
|
274
271
|
const body =
|
|
275
|
-
ui.tab === '
|
|
276
|
-
?
|
|
277
|
-
: ui.tab === '
|
|
278
|
-
?
|
|
279
|
-
: ui.tab === '
|
|
280
|
-
?
|
|
281
|
-
: ui.tab === '
|
|
282
|
-
?
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
: ui.tab === '
|
|
289
|
-
?
|
|
290
|
-
:
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}),
|
|
308
|
-
];
|
|
272
|
+
ui.tab === 'decisions'
|
|
273
|
+
? decisionsTab(view.cards ?? [], ui, els, on)
|
|
274
|
+
: ui.tab === 'journal'
|
|
275
|
+
? journalTab(view.journal ?? [], els, view.now)
|
|
276
|
+
: ui.tab === 'code'
|
|
277
|
+
? codeTab(view, els, on)
|
|
278
|
+
: ui.tab === 'map'
|
|
279
|
+
? mapTab(view.status, ui.cell, els, on, view.trail, {
|
|
280
|
+
surface: view.surface,
|
|
281
|
+
columns: view.columns,
|
|
282
|
+
})
|
|
283
|
+
: ui.tab === 'queue'
|
|
284
|
+
? queueSection(loop, text, Box)
|
|
285
|
+
: ui.tab === 'usage'
|
|
286
|
+
? usageSection(view.usage, plan, text, Box)
|
|
287
|
+
: [
|
|
288
|
+
lanesPane(state, els, view.usage, plan, loop, {
|
|
289
|
+
filter: ui.filter,
|
|
290
|
+
search: ui.search,
|
|
291
|
+
selected: ui.selected,
|
|
292
|
+
live: view.live ?? {},
|
|
293
|
+
art: view.art ?? null,
|
|
294
|
+
detail: selected
|
|
295
|
+
? {
|
|
296
|
+
agent: selected.agent ?? null,
|
|
297
|
+
owns: view.paths?.[selected.name] ?? [],
|
|
298
|
+
stories: laneStories(selected, state),
|
|
299
|
+
}
|
|
300
|
+
: null,
|
|
301
|
+
combined: view.combined ?? null,
|
|
302
|
+
}),
|
|
303
|
+
];
|
|
309
304
|
const empty =
|
|
310
|
-
ui.tab === '
|
|
311
|
-
? '
|
|
312
|
-
: ui.tab === '
|
|
313
|
-
? 'Nothing
|
|
314
|
-
: ui.tab === '
|
|
315
|
-
? '
|
|
316
|
-
: ui.tab === '
|
|
317
|
-
? '
|
|
318
|
-
:
|
|
319
|
-
? 'No usage measured yet this session.'
|
|
320
|
-
: null;
|
|
305
|
+
ui.tab === 'decisions'
|
|
306
|
+
? 'Nothing needs you. The lead is handling the build.'
|
|
307
|
+
: ui.tab === 'journal'
|
|
308
|
+
? 'Nothing recorded yet: the journal starts with the brief.'
|
|
309
|
+
: ui.tab === 'queue'
|
|
310
|
+
? 'The merge queue is empty.'
|
|
311
|
+
: ui.tab === 'usage'
|
|
312
|
+
? 'No usage measured yet this session.'
|
|
313
|
+
: null;
|
|
321
314
|
|
|
322
315
|
// VIEW-6: j/k move between lanes; the selected lane's acts; f filters.
|
|
323
316
|
const opening = selected ? storyToOpen(selected, state) : null;
|