@joshuafolkken/kit 1.1435.0 → 1.1455.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.
Files changed (105) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-handoff-report.md +1 -1
  2. package/.claude/skills/workflow-commands/backlogrun-park.md +2 -0
  3. package/.claude/skills/workflow-commands/backlogrun-steps.md +9 -7
  4. package/.claude/skills/workflow-commands/backlogrun.md +2 -2
  5. package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
  6. package/.claude/skills/workflow-commands/halfrun.md +3 -2
  7. package/.claude/skills/workflow-commands/kickoff.md +12 -13
  8. package/.claude/skills/workflow-commands/observation-filing.md +1 -1
  9. package/.claude/skills/workflow-commands/prerequisite.md +6 -0
  10. package/.claude/skills/workflow-commands/prrun.md +2 -1
  11. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  12. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  13. package/CLAUDE.md +1 -1
  14. package/README.md +10 -19
  15. package/dist/CLAUDE.md +1 -1
  16. package/dist/hooks/batch-guard.js +6 -6
  17. package/dist/hooks/{chunk-3LE6VLSR.js → chunk-3C4XRYG6.js} +18 -5
  18. package/dist/hooks/{chunk-7KMN5QB5.js → chunk-5H4SSDSD.js} +3 -3
  19. package/dist/hooks/{chunk-YZXYOAZ3.js → chunk-7WQBPLUN.js} +11 -5
  20. package/dist/hooks/{chunk-2G5F2YCD.js → chunk-D5QBTOCZ.js} +3 -3
  21. package/dist/hooks/{chunk-MU4LJFIV.js → chunk-GPLGZ6IC.js} +3 -3
  22. package/dist/hooks/{chunk-MSYSUSY6.js → chunk-HFGWYGUV.js} +5 -5
  23. package/dist/hooks/{chunk-JXQBK323.js → chunk-HHI6FO3R.js} +132 -105
  24. package/dist/hooks/{chunk-XER6SSIB.js → chunk-LIAFCXL7.js} +17 -7
  25. package/dist/hooks/{chunk-4BHUPFKF.js → chunk-OLMO72ON.js} +1 -1
  26. package/dist/hooks/{chunk-EMB55BDF.js → chunk-TBJFTSUY.js} +2 -2
  27. package/dist/hooks/{chunk-5SJSSCJG.js → chunk-UT7YC3N2.js} +1 -1
  28. package/dist/hooks/{chunk-QL6QTPWN.js → chunk-WNLVGT7E.js} +3 -3
  29. package/dist/hooks/{chunk-YW452ZAX.js → chunk-Z4XI7CZS.js} +2 -2
  30. package/dist/hooks/codex-hook-adapter.js +13 -13
  31. package/dist/hooks/duplicate-read-guard.js +7 -7
  32. package/dist/hooks/format-edited.js +3 -3
  33. package/dist/hooks/inputs.json +229 -226
  34. package/dist/hooks/investigation-guard.js +7 -7
  35. package/dist/hooks/pretool-guard.js +12 -12
  36. package/dist/hooks/session-lang.js +3 -3
  37. package/dist/hooks/stop-guard.js +64 -11
  38. package/dist/josh.js +34 -11
  39. package/package.json +1 -1
  40. package/prompts/collaboration-workflow/issue-template.md +10 -8
  41. package/prompts/collaboration-workflow/shell-body.md +1 -1
  42. package/prompts/review.md +8 -4
  43. package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
  44. package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
  45. package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
  46. package/scripts/auto-ok/auto-ok-judge.ts +67 -0
  47. package/scripts/backlog/backlog-drive-cli.ts +14 -0
  48. package/scripts/backlog/backlog-drive.ts +22 -3
  49. package/scripts/document/document-byte-budget.ts +1 -1
  50. package/scripts/fresh-machine/fresh-machine-patterns.ts +60 -15
  51. package/scripts/gate/project-checks.ts +5 -2
  52. package/scripts/gh/git-gh-issue-rest.ts +1 -0
  53. package/scripts/gh/git-gh-issue.ts +9 -0
  54. package/scripts/gh/git-pr-ai-review.ts +12 -1
  55. package/scripts/gh/git-pr-confirmation.ts +11 -7
  56. package/scripts/init/init-actions.ts +3 -3
  57. package/scripts/init/init-ai-copy.ts +1 -1
  58. package/scripts/init/init-basic.ts +1 -1
  59. package/scripts/init/init-bootstrap.ts +6 -2
  60. package/scripts/init/init-dry-run-report.ts +150 -0
  61. package/scripts/init/init-dry-run.ts +189 -0
  62. package/scripts/init/init-install.ts +8 -1
  63. package/scripts/init/init.ts +57 -19
  64. package/scripts/init/kit-setup-state.ts +11 -17
  65. package/scripts/init/own-formatter.ts +21 -0
  66. package/scripts/init/project-profile.ts +25 -6
  67. package/scripts/init/start-dry-run.ts +70 -0
  68. package/scripts/init/start-plan.ts +8 -3
  69. package/scripts/init/start-setup-pr.ts +13 -24
  70. package/scripts/init/start-steps.ts +16 -1
  71. package/scripts/init/start.ts +38 -18
  72. package/scripts/init/working-tree.ts +116 -0
  73. package/scripts/issue/issue-auto-ok.ts +68 -99
  74. package/scripts/issue/issue-file-cli.ts +17 -17
  75. package/scripts/issue/issue-file.ts +1 -6
  76. package/scripts/issue/issue-labels.ts +6 -2
  77. package/scripts/issue/issue-release-cli.ts +69 -23
  78. package/scripts/issue/issue-release.ts +3 -1
  79. package/scripts/josh/josh-commands-ai.ts +11 -0
  80. package/scripts/josh/josh-commands-issue.ts +3 -3
  81. package/scripts/josh/josh-commands-project.ts +2 -2
  82. package/scripts/josh/josh-commands-workflow.ts +1 -1
  83. package/scripts/josh/process-identity.ts +32 -6
  84. package/scripts/lane/lane-broker-server.ts +69 -0
  85. package/scripts/lane/lane-session-broker.ts +37 -14
  86. package/scripts/notify/confirmation-options.ts +59 -0
  87. package/scripts/notify/notify-opt-in.ts +33 -0
  88. package/scripts/notify/telegram-notify.ts +25 -8
  89. package/scripts/notify/telegram-test-logic.ts +33 -1
  90. package/scripts/notify/telegram-test.ts +4 -2
  91. package/scripts/observations/observation-ledger-migrate.ts +3 -15
  92. package/scripts/release/release-issue-close.ts +133 -0
  93. package/scripts/release/release-publish.ts +12 -1
  94. package/scripts/repo/repository-labels.ts +11 -1
  95. package/scripts/review/comment-ranges.ts +199 -0
  96. package/scripts/review/review-brief-cli.ts +22 -5
  97. package/scripts/review/review-code-tree.ts +119 -0
  98. package/scripts/review/review-round2-cli.ts +16 -5
  99. package/scripts/review/review-round2.ts +30 -10
  100. package/scripts/review/review-stamps.ts +22 -1
  101. package/scripts/rules/shell-body-trigger.ts +3 -2
  102. package/scripts/rules/stop-rules.ts +2 -1
  103. package/scripts/run/entry/run-entry-stop.ts +6 -3
  104. package/scripts/run/run-stop-notify.ts +2 -0
  105. package/scripts/uninstall/distributed-files.ts +1 -1
