@joshuafolkken/kit 1.1435.0 → 1.1453.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/skills/workflow-commands/backlogrun-park.md +2 -0
- package/.claude/skills/workflow-commands/backlogrun-steps.md +9 -7
- package/.claude/skills/workflow-commands/backlogrun.md +2 -2
- package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
- package/.claude/skills/workflow-commands/halfrun.md +1 -1
- package/.claude/skills/workflow-commands/kickoff.md +12 -13
- package/.claude/skills/workflow-commands/observation-filing.md +1 -1
- package/.claude/skills/workflow-commands/prerequisite.md +6 -0
- package/.claude/skills/workflow-commands/retrospective.md +5 -5
- package/.claude/skills/workflow-commands/split-assessment.md +2 -2
- package/README.md +15 -17
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-3LE6VLSR.js → chunk-3C4XRYG6.js} +18 -5
- package/dist/hooks/{chunk-JXQBK323.js → chunk-5C7L6FRC.js} +131 -104
- package/dist/hooks/{chunk-EMB55BDF.js → chunk-D3NJXN5M.js} +2 -2
- package/dist/hooks/{chunk-7KMN5QB5.js → chunk-EF5GQ6WS.js} +3 -3
- package/dist/hooks/{chunk-2G5F2YCD.js → chunk-LBFYEOXJ.js} +3 -3
- package/dist/hooks/{chunk-XER6SSIB.js → chunk-MBJYQPEK.js} +16 -6
- package/dist/hooks/{chunk-QL6QTPWN.js → chunk-NO4AALP5.js} +3 -3
- package/dist/hooks/{chunk-4BHUPFKF.js → chunk-OLMO72ON.js} +1 -1
- package/dist/hooks/{chunk-MSYSUSY6.js → chunk-SHYUQOIS.js} +5 -5
- package/dist/hooks/{chunk-5SJSSCJG.js → chunk-UT7YC3N2.js} +1 -1
- package/dist/hooks/{chunk-MU4LJFIV.js → chunk-W5ZKGTSY.js} +3 -3
- package/dist/hooks/{chunk-YZXYOAZ3.js → chunk-Y3GKIKGW.js} +11 -5
- package/dist/hooks/{chunk-YW452ZAX.js → chunk-Z4XI7CZS.js} +2 -2
- package/dist/hooks/codex-hook-adapter.js +13 -13
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +3 -3
- package/dist/hooks/inputs.json +241 -240
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +12 -12
- package/dist/hooks/session-lang.js +3 -3
- package/dist/hooks/stop-guard.js +6 -6
- package/dist/josh.js +33 -10
- package/package.json +1 -1
- package/prompts/collaboration-workflow/issue-template.md +10 -8
- package/prompts/review.md +8 -4
- package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
- package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
- package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
- package/scripts/auto-ok/auto-ok-judge.ts +67 -0
- package/scripts/backlog/backlog-drive-cli.ts +14 -0
- package/scripts/backlog/backlog-drive.ts +22 -3
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/fresh-machine/fresh-machine-patterns.ts +60 -15
- package/scripts/gate/project-checks.ts +5 -2
- package/scripts/gh/git-gh-issue-rest.ts +1 -0
- package/scripts/gh/git-gh-issue.ts +9 -0
- package/scripts/init/init-actions.ts +3 -3
- package/scripts/init/init-ai-copy.ts +1 -1
- package/scripts/init/init-basic.ts +1 -1
- package/scripts/init/init-bootstrap.ts +6 -2
- package/scripts/init/init-dry-run-report.ts +150 -0
- package/scripts/init/init-dry-run.ts +189 -0
- package/scripts/init/init-install.ts +8 -1
- package/scripts/init/init.ts +57 -19
- package/scripts/init/kit-setup-state.ts +11 -17
- package/scripts/init/own-formatter.ts +21 -0
- package/scripts/init/project-profile.ts +25 -6
- package/scripts/init/start-dry-run.ts +70 -0
- package/scripts/init/start-plan.ts +8 -3
- package/scripts/init/start-setup-pr.ts +13 -24
- package/scripts/init/start-steps.ts +16 -1
- package/scripts/init/start.ts +38 -18
- package/scripts/init/working-tree.ts +116 -0
- package/scripts/issue/issue-auto-ok.ts +68 -99
- package/scripts/issue/issue-file-cli.ts +17 -17
- package/scripts/issue/issue-file.ts +1 -6
- package/scripts/issue/issue-labels.ts +6 -2
- package/scripts/issue/issue-release-cli.ts +69 -23
- package/scripts/issue/issue-release.ts +3 -1
- package/scripts/josh/josh-commands-ai.ts +11 -0
- package/scripts/josh/josh-commands-issue.ts +3 -3
- package/scripts/josh/josh-commands-project.ts +2 -2
- package/scripts/josh/process-identity.ts +32 -6
- package/scripts/lane/lane-broker-server.ts +69 -0
- package/scripts/lane/lane-session-broker.ts +37 -14
- package/scripts/observations/observation-ledger-migrate.ts +3 -15
- package/scripts/release/release-issue-close.ts +133 -0
- package/scripts/release/release-publish.ts +12 -1
- package/scripts/repo/repository-labels.ts +11 -1
- package/scripts/review/comment-ranges.ts +199 -0
- package/scripts/review/review-brief-cli.ts +22 -5
- package/scripts/review/review-code-tree.ts +119 -0
- package/scripts/review/review-round2-cli.ts +16 -5
- package/scripts/review/review-round2.ts +30 -10
- package/scripts/review/review-stamps.ts +22 -1
- package/scripts/uninstall/distributed-files.ts +1 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { issue_auto_ok, type AutoOkDecision } from '#scripts/issue/issue-auto-ok'
|
|
3
|
+
import { AUTO_OK_LABEL, has_label_name } from '#scripts/issue/issue-labels'
|
|
4
|
+
import type { WatchComment } from '#scripts/issue/issue-watch-diff'
|
|
5
|
+
|
|
6
|
+
// The pure half of `josh auto-ok:judge`: which open issues `backlogrun` judges for `auto-ok` when a lane
|
|
7
|
+
// is free and nothing is opted in, and whether one was already judged (joshuafolkken/kit#3854).
|
|
8
|
+
//
|
|
9
|
+
// **The mechanical exclusions are `issue_auto_ok.exclusion_of`**, the list a filing is judged by
|
|
10
|
+
// (joshuafolkken/kit#3853), never a second one. What a label cannot answer — whether a policy decision,
|
|
11
|
+
// an external account or a human visual check is needed — is the agent's reading of the body and the
|
|
12
|
+
// comments, so this module only says which issues still owe that reading.
|
|
13
|
+
//
|
|
14
|
+
// **The record is the judgement comment itself.** It carries a digest of the body it judged, so an
|
|
15
|
+
// issue is judged again only when its body changed or a person commented after it — the same line
|
|
16
|
+
// `issue_watch_diff` draws between a person's update and an automated one.
|
|
17
|
+
|
|
18
|
+
interface JudgeIssue {
|
|
19
|
+
number: number
|
|
20
|
+
labels: ReadonlyArray<string>
|
|
21
|
+
body: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MARKER_PREFIX = '<!-- josh:auto-ok-judged body='
|
|
25
|
+
const MARKER_SUFFIX = ' -->'
|
|
26
|
+
const MARKER_PATTERN = /<!-- josh:auto-ok-judged body=(?<digest>[\da-f]+) -->/u
|
|
27
|
+
const DIGEST_LENGTH = 16
|
|
28
|
+
const CRLF = '\r\n'
|
|
29
|
+
const LF = '\n'
|
|
30
|
+
|
|
31
|
+
function is_candidate(issue: JudgeIssue): boolean {
|
|
32
|
+
if (has_label_name(issue.labels, AUTO_OK_LABEL)) return false
|
|
33
|
+
|
|
34
|
+
return issue_auto_ok.exclusion_of(issue.labels) === undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// GitHub stores a body saved in its web editor with CRLF, so the digest is taken over LF alone.
|
|
38
|
+
function body_digest(body: string): string {
|
|
39
|
+
const normalized = body.split(CRLF).join(LF)
|
|
40
|
+
|
|
41
|
+
return createHash('sha256').update(normalized).digest('hex').slice(0, DIGEST_LENGTH)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function digest_of(comment: WatchComment): string | undefined {
|
|
45
|
+
return MARKER_PATTERN.exec(comment.body)?.groups?.['digest']
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Judged when the newest judgement comment covers the body as it stands and no person has commented
|
|
49
|
+
// since. A failed comment read never reaches here — the caller treats it as nothing learned.
|
|
50
|
+
function is_judged(body: string, comments: ReadonlyArray<WatchComment>): boolean {
|
|
51
|
+
const judgement = comments.findLast((comment) => digest_of(comment) !== undefined)
|
|
52
|
+
|
|
53
|
+
if (judgement === undefined || digest_of(judgement) !== body_digest(body)) return false
|
|
54
|
+
|
|
55
|
+
return comments.every((comment) => comment.id <= judgement.id || comment.is_automated)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function comment_of(decision: AutoOkDecision, body: string): string {
|
|
59
|
+
const marker = `${MARKER_PREFIX}${body_digest(body)}${MARKER_SUFFIX}`
|
|
60
|
+
|
|
61
|
+
return `${issue_auto_ok.line_of(decision)}${LF}${LF}${marker}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const auto_ok_judge = { comment_of, is_candidate, is_judged }
|
|
65
|
+
|
|
66
|
+
export type { JudgeIssue }
|
|
67
|
+
export { auto_ok_judge }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { setTimeout as sleep } from 'node:timers/promises'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { auto_ok_judge_read, type JudgedCache } from '#scripts/auto-ok/auto-ok-judge-read'
|
|
4
5
|
import { issue_cite } from '#scripts/issue/issue-cite'
|
|
5
6
|
import { hook_decision } from '#scripts/josh/hook-decision'
|
|
6
7
|
import { josh_command } from '#scripts/josh/josh-run'
|
|
@@ -166,6 +167,18 @@ function end_line(end: DriveEnd): string {
|
|
|
166
167
|
return end.detail === undefined ? base : `${base} ${end.detail}`
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
// The count of open issues owing an `auto-ok` judgement, over a cache kept for the process so a watch
|
|
171
|
+
// minute reads the comments only of the issues that moved (`auto-ok-judge-read.ts`).
|
|
172
|
+
function judgement_reader(): () => Promise<number> {
|
|
173
|
+
const cache: JudgedCache = new Map()
|
|
174
|
+
|
|
175
|
+
return async () => {
|
|
176
|
+
const owed = await auto_ok_judge_read.owed(cache)
|
|
177
|
+
|
|
178
|
+
return owed?.length ?? 0
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
169
182
|
function ports_of(
|
|
170
183
|
context: DriveContext,
|
|
171
184
|
seeded: ReadonlyArray<string>,
|
|
@@ -177,6 +190,7 @@ function ports_of(
|
|
|
177
190
|
offer: async (state) => await read_offer(state, context),
|
|
178
191
|
launch: async (issue) => await backlog_drive_launch.launch(issue, context.owner),
|
|
179
192
|
free_lanes: backlog_ready.drive_free_lane_count,
|
|
193
|
+
judgement_owed: judgement_reader(),
|
|
180
194
|
now: () => new Date(),
|
|
181
195
|
sleep: async (milliseconds) => {
|
|
182
196
|
await sleep(milliseconds)
|
|
@@ -65,6 +65,8 @@ const KNOWN_VERDICTS: ReadonlySet<string> = new Set([
|
|
|
65
65
|
])
|
|
66
66
|
const NO_RETRIES = 0
|
|
67
67
|
const NO_FREE_LANES = 0
|
|
68
|
+
const EXHAUSTED_ANSWER = 'exhausted'
|
|
69
|
+
const JUDGE_REASON = 'judge'
|
|
68
70
|
const LAUNCHED = 'launched'
|
|
69
71
|
const UNOPENED = 'unopened'
|
|
70
72
|
|
|
@@ -98,7 +100,8 @@ interface DriveState {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
interface DriveEnd {
|
|
101
|
-
// Why the loop handed control back: `merge`, `launch`, `offer`, `watch`, `stop`, `window`
|
|
103
|
+
// Why the loop handed control back: `merge`, `launch`, `offer`, `judge`, `watch`, `stop`, `window`
|
|
104
|
+
// or a verdict.
|
|
102
105
|
reason: string
|
|
103
106
|
// The token the parent reads, where one command printed it.
|
|
104
107
|
token: string | undefined
|
|
@@ -121,6 +124,9 @@ interface DrivePorts {
|
|
|
121
124
|
// The lanes free right now — `backlog_ready.drive_free_lane_count` in production, the limit less the
|
|
122
125
|
// open lanes.
|
|
123
126
|
free_lanes: () => Promise<number>
|
|
127
|
+
// How many open issues lacking `auto-ok` still owe a judgement — `auto_ok_judge_read.owed` in
|
|
128
|
+
// production, `0` when the read failed.
|
|
129
|
+
judgement_owed: () => Promise<number>
|
|
124
130
|
now: () => Date
|
|
125
131
|
}
|
|
126
132
|
|
|
@@ -149,7 +155,7 @@ function ended(reason: string, state: DriveState, token?: string, issue?: string
|
|
|
149
155
|
function is_drain(read: OfferRead, state: DriveState): boolean {
|
|
150
156
|
return (
|
|
151
157
|
read.verdict === WATCH_VERDICT &&
|
|
152
|
-
read.answer ===
|
|
158
|
+
read.answer === EXHAUSTED_ANSWER &&
|
|
153
159
|
state.in_flight.length === 0 &&
|
|
154
160
|
read.is_retrospective_owed === true
|
|
155
161
|
)
|
|
@@ -158,7 +164,7 @@ function is_drain(read: OfferRead, state: DriveState): boolean {
|
|
|
158
164
|
function needs_retrospective(read: OfferRead, state: DriveState): boolean {
|
|
159
165
|
return (
|
|
160
166
|
read.verdict === STOP_VERDICT &&
|
|
161
|
-
read.answer ===
|
|
167
|
+
read.answer === EXHAUSTED_ANSWER &&
|
|
162
168
|
state.in_flight.length === 0 &&
|
|
163
169
|
read.is_retrospective_owed === true
|
|
164
170
|
)
|
|
@@ -318,6 +324,18 @@ function on_verdict(read: OfferRead, state: DriveState): PassResult {
|
|
|
318
324
|
return { kind: 'continue', state }
|
|
319
325
|
}
|
|
320
326
|
|
|
327
|
+
// **A free lane with nothing opted in is the open backlog's turn to be judged**
|
|
328
|
+
// (joshuafolkken/kit#3854): a watch over an exhausted pool hands back `judge` while an open issue
|
|
329
|
+
// lacking `auto-ok` still owes a judgement, ahead of the drain it would otherwise be. Once every one
|
|
330
|
+
// carries its judgement comment the count is zero and the watch is the driver's again.
|
|
331
|
+
async function is_judgement_owed(read: OfferRead, ports: DrivePorts): Promise<boolean> {
|
|
332
|
+
const is_exhausted = read.verdict === WATCH_VERDICT && read.answer === EXHAUSTED_ANSWER
|
|
333
|
+
|
|
334
|
+
if (!is_exhausted || (await ports.free_lanes()) <= NO_FREE_LANES) return false
|
|
335
|
+
|
|
336
|
+
return (await ports.judgement_owed()) > 0
|
|
337
|
+
}
|
|
338
|
+
|
|
321
339
|
async function ask_offer(state: DriveState, ports: DrivePorts): Promise<PassResult> {
|
|
322
340
|
const read = await ports.offer(state)
|
|
323
341
|
|
|
@@ -326,6 +344,7 @@ async function ask_offer(state: DriveState, ports: DrivePorts): Promise<PassResu
|
|
|
326
344
|
const next = { ...state, retries: read.retries }
|
|
327
345
|
|
|
328
346
|
if (read.verdict === RUN_VERDICT) return await launch_all(read.issues, next, ports)
|
|
347
|
+
if (await is_judgement_owed(read, ports)) return ended(JUDGE_REASON, next, JUDGE_REASON)
|
|
329
348
|
|
|
330
349
|
return on_verdict(read, next)
|
|
331
350
|
}
|
|
@@ -76,7 +76,7 @@ const DOCUMENT_BYTE_BUDGET: ReadonlyArray<DocumentBudget> = [
|
|
|
76
76
|
{ path: 'docs/josh-commands-automation.md', bytes: 53_248 },
|
|
77
77
|
{ path: 'docs/josh-commands-run.md', bytes: 77_824 },
|
|
78
78
|
{ path: 'docs/josh-commands-backlog.md', bytes: 61_440 },
|
|
79
|
-
{ path: 'docs/josh-commands.md', bytes:
|
|
79
|
+
{ path: 'docs/josh-commands.md', bytes: 57_344 },
|
|
80
80
|
{ path: 'docs/labels-and-run-states.md', bytes: 8192 },
|
|
81
81
|
{ path: 'prompts/coding-standards.md', bytes: 8192 },
|
|
82
82
|
{ path: 'prompts/collaboration-workflow.md', bytes: 8192 },
|
|
@@ -27,13 +27,18 @@ const INIT_COMMANDS = [INSTALL_TARBALL, 'pnpm exec josh init', GATE]
|
|
|
27
27
|
// `josh start` leaves the project on its setup branch, where the gate asks for the scoped checks
|
|
28
28
|
// first; this is the line its refusal hands back.
|
|
29
29
|
const SCOPED_CHECKS = 'pnpm josh lint:related && pnpm josh test:related'
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
30
|
+
const START = 'pnpm exec josh start --yes --github'
|
|
31
|
+
|
|
32
|
+
// An agent names the profile rather than leaving it to detection, so every pattern passes its own.
|
|
33
|
+
function start_commands(profile: string): Array<string> {
|
|
34
|
+
return [
|
|
35
|
+
EXCLUDE_RELEASE_AGE,
|
|
36
|
+
INSTALL_PUBLISHED,
|
|
37
|
+
`${START} --profile ${profile}`,
|
|
38
|
+
SCOPED_CHECKS,
|
|
39
|
+
GATE,
|
|
40
|
+
]
|
|
41
|
+
}
|
|
37
42
|
|
|
38
43
|
// `gh auth login` cannot run without a person, and it is what points git at gh for credentials; with
|
|
39
44
|
// the token in the environment this is the same step.
|
|
@@ -49,6 +54,11 @@ const FIRST_COMMIT = [
|
|
|
49
54
|
'git add --all && git commit --message "Initial commit"',
|
|
50
55
|
]
|
|
51
56
|
const CREATE_REPOSITORY = 'gh repo create "$RUN_NAME" --private --source . --push'
|
|
57
|
+
// The labels a repository already had, recorded before `josh start` so the probe after it shows none
|
|
58
|
+
// was removed or changed.
|
|
59
|
+
const LABELS = 'gh label list --limit 100'
|
|
60
|
+
// The setup pull request's CI, waited for and recorded; the pull request itself is never merged.
|
|
61
|
+
const SETUP_CI = 'gh pr checks --watch --interval 60'
|
|
52
62
|
|
|
53
63
|
// The two states #3712 was found in: a shell that reads no rc file, and a PATH with `node` on it and
|
|
54
64
|
// no `pnpm`.
|
|
@@ -60,13 +70,29 @@ const PATH_PROBES = [
|
|
|
60
70
|
`mkdir -p ${NODE_ONLY} && ln -sf "$(command -v node)" ${NODE_ONLY}/node && ${BARE_SHELL} 'PATH="$HOME/node-only:$PATH" ./node_modules/.bin/josh gate'`,
|
|
61
71
|
]
|
|
62
72
|
|
|
73
|
+
// What a `josh start` run is checked against before the PATH states: the profile it recorded, the
|
|
74
|
+
// repository's visibility and branch, its labels, and the files the first commit on `main` holds. Where
|
|
75
|
+
// `main` already had a commit, `josh start` opens a setup Issue and pull request instead of pushing to
|
|
76
|
+
// it; the last four record those, the files the pull request carries and the `main` GitHub holds.
|
|
77
|
+
const PROFILE = 'pnpm josh profile'
|
|
78
|
+
const START_PROBES = [
|
|
79
|
+
PROFILE,
|
|
80
|
+
'gh repo view --json visibility,defaultBranchRef',
|
|
81
|
+
LABELS,
|
|
82
|
+
'git show --name-only --format= "$(git rev-list --max-parents=0 main)"',
|
|
83
|
+
'gh issue list --state all --json number,title,state,labels',
|
|
84
|
+
'gh pr list --state all --json number,title,state,headRefName,labels,closingIssuesReferences',
|
|
85
|
+
'gh pr diff --name-only',
|
|
86
|
+
'git log --oneline origin/main',
|
|
87
|
+
]
|
|
88
|
+
|
|
63
89
|
// What a `josh init` run is checked against after the gate: the profile it chose, the files it left
|
|
64
90
|
// (a `./.git` line is a repository it should not have made), and a second run that changes nothing.
|
|
65
91
|
const PROJECT_FILES =
|
|
66
92
|
'find . -name node_modules -prune -o -name .git -prune -o -type f -print | sort'
|
|
67
93
|
const CHECKSUMS = `${PROJECT_FILES} | xargs sha256sum`
|
|
68
94
|
const INIT_PROBES = [
|
|
69
|
-
|
|
95
|
+
PROFILE,
|
|
70
96
|
'find . -name node_modules -prune -o -print | sort',
|
|
71
97
|
`${CHECKSUMS} > "$HOME/before-second-init.sha" && pnpm exec josh init && ${CHECKSUMS} | diff "$HOME/before-second-init.sha" -`,
|
|
72
98
|
]
|
|
@@ -90,31 +116,50 @@ function init_pattern(
|
|
|
90
116
|
})
|
|
91
117
|
}
|
|
92
118
|
|
|
119
|
+
// What a `josh start` pattern adds to the shared steps: the state before the install, the profile it
|
|
120
|
+
// names, the commands that must pass after the gate, and the probes recorded before the PATH states.
|
|
121
|
+
interface StartProject {
|
|
122
|
+
setup: ReadonlyArray<string>
|
|
123
|
+
profile: string
|
|
124
|
+
checks?: ReadonlyArray<string>
|
|
125
|
+
probes?: ReadonlyArray<string>
|
|
126
|
+
}
|
|
127
|
+
|
|
93
128
|
function start_pattern(
|
|
94
129
|
project: string,
|
|
95
130
|
files: Record<string, string>,
|
|
96
|
-
setup:
|
|
131
|
+
{ setup, profile, checks = [], probes = [] }: StartProject,
|
|
97
132
|
): Scenario {
|
|
98
133
|
return fresh_machine_spec.define({
|
|
99
134
|
name: `kit-test-${project}-start`,
|
|
100
135
|
requires_github: true,
|
|
101
136
|
package_source: 'published',
|
|
102
137
|
files,
|
|
103
|
-
commands: [SIGN_IN, ...setup, ...
|
|
104
|
-
probes: PATH_PROBES,
|
|
138
|
+
commands: [SIGN_IN, ...setup, ...start_commands(profile), ...checks],
|
|
139
|
+
probes: [...START_PROBES, ...probes, ...PATH_PROBES],
|
|
105
140
|
})
|
|
106
141
|
}
|
|
107
142
|
|
|
108
143
|
const PATTERNS: ReadonlyArray<Scenario> = [
|
|
109
144
|
init_pattern('lua', LUA_FILES, [BARE_MANIFEST]),
|
|
110
145
|
// No Git: `josh start` creates the repository, the first commit and the GitHub repository.
|
|
111
|
-
start_pattern('lua', LUA_FILES, [MACHINE_IDENTITY, BARE_MANIFEST]),
|
|
146
|
+
start_pattern('lua', LUA_FILES, { setup: [MACHINE_IDENTITY, BARE_MANIFEST], profile: 'basic' }),
|
|
112
147
|
init_pattern('html', HTML_FILES, [BARE_MANIFEST]),
|
|
113
148
|
// Git with a first commit on `main`, no GitHub repository.
|
|
114
|
-
start_pattern('html', HTML_FILES, [...FIRST_COMMIT, BARE_MANIFEST]),
|
|
149
|
+
start_pattern('html', HTML_FILES, { setup: [...FIRST_COMMIT, BARE_MANIFEST], profile: 'basic' }),
|
|
115
150
|
init_pattern('svelte', {}, [CREATE_SVELTEKIT, SV_VERSION, SVELTEKIT_CHECKS], [SVELTEKIT_CHECKS]),
|
|
116
|
-
// A GitHub repository with `main` already pushed
|
|
117
|
-
|
|
151
|
+
// A GitHub repository with `main` already pushed, its labels recorded before the install; the app
|
|
152
|
+
// still builds and type-checks on the setup branch, and the setup pull request's CI is waited for.
|
|
153
|
+
start_pattern(
|
|
154
|
+
'svelte',
|
|
155
|
+
{},
|
|
156
|
+
{
|
|
157
|
+
setup: [CREATE_SVELTEKIT, SV_VERSION, ...FIRST_COMMIT, CREATE_REPOSITORY, LABELS],
|
|
158
|
+
profile: 'full',
|
|
159
|
+
checks: [SVELTEKIT_CHECKS],
|
|
160
|
+
probes: [SETUP_CI],
|
|
161
|
+
},
|
|
162
|
+
),
|
|
118
163
|
]
|
|
119
164
|
|
|
120
165
|
function names(): Array<string> {
|
|
@@ -2,6 +2,7 @@ import { existsSync, globSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { find_local_bin_upwards } from '#scripts/build/local-bin'
|
|
4
4
|
import { doctor_consumer } from '#scripts/doctor/doctor-consumer'
|
|
5
|
+
import { own_formatter } from '#scripts/init/own-formatter'
|
|
5
6
|
import { project_profile } from '#scripts/init/project-profile'
|
|
6
7
|
import { ancestor_directories } from '#scripts/lib/ancestor-directories'
|
|
7
8
|
import { json_value } from '#scripts/lib/json-value'
|
|
@@ -150,10 +151,12 @@ function type_check_skip_reason(directory: string): string | undefined {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
// Shared by `josh lint`, `josh lint:related` and `josh format`, so a basic project without Web
|
|
153
|
-
// files — a Python or Rust project, which `josh init` gives no Prettier —
|
|
154
|
-
// reason by all three.
|
|
154
|
+
// files — a Python or Rust project, which `josh init` gives no Prettier — or with a formatter of its
|
|
155
|
+
// own is skipped for the same reason by all three.
|
|
155
156
|
function prettier_skip_reason(directory: string): string | undefined {
|
|
156
157
|
if (!is_basic(directory)) return undefined
|
|
158
|
+
const formatter = own_formatter.find_config(project_root(directory))
|
|
159
|
+
if (formatter !== undefined) return `${formatter} configures the project's own formatter`
|
|
157
160
|
if (!has_files(directory, WEB_FILES)) return 'no HTML, CSS or JavaScript files were found'
|
|
158
161
|
|
|
159
162
|
return missing_tool_reason(directory, 'prettier', 'prettier')
|
|
@@ -71,6 +71,7 @@ const REST_FIELD_NAMES = new Map<string, string>([
|
|
|
71
71
|
['url', 'html_url'],
|
|
72
72
|
['createdAt', 'created_at'],
|
|
73
73
|
['closedAt', 'closed_at'],
|
|
74
|
+
['updatedAt', 'updated_at'],
|
|
74
75
|
])
|
|
75
76
|
|
|
76
77
|
const NOT_AN_ISSUE_MESSAGE = 'gh api answered something other than an issue object'
|
|
@@ -150,6 +150,14 @@ async function issue_list_by_label_in_repo(
|
|
|
150
150
|
return await issue_list({ json_fields: SUMMARY_FIELDS, limit, label, repo })
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// Every open issue with what `josh auto-ok:judge` judges it by — its labels and body — and `updatedAt`,
|
|
154
|
+
// so the backlog driver's resident check reads an issue's comments again only once it has moved.
|
|
155
|
+
const JUDGEMENT_FIELDS = 'number,title,body,labels,updatedAt'
|
|
156
|
+
|
|
157
|
+
async function issue_list_open_for_judgement(limit: number): Promise<IssueListOutcome> {
|
|
158
|
+
return await issue_list({ json_fields: JUDGEMENT_FIELDS, limit })
|
|
159
|
+
}
|
|
160
|
+
|
|
153
161
|
const git_gh_issue = {
|
|
154
162
|
...git_gh_issue_read,
|
|
155
163
|
...git_gh_issue_write,
|
|
@@ -159,6 +167,7 @@ const git_gh_issue = {
|
|
|
159
167
|
issue_list_by_label_summary,
|
|
160
168
|
issue_search_body,
|
|
161
169
|
issue_list_open_bodies,
|
|
170
|
+
issue_list_open_for_judgement,
|
|
162
171
|
issue_list_recently_closed,
|
|
163
172
|
}
|
|
164
173
|
|
|
@@ -106,7 +106,7 @@ function build_basic_settings_action(): FileAction {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
function build_basic_vscode_actions(shape: ProjectShape): ReadonlyArray<FileAction> {
|
|
109
|
-
const recommendations = shape.
|
|
109
|
+
const recommendations = shape.should_use_prettier
|
|
110
110
|
? [...BASIC_EXTENSIONS, PRETTIER_EXTENSION]
|
|
111
111
|
: BASIC_EXTENSIONS
|
|
112
112
|
const actions: Array<FileAction> = [
|
|
@@ -117,7 +117,7 @@ function build_basic_vscode_actions(shape: ProjectShape): ReadonlyArray<FileActi
|
|
|
117
117
|
),
|
|
118
118
|
]
|
|
119
119
|
|
|
120
|
-
if (shape.
|
|
120
|
+
if (shape.should_use_prettier) actions.push(build_basic_settings_action())
|
|
121
121
|
|
|
122
122
|
return actions
|
|
123
123
|
}
|
|
@@ -210,7 +210,7 @@ function build_basic_actions(shape: ProjectShape): ReadonlyArray<FileAction> {
|
|
|
210
210
|
if (shape.has_git) actions.push(build_gitignore_action())
|
|
211
211
|
if (shape.has_typescript) actions.push(build_basic_tsconfig_action())
|
|
212
212
|
|
|
213
|
-
if (shape.
|
|
213
|
+
if (shape.should_use_prettier) {
|
|
214
214
|
actions.push({
|
|
215
215
|
dest: BASIC_PRETTIER_CONFIG,
|
|
216
216
|
create: () =>
|
|
@@ -348,7 +348,7 @@ function should_copy_github_file(filename: string, shape?: ProjectShape): boolea
|
|
|
348
348
|
function should_copy_basic_file(filename: string, shape?: ProjectShape): boolean {
|
|
349
349
|
if (filename !== PRETTIER_IGNORE || shape?.profile !== 'basic') return true
|
|
350
350
|
|
|
351
|
-
return shape.
|
|
351
|
+
return shape.should_use_prettier
|
|
352
352
|
}
|
|
353
353
|
|
|
354
354
|
function should_copy_ai_file(filename: string, shape?: ProjectShape): boolean {
|
|
@@ -45,7 +45,7 @@ function merge_basic_manifest(
|
|
|
45
45
|
const with_kit = init_logic.merge_development_dependencies(with_script, {
|
|
46
46
|
[KIT_PACKAGE_NAME]: versions.kit,
|
|
47
47
|
})
|
|
48
|
-
const with_prettier = shape.
|
|
48
|
+
const with_prettier = shape.should_use_prettier
|
|
49
49
|
? init_logic.merge_development_dependencies(with_kit, { prettier: versions.prettier })
|
|
50
50
|
: with_kit
|
|
51
51
|
|
|
@@ -82,13 +82,17 @@ function ensure_project_manifest(project_root: string): void {
|
|
|
82
82
|
if (!existsSync(manifest_path)) writeFileSync(manifest_path, init_basic.initial_manifest())
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
function is_handed_off(): boolean {
|
|
86
|
+
return process.env[HANDOFF_ENV] !== undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
85
89
|
// Returns the failure to report, or undefined once the project-installed kit finished the setup.
|
|
86
90
|
function hand_off(
|
|
87
91
|
package_directory: string,
|
|
88
92
|
project_root: string,
|
|
89
93
|
args: ReadonlyArray<string>,
|
|
90
94
|
): string | undefined {
|
|
91
|
-
if (
|
|
95
|
+
if (is_handed_off()) return REPEATED_HANDOFF
|
|
92
96
|
console.info('\n🚀 Installing @joshuafolkken/kit into the project, then running its josh init')
|
|
93
97
|
const template = readFileSync(path.join(package_directory, KIT_BUILDS_TEMPLATE), 'utf8')
|
|
94
98
|
const install = kit_install_step(has_kit_dependency(project_root), template)
|
|
@@ -100,5 +104,5 @@ function hand_off(
|
|
|
100
104
|
return undefined
|
|
101
105
|
}
|
|
102
106
|
|
|
103
|
-
const init_bootstrap = { is_project_kit, kit_build_flags, hand_off }
|
|
107
|
+
const init_bootstrap = { HANDOFF_ENV, is_project_kit, kit_build_flags, hand_off, is_handed_off }
|
|
104
108
|
export { init_bootstrap }
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { json_value } from '#scripts/lib/json-value'
|
|
2
|
+
|
|
3
|
+
type ChangeKind = 'create' | 'merge' | 'overwrite' | 'delete'
|
|
4
|
+
|
|
5
|
+
interface FileChange {
|
|
6
|
+
path: string
|
|
7
|
+
before: string | undefined
|
|
8
|
+
after: string | undefined
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type JsonRecord = Record<string, unknown>
|
|
12
|
+
|
|
13
|
+
const PACKAGE_JSON = 'package.json'
|
|
14
|
+
const JSON_EXTENSION = '.json'
|
|
15
|
+
const KIND_WIDTH = 'overwrite'.length
|
|
16
|
+
const NO_CHANGES = ' (no file changes)'
|
|
17
|
+
|
|
18
|
+
// Every path whose content differs between the two snapshots, sorted, so a run reads the same twice.
|
|
19
|
+
function changed_files(
|
|
20
|
+
before: ReadonlyMap<string, string>,
|
|
21
|
+
after: ReadonlyMap<string, string>,
|
|
22
|
+
): Array<FileChange> {
|
|
23
|
+
const paths = [...new Set([...before.keys(), ...after.keys()])].toSorted((left, right) =>
|
|
24
|
+
left.localeCompare(right),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return paths
|
|
28
|
+
.filter((file) => before.get(file) !== after.get(file))
|
|
29
|
+
.map((file) => ({ path: file, before: before.get(file), after: after.get(file) }))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A changed file is a merge when every line it had survives — the project's own content is kept —
|
|
33
|
+
// and an overwrite when any of its lines is replaced or removed.
|
|
34
|
+
function keeps_every_line(before: string, after: string): boolean {
|
|
35
|
+
const remaining = new Set(after.split('\n'))
|
|
36
|
+
|
|
37
|
+
return before.split('\n').every((line) => remaining.has(line))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function is_kept_array(before: ReadonlyArray<unknown>, after: unknown): boolean {
|
|
41
|
+
if (!Array.isArray(after)) return false
|
|
42
|
+
const remaining = new Set(after.map((value) => JSON.stringify(value)))
|
|
43
|
+
|
|
44
|
+
return before.every((value) => remaining.has(JSON.stringify(value)))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A JSON file is rewritten whole when kit adds to it — `package.json` written out again, a list grown — so
|
|
48
|
+
// it is compared by value: a merge when every value it had is still there, wherever the lines moved.
|
|
49
|
+
function is_kept(before: unknown, after: unknown): boolean {
|
|
50
|
+
if (Array.isArray(before)) return is_kept_array(before, after)
|
|
51
|
+
if (!json_value.is_record(before) || !json_value.is_record(after)) return before === after
|
|
52
|
+
|
|
53
|
+
return Object.keys(before).every((key) => is_kept(before[key], after[key]))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Undefined when either side is not plain JSON (a `tsconfig.json` with comments), which falls back
|
|
57
|
+
// to the line test.
|
|
58
|
+
function keeps_every_value(before: string, after: string): boolean | undefined {
|
|
59
|
+
const old_value = json_value.parse_or_undefined(before)
|
|
60
|
+
const new_value = json_value.parse_or_undefined(after)
|
|
61
|
+
if (old_value === undefined || new_value === undefined) return undefined
|
|
62
|
+
|
|
63
|
+
return is_kept(old_value, new_value)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function keeps_content(file: string, before: string, after: string): boolean {
|
|
67
|
+
const is_kept_by_value = file.endsWith(JSON_EXTENSION)
|
|
68
|
+
? keeps_every_value(before, after)
|
|
69
|
+
: undefined
|
|
70
|
+
|
|
71
|
+
return is_kept_by_value ?? keeps_every_line(before, after)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function change_kind(change: FileChange): ChangeKind {
|
|
75
|
+
if (change.before === undefined) return 'create'
|
|
76
|
+
if (change.after === undefined) return 'delete'
|
|
77
|
+
|
|
78
|
+
return keeps_content(change.path, change.before, change.after) ? 'merge' : 'overwrite'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parse_record(content: string | undefined): JsonRecord {
|
|
82
|
+
const parsed = content === undefined ? undefined : json_value.parse_or_undefined(content)
|
|
83
|
+
|
|
84
|
+
return json_value.is_record(parsed) ? parsed : {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function changed_keys(before: JsonRecord, after: JsonRecord): Array<string> {
|
|
88
|
+
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])]
|
|
89
|
+
|
|
90
|
+
return keys.filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function entry_line(name: string, before: unknown, after: unknown): string {
|
|
94
|
+
if (before === undefined) return `+ ${name} ${JSON.stringify(after)}`
|
|
95
|
+
if (after === undefined) return `- ${name}`
|
|
96
|
+
|
|
97
|
+
return `~ ${name} ${JSON.stringify(before)} → ${JSON.stringify(after)}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A missing field reads as an empty object, so a block added or removed whole is listed too.
|
|
101
|
+
function as_entries(value: unknown): JsonRecord | undefined {
|
|
102
|
+
if (value === undefined) return {}
|
|
103
|
+
|
|
104
|
+
return json_value.is_record(value) ? value : undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A field that is an object (`scripts`, `devDependencies`) is listed entry by entry, so the plan
|
|
108
|
+
// names each script and dependency rather than the whole block.
|
|
109
|
+
function field_lines(key: string, before: unknown, after: unknown): Array<string> {
|
|
110
|
+
const old_entries = as_entries(before)
|
|
111
|
+
const new_entries = as_entries(after)
|
|
112
|
+
|
|
113
|
+
if (old_entries === undefined || new_entries === undefined) {
|
|
114
|
+
return [entry_line(key, before, after)]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return changed_keys(old_entries, new_entries).map((name) =>
|
|
118
|
+
entry_line(`${key}.${name}`, old_entries[name], new_entries[name]),
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function package_json_lines(change: FileChange): Array<string> {
|
|
123
|
+
const before = parse_record(change.before)
|
|
124
|
+
const after = parse_record(change.after)
|
|
125
|
+
|
|
126
|
+
return changed_keys(before, after).flatMap((key) => field_lines(key, before[key], after[key]))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function change_lines(change: FileChange): Array<string> {
|
|
130
|
+
const head = ` ${change_kind(change).padEnd(KIND_WIDTH)} ${change.path}`
|
|
131
|
+
if (change.path !== PACKAGE_JSON || change.after === undefined) return [head]
|
|
132
|
+
|
|
133
|
+
return [head, ...package_json_lines(change).map((line) => ` ${line}`)]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function format_report(
|
|
137
|
+
title: string,
|
|
138
|
+
changes: ReadonlyArray<FileChange>,
|
|
139
|
+
dependency_lines: ReadonlyArray<string>,
|
|
140
|
+
): string {
|
|
141
|
+
const files =
|
|
142
|
+
changes.length === 0 ? [NO_CHANGES] : changes.flatMap((change) => change_lines(change))
|
|
143
|
+
const dependencies = dependency_lines.map((line) => ` ${line}`)
|
|
144
|
+
|
|
145
|
+
return [title, '', 'Files:', ...files, '', 'Dependencies:', ...dependencies].join('\n')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const init_dry_run_report = { changed_files, change_kind, package_json_lines, format_report }
|
|
149
|
+
export { init_dry_run_report }
|
|
150
|
+
export type { ChangeKind, FileChange }
|