@@ -0,0 +1,109 @@
1
+ import { git_gh_command } from '#scripts/gh/git-gh-command'
2
+ import { issue_label_schema } from '#scripts/git/git-schemas'
3
+ import { parse_json_object_or_undefined } from '#scripts/git/parse-json-array'
4
+ import { issue_auto_ok, type AutoOkDecision } from '#scripts/issue/issue-auto-ok'
5
+ import { issue_cite } from '#scripts/issue/issue-cite'
6
+ import { AUTO_OK_LABEL } from '#scripts/issue/issue-labels'
7
+ import { session_cite } from '#scripts/issue/session-cite'
8
+ import { run_event_stream } from '#scripts/run/event/run-event-stream'
9
+ import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
10
+ import { z } from 'zod'
11
+ import { auto_ok_judge } from './auto-ok-judge'
12
+
13
+ // The writing half of `josh auto-ok:judge`: the agent's judgement of one open issue, recorded on it.
14
+ //
15
+ // **An applied judgement still passes the mechanical exclusions** — `issue_auto_ok.exclusion_of` is
16
+ // re-read against the labels as they stand, so a label added since the listing refuses the write
17
+ // rather than racing it. **The run label lands before `auto-ok`**, so the pool never holds an opted-in
18
+ // issue that `backlog:next` would withhold as untriaged. Both answers leave the judgement comment the
19
+ // next listing reads as the record, and an applied one is a `note` on the board's 📌.
20
+
21
+ const SUCCESS_EXIT_CODE = 0
22
+ const FAILURE_EXIT_CODE = 1
23
+ const UNREADABLE = 'the issue could not be read'
24
+
25
+ const issue_schema = z.object({ labels: z.array(issue_label_schema), body: z.string().nullish() })
26
+
27
+ interface IssueRead {
28
+ labels: ReadonlyArray<string>
29
+ body: string
30
+ }
31
+
32
+ async function read_issue(issue: string): Promise<IssueRead | undefined> {
33
+ const raw = await git_gh_command.issue_get_labels_and_body(issue)
34
+ const parsed = raw === undefined ? undefined : parse_json_object_or_undefined(raw, issue_schema)
35
+
36
+ if (parsed === undefined) return undefined
37
+
38
+ return { labels: parsed.labels.map((label) => label.name), body: parsed.body ?? '' }
39
+ }
40
+
41
+ async function apply_labels(issue: string, run_label: string): Promise<string | undefined> {
42
+ for (const label of [run_label, AUTO_OK_LABEL]) {
43
+ // eslint-disable-next-line no-await-in-loop -- the run label must land before `auto-ok`
44
+ const write = await git_gh_command.issue_apply_label(issue, label)
45
+
46
+ if (!write.is_applied) return `could not apply \`${label}\`: ${write.reason}`
47
+ }
48
+
49
+ return undefined
50
+ }
51
+
52
+ async function record(issue: string, decision: AutoOkDecision, body: string): Promise<number> {
53
+ try {
54
+ await git_gh_command.issue_comment(issue, auto_ok_judge.comment_of(decision, body))
55
+ } catch {
56
+ console.error(`could not comment the judgement on ${session_cite.issue(issue)}`)
57
+
58
+ return FAILURE_EXIT_CODE
59
+ }
60
+
61
+ console.info(issue_auto_ok.line_of(decision))
62
+
63
+ return SUCCESS_EXIT_CODE
64
+ }
65
+
66
+ function refused(issue: string, problem: string): number {
67
+ console.error(`${session_cite.issue(issue)}: ${problem}`)
68
+
69
+ return FAILURE_EXIT_CODE
70
+ }
71
+
72
+ async function apply_to(issue: string, run_label: string, read: IssueRead): Promise<number> {
73
+ const exclusion = issue_auto_ok.exclusion_of(read.labels)
74
+
75
+ if (exclusion !== undefined) return refused(issue, issue_auto_ok.line_of(exclusion))
76
+
77
+ const problem = await apply_labels(issue, run_label)
78
+
79
+ if (problem !== undefined) return refused(issue, problem)
80
+
81
+ const reason = `judged by backlogrun: no exclusion holds and no person's judgement is needed (${run_label})`
82
+
83
+ await run_event_stream_emit.emit(
84
+ run_event_stream.EVENT_KIND.NOTE,
85
+ `${issue_cite.plain(issue)} auto-ok applied (${run_label})`,
86
+ )
87
+
88
+ return await record(issue, { is_applied: true, reason }, read.body)
89
+ }
90
+
91
+ async function apply(issue: string, run_label: string): Promise<number> {
92
+ const read = await read_issue(issue)
93
+
94
+ if (read === undefined) return refused(issue, UNREADABLE)
95
+
96
+ return await apply_to(issue, run_label, read)
97
+ }
98
+
99
+ async function decline(issue: string, reason: string): Promise<number> {
100
+ const read = await read_issue(issue)
101
+
102
+ if (read === undefined) return refused(issue, UNREADABLE)
103
+
104
+ return await record(issue, { is_applied: false, reason }, read.body)
105
+ }
106
+
107
+ const auto_ok_judge_write = { apply, decline }
108
+
109
+ export { auto_ok_judge_write }
@@ -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` or a verdict.
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 === 'exhausted' &&
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 === 'exhausted' &&
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: 53_248 },
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 START_COMMANDS = [
31
- EXCLUDE_RELEASE_AGE,
32
- INSTALL_PUBLISHED,
33
- 'pnpm exec josh start --yes --github',
34
- SCOPED_CHECKS,
35
- GATE,
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
- 'pnpm josh profile',
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: ReadonlyArray<string>,
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, ...START_COMMANDS],
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
- start_pattern('svelte', {}, [CREATE_SVELTEKIT, ...FIRST_COMMIT, CREATE_REPOSITORY]),
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 — is skipped for the same
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
 
@@ -89,6 +89,10 @@ async function fetch_review_comments(
89
89
  return parsed?.map((comment) => to_review_comment(comment))
90
90
  }
91
91
 
92
+ // The other choice both AI-review stops offer: a person who has looked records why and moves on.
93
+ const IGNORE_ALTERNATIVE =
94
+ 'Re-run `pnpm josh followup --ai-review-ignore-reason "<reason>"` if the gate does not apply'
95
+
92
96
  async function handle_blockers(input: {
93
97
  branch_name: string
94
98
  ignore_reason: string | undefined
@@ -105,7 +109,12 @@ async function handle_blockers(input: {
105
109
  return
106
110
  }
107
111
 
108
- await git_pr_confirmation.notify_confirmation({ context: input.context, body })
112
+ await git_pr_confirmation.notify_confirmation({
113
+ context: input.context,
114
+ body,
115
+ recommended: 'Fix the findings and re-run `pnpm josh followup`',
116
+ alternatives: [IGNORE_ALTERNATIVE],
117
+ })
109
118
 
110
119
  throw new Error(body)
111
120
  }
@@ -140,6 +149,8 @@ async function handle_unreadable_comments(input: {
140
149
  await git_pr_confirmation.notify_confirmation({
141
150
  context: input.context,
142
151
  body: UNREADABLE_COMMENTS_BODY,
152
+ recommended: 'Re-run `pnpm josh followup` once the comments can be read',
153
+ alternatives: [IGNORE_ALTERNATIVE],
143
154
  })
144
155
 
145
156
  throw new Error(UNREADABLE_COMMENTS_BODY)
@@ -1,3 +1,7 @@
1
+ import {
2
+ confirmation_options,
3
+ type ConfirmationOptions,
4
+ } from '#scripts/notify/confirmation-options'
1
5
  import { telegram_notify, type TelegramSendInput } from '#scripts/notify/telegram-notify'
2
6
 
3
7
  // What to type when the confirmation notification itself could not be delivered.
@@ -5,7 +9,7 @@ import { telegram_notify, type TelegramSendInput } from '#scripts/notify/telegra
5
9
  // commands and a backtick inside shell double quotes is evaluated before the command runs.
6
10
  const CONFIRMATION_RECOVERY =
7
11
  'send it by hand — `pnpm josh notify --task-type confirmation --issue-url <url> ' +
8
- '--body-file <path>`'
12
+ '--body-file <path> --recommended "<choice>"`'
9
13
 
10
14
  interface TelegramContext {
11
15
  repo_name: string | undefined
@@ -14,15 +18,18 @@ interface TelegramContext {
14
18
  pr_url: string | undefined
15
19
  }
16
20
 
17
- function build_confirmation_input(input: {
21
+ interface ConfirmationInput extends ConfirmationOptions {
18
22
  context: TelegramContext
19
23
  body: string
20
- }): TelegramSendInput {
24
+ }
25
+
26
+ function build_confirmation_input(input: ConfirmationInput): TelegramSendInput {
21
27
  return {
22
28
  task_type: 'confirmation',
23
29
  repo_name: input.context.repo_name,
24
30
  issue_title: input.context.issue_title,
25
31
  body: input.body,
32
+ choices: confirmation_options.block(input),
26
33
  issue_url: input.context.issue_url,
27
34
  pr_url: input.context.pr_url,
28
35
  }
@@ -34,10 +41,7 @@ function build_confirmation_input(input: {
34
41
  //
35
42
  // Unlike the completion notification, this one *has* a recovery: a `confirmation` is exactly what
36
43
  // `josh notify` is for, and the run stops here anyway, so re-sending it by hand loses nothing.
37
- async function notify_confirmation(input: {
38
- context: TelegramContext
39
- body: string
40
- }): Promise<void> {
44
+ async function notify_confirmation(input: ConfirmationInput): Promise<void> {
41
45
  await telegram_notify.send_or_report(build_confirmation_input(input), CONFIRMATION_RECOVERY)
42
46
  }
43
47
 
@@ -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.has_web
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.has_web) actions.push(build_basic_settings_action())
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.has_web) {
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.has_web
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.has_web
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 (process.env[HANDOFF_ENV] !== undefined) return REPEATED_HANDOFF
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 }