@joshuafolkken/kit 1.953.0 → 1.972.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 (100) hide show
  1. package/.claude/skills/workflow-commands/background-commands.md +3 -0
  2. package/.claude/skills/workflow-commands/backlogrun-child.md +75 -120
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +78 -115
  4. package/.claude/skills/workflow-commands/backlogrun-progress.md +82 -146
  5. package/.claude/skills/workflow-commands/backlogrun-steps.md +173 -229
  6. package/.claude/skills/workflow-commands/backlogrun.md +3 -1
  7. package/.claude/skills/workflow-commands/followup.md +3 -0
  8. package/.claude/skills/workflow-commands/fullrun.md +3 -4
  9. package/.claude/skills/workflow-commands/observation-filing.md +31 -25
  10. package/.claude/skills/workflow-commands/pre-gate-cut.md +96 -161
  11. package/.github/workflows/pr-classification.yml +12 -1
  12. package/CLAUDE.md +3 -3
  13. package/README.md +5 -3
  14. package/dist/CLAUDE.md +3 -3
  15. package/dist/hooks/batch-guard.js +4 -4
  16. package/dist/hooks/{chunk-OM5VDM6F.js → chunk-2K52BRWV.js} +2 -2
  17. package/dist/hooks/{chunk-Z5TVZ4AP.js → chunk-BNWIOJFU.js} +29 -16
  18. package/dist/hooks/{chunk-NKVLBB65.js → chunk-BVMSPG3M.js} +3 -3
  19. package/dist/hooks/{chunk-IA7WC4NS.js → chunk-CRGJTD6E.js} +1 -1
  20. package/dist/hooks/{chunk-M4U4YNRE.js → chunk-F6OM6SIG.js} +3 -3
  21. package/dist/hooks/{chunk-O3PEDF3M.js → chunk-KQG4776P.js} +1 -1
  22. package/dist/hooks/{chunk-V7RH56HU.js → chunk-NXL3SBXY.js} +2 -2
  23. package/dist/hooks/{chunk-452OKRQX.js → chunk-WWTMHHZU.js} +120 -18
  24. package/dist/hooks/{chunk-EYFJ74MJ.js → chunk-ZPLXLJ7O.js} +21 -7
  25. package/dist/hooks/{chunk-IBFC25IX.js → chunk-ZYYSBNR6.js} +4 -4
  26. package/dist/hooks/codex-hook-adapter.js +10 -10
  27. package/dist/hooks/duplicate-read-guard.js +5 -5
  28. package/dist/hooks/format-edited.js +2 -2
  29. package/dist/hooks/investigation-guard.js +5 -5
  30. package/dist/hooks/pretool-guard.js +9 -9
  31. package/dist/hooks/stop-guard.js +206 -176
  32. package/dist/josh.js +14 -6
  33. package/package.json +3 -3
  34. package/prompts/collaboration-workflow/wip-cap.md +1 -1
  35. package/scripts/backlog/backlog-arrival.ts +11 -1
  36. package/scripts/backlog/backlog-budget.ts +25 -8
  37. package/scripts/backlog/backlog-drive-cli.ts +5 -1
  38. package/scripts/backlog/backlog-drive-retrospective.ts +20 -0
  39. package/scripts/backlog/backlog-drive.ts +7 -3
  40. package/scripts/backlog/backlog-next.ts +45 -2
  41. package/scripts/backlog/backlog-offer.ts +1 -0
  42. package/scripts/backlog/backlog-plan-cli.ts +66 -27
  43. package/scripts/backlog/backlog-plan.ts +30 -2
  44. package/scripts/backlog/backlog-stalled.ts +9 -0
  45. package/scripts/backlog/backlog-waves.ts +169 -0
  46. package/scripts/document/document-byte-budget.ts +1 -1
  47. package/scripts/document/entry-read-budget.ts +7 -5
  48. package/scripts/epic/epic-lane-offer.ts +69 -4
  49. package/scripts/epic/epic-next.ts +2 -2
  50. package/scripts/epic/epic-solo.ts +147 -0
  51. package/scripts/epic/epic-triage.ts +47 -0
  52. package/scripts/gate/hook-gate-reuse.ts +7 -8
  53. package/scripts/git/git-closes-keyword.ts +15 -0
  54. package/scripts/git/git-followup-flush.ts +5 -6
  55. package/scripts/git/git-followup-issue-close.ts +75 -0
  56. package/scripts/git/git-pr-followup-wrapup.ts +36 -6
  57. package/scripts/git/git-pr-followup.ts +9 -16
  58. package/scripts/git/git-staging.ts +33 -20
  59. package/scripts/git/issue-labels.ts +42 -0
  60. package/scripts/hooks/stop-guard.ts +1 -0
  61. package/scripts/init/init-install.ts +64 -0
  62. package/scripts/init/init.ts +38 -11
  63. package/scripts/init/project-profile.ts +9 -5
  64. package/scripts/init/start-plan.ts +145 -0
  65. package/scripts/init/start-prompt.ts +55 -0
  66. package/scripts/init/start-steps.ts +167 -0
  67. package/scripts/init/start.ts +92 -16
  68. package/scripts/issue/issue-closing-pr.ts +93 -0
  69. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +4 -2
  70. package/scripts/josh/josh-commands-project.ts +11 -3
  71. package/scripts/josh/josh-commands-workflow.ts +3 -3
  72. package/scripts/observations/observation-ledger-prepare.ts +31 -0
  73. package/scripts/observations/observation-ledger.ts +5 -5
  74. package/scripts/observations/observations-flush.ts +11 -30
  75. package/scripts/rules/implementation-cut.ts +49 -16
  76. package/scripts/rules/lane-background.ts +25 -5
  77. package/scripts/rules/stop-rules.ts +16 -4
  78. package/scripts/run/run-carry-cli.ts +21 -0
  79. package/scripts/run/run-cut-cli.ts +92 -110
  80. package/scripts/run/run-cut-report.ts +167 -0
  81. package/scripts/run/run-cut.ts +23 -7
  82. package/scripts/run/run-entry-cli.ts +36 -2
  83. package/scripts/run/run-headless.ts +1 -0
  84. package/scripts/run/run-hold-cli.ts +36 -15
  85. package/scripts/run/run-hold.ts +15 -4
  86. package/scripts/run/run-merge-cli.ts +28 -14
  87. package/scripts/run/run-merge-steps.ts +38 -2
  88. package/scripts/run/run-merge.ts +41 -3
  89. package/scripts/run/run-prep-cli.ts +8 -2
  90. package/scripts/run/run-prep-locate.ts +165 -0
  91. package/scripts/run/run-prep.ts +12 -1
  92. package/scripts/run/run-retrospective.ts +34 -0
  93. package/scripts/run/run-step-cli.ts +19 -5
  94. package/scripts/run/run-step.ts +31 -19
  95. package/scripts/run/run-tail-cli.ts +4 -2
  96. package/scripts/run/run-wake-work.ts +4 -0
  97. package/scripts/safe-chain/ci-installer-pin.ts +8 -4
  98. package/scripts/time-runtime/time-background.ts +11 -0
  99. package/scripts/time-runtime/time-transcript-line.ts +4 -0
  100. package/templates/pnpm-workspace.static.yaml +2 -0
@@ -1,6 +1,7 @@
1
1
  import { git_epic_close } from './git-epic-close'
2
2
  import { git_followup_cleanup } from './git-followup-cleanup'
3
3
  import { git_followup_flush } from './git-followup-flush'
4
+ import { git_followup_issue_close } from './git-followup-issue-close'
4
5
  import { git_followup_label } from './git-followup-label'
5
6
  import { git_followup_stages, type StageLog } from './git-followup-stages'
6
7
  import { git_gh_command } from './git-gh-command'
@@ -25,6 +26,9 @@ const IN_PROGRESS_RECOVERY = 'gh api -X DELETE repos/{owner}/{repo}/issues/<N>/l
25
26
  interface WrapupInput {
26
27
  branch_name: string
27
28
  issue_number: string | undefined
29
+ // The number the pull request body's `closes #N` names — the only issue the merge claimed to
30
+ // close, so the only one the merged-issue close may write to (joshuafolkken/kit#2770).
31
+ closes_number: string | undefined
28
32
  notify_config: GitNotifyConfig | undefined
29
33
  pr_url: string | undefined
30
34
  should_merge: boolean
@@ -164,9 +168,30 @@ async function in_progress_step(input: WrapupInput): Promise<void> {
164
168
  })
165
169
  }
166
170
 
171
+ // **The merged issue's close, confirmed rather than assumed** (joshuafolkken/kit#2770): GitHub stopped
172
+ // applying `closes #N` on some merges, and nothing noticed. The epic auto-close does not wait on it —
173
+ // it already excludes the merged issue from its reads — so it runs beside the other steps. It acts on
174
+ // the body's `closes #N` rather than the run's issue number: a pull request that closes nothing, or
175
+ // another issue, never closes the one the invocation named.
176
+ async function issue_close_step(input: WrapupInput): Promise<void> {
177
+ if (!input.should_merge) return
178
+
179
+ await git_followup_cleanup.run_guarded_step(input.should_merge, {
180
+ label: 'The merged issue close',
181
+ recovery: git_followup_issue_close.CLOSE_RECOVERY,
182
+ run: async () => {
183
+ await git_followup_issue_close.ensure_issue_closed({
184
+ issue_number: input.closes_number,
185
+ pr_url: input.pr_url,
186
+ })
187
+ },
188
+ })
189
+ }
190
+
167
191
  // **The steps after the merge are issued together** (joshuafolkken/kit#1446). The completion
168
- // comment writes to the issue or the pull request, the auto-close reads the open epics, and the
169
- // label removal reads and writes the issue's labels; none needs another's answer, and measured
192
+ // comment writes to the issue or the pull request, the auto-close reads the open epics, the label
193
+ // removal reads and writes the issue's labels, and the merged-issue close reads and may close the
194
+ // issue; none needs another's answer, and measured
170
195
  // serially the first two were 1.9 s and 3.1 s of `followup`'s own clock. Past the merge none can
171
196
  // reject another — `run_guarded_step` reports the failure and answers instead — so they settle
172
197
  // independently and the stage costs the longest of them rather than their sum.
@@ -178,9 +203,9 @@ async function in_progress_step(input: WrapupInput): Promise<void> {
178
203
  // (`git-followup-cleanup.ts`), which is easier to read as one sequence than as a settled set.
179
204
  //
180
205
  // **What the overlap costs, stated rather than glossed**: mutating requests now go out at once
181
- // against one repository — a comment write, an auto-close that comments on and closes epics, and a
182
- // label delete — which is what GitHub's secondary rate limit guidance is about. Past the merge all
183
- // of them are guarded, so the worst case is a cleanup reported as unfinished rather than a failed
206
+ // against one repository — a comment write, an auto-close that comments on and closes epics, a label
207
+ // delete, and the merged issue's close when GitHub did not apply it — which is what GitHub's
208
+ // secondary rate limit guidance is about. Past the merge all of them are guarded, so the worst case is a cleanup reported as unfinished rather than a failed
184
209
  // run, and this is a handful of requests rather than a fan-out. **Their console output can
185
210
  // interleave too**: the guard's warning and its recovery line are printed as they happen, so an
186
211
  // auto-close progress line can land between them. The recovery line names its own command and stands
@@ -194,7 +219,12 @@ async function run_tail_steps(input: WrapupInput): Promise<void> {
194
219
  return
195
220
  }
196
221
 
197
- await Promise.all([notify_step(input), epic_close_step(input), in_progress_step(input)])
222
+ await Promise.all([
223
+ notify_step(input),
224
+ epic_close_step(input),
225
+ in_progress_step(input),
226
+ issue_close_step(input),
227
+ ])
198
228
  }
199
229
 
200
230
  async function run_wrapup(input: WrapupInput, log: StageLog): Promise<void> {
@@ -1,3 +1,4 @@
1
+ import { git_closes_keyword } from './git-closes-keyword'
1
2
  import { git_followup_pending } from './git-followup-pending'
2
3
  import { git_followup_stages, type StageLog } from './git-followup-stages'
3
4
  import { git_gh_command } from './git-gh-command'
@@ -15,10 +16,6 @@ import { telegram_notify, type TelegramSendInput, type TelegramTaskType } from '
15
16
 
16
17
  const { STAGE, lap } = git_followup_stages
17
18
 
18
- // Captures the number, so the one pattern answers both questions the run asks of a PR body: whether
19
- // the linked issue will auto-close, and — since joshuafolkken/kit#1539 — which issue that is when the
20
- // invocation did not say. A second pattern here would be two readings of "closes #N" to disagree.
21
- const CLOSES_PATTERN = /closes\s+#(\d+)/iu
22
19
  // Named so a test can pin the note without restating it (joshuafolkken/kit#999).
23
20
  const WATCH_FAILED_NOTE = 'pr checks --watch failed; falling through to polling'
24
21
  const REPO_NAME_SEPARATOR = '/'
@@ -69,11 +66,7 @@ interface FollowupInput {
69
66
  should_merge: boolean
70
67
  }
71
68
 
72
- function parse_closes_issue_number(body: string | undefined): string | undefined {
73
- if (body === undefined) return undefined
74
-
75
- return CLOSES_PATTERN.exec(body)?.[1]
76
- }
69
+ const { parse_closes_issue_number } = git_closes_keyword
77
70
 
78
71
  function has_closes_keyword(body: string | undefined): boolean {
79
72
  return parse_closes_issue_number(body) !== undefined
@@ -234,6 +227,7 @@ async function read_issue_title(
234
227
 
235
228
  interface RunContext {
236
229
  issue_number: string | undefined
230
+ closes_number: string | undefined
237
231
  context: TelegramContext
238
232
  }
239
233
 
@@ -256,19 +250,17 @@ async function read_run_context(input: {
256
250
  issue_number: string | undefined
257
251
  }): Promise<RunContext> {
258
252
  const closes = warn_if_missing_closes(input.branch_name)
259
- const owner = git_gh_command.repo_get_name_with_owner()
260
- const url = git_gh_command.pr_get_url(input.branch_name)
261
- const title = read_issue_title(input.issue_number, closes)
262
253
  const [closes_number, name_with_owner, pr_url, issue_title] = await Promise.all([
263
254
  closes,
264
- owner,
265
- url,
266
- title,
255
+ git_gh_command.repo_get_name_with_owner(),
256
+ git_gh_command.pr_get_url(input.branch_name),
257
+ read_issue_title(input.issue_number, closes),
267
258
  ])
268
259
  const issue_number = input.issue_number ?? closes_number
269
260
 
270
261
  return {
271
262
  issue_number,
263
+ closes_number,
272
264
  context: {
273
265
  repo_name: parse_repo_name(name_with_owner),
274
266
  issue_title,
@@ -373,7 +365,7 @@ async function run_review_checks(
373
365
  // the epic close all name the same issue — and so the caller's own tail can record a run whose number
374
366
  // only the pull request knew.
375
367
  async function run_stages(input: FollowupInput, log: StageLog): Promise<string | undefined> {
376
- const { issue_number, context } = await read_run_context(input)
368
+ const { issue_number, closes_number, context } = await read_run_context(input)
377
369
 
378
370
  lap(log, STAGE.closes_and_context)
379
371
  const checks = await run_review_checks(input, context, log)
@@ -385,6 +377,7 @@ async function run_stages(input: FollowupInput, log: StageLog): Promise<string |
385
377
  {
386
378
  branch_name: input.branch_name,
387
379
  issue_number,
380
+ closes_number,
388
381
  notify_config: input.notify_config,
389
382
  pr_url: context.pr_url,
390
383
  should_merge: input.should_merge,
@@ -2,6 +2,7 @@ import {
2
2
  observation_ledger,
3
3
  OBSERVATION_LEDGER_PATHS,
4
4
  } from '#scripts/observations/observation-ledger'
5
+ import { observation_ledger_prepare } from '#scripts/observations/observation-ledger-prepare'
5
6
  import { git_command } from './git-command'
6
7
  import { git_prompt } from './git-prompt'
7
8
  import { git_status } from './git-status'
@@ -67,18 +68,26 @@ async function stage_untracked_files(files: ReadonlyArray<string>): Promise<void
67
68
  for (const file of files) console.info(` + ${file}`)
68
69
  }
69
70
 
70
- // **The observation ledger is never staged by an ordinary commit** (joshuafolkken/kit#1756). A
71
- // parent session appends to it in the primary checkout and nothing there commits it, so the next
72
- // `pnpm josh git` run in that checkout swept the line into a pull request about something else
73
- // entirely — a diff a reviewer has no reason to question. Excluding it here covers every entry
74
- // point, because this is the one staging step all of them go through;
75
- // `pnpm josh observations:flush` is what commits it, on a branch of its own.
76
- // Both of its paths are excluded (joshuafolkken/kit#2724): a run on the old code still appends at
77
- // the old one until `observation-ledger-migrate.ts` moves it.
78
- const PATHS_EXCLUDED_FROM_STAGING: ReadonlyArray<string> = OBSERVATION_LEDGER_PATHS
79
-
80
- function is_stageable(file_path: string): boolean {
81
- return !observation_ledger.is_ledger_path(file_path)
71
+ // **The observation ledger rides the run's own commit** (joshuafolkken/kit#2763). It used to be
72
+ // excluded here (joshuafolkken/kit#1756) and committed afterwards by `pnpm josh observations:flush` as
73
+ // a pull request of its own — a second branch, CI wait and merge behind every run that appended a
74
+ // line, 144 of them in one month. Staged with the run instead, the lines are reviewed and merged with
75
+ // the pull request whose run recorded them, while CI for that pull request is the only wait.
76
+ //
77
+ // **A line that breaks the grammar keeps the old exclusion** (joshuafolkken/kit#2123): the commit
78
+ // goes ahead without the ledger, and `pnpm josh observations:flush` — which refuses the same line and
79
+ // names it — is where it is repaired. The migration is run first, as the flush runs it, so a line
80
+ // still on the old path (joshuafolkken/kit#2724) is committed at the new one. A lane has no ledger
81
+ // line of its own to carry: its writers append to the primary checkout's.
82
+ async function ledger_exclusions(): Promise<ReadonlyArray<string>> {
83
+ const broken = await observation_ledger_prepare.prepare(await git_command.repository_root())
84
+
85
+ return broken.length > 0 ? OBSERVATION_LEDGER_PATHS : []
86
+ }
87
+
88
+ // A migration claim is a copy mid-move, never the ledger itself, so it is never staged.
89
+ function is_stageable(file_path: string, excluded: ReadonlyArray<string>): boolean {
90
+ return !observation_ledger.is_migration_claim(file_path) && !excluded.includes(file_path)
82
91
  }
83
92
 
84
93
  // **Said out loud, because the alternative is an unexplained failure.** With the ledger the only
@@ -89,27 +98,31 @@ function is_stageable(file_path: string): boolean {
89
98
  // **The paths are parsed rather than matched as substrings**: `docs/maintainers/observations.md.bak` contains the
90
99
  // ledger's path, and a hint naming a file the exclusion never touched is a hint that teaches the
91
100
  // reader to ignore it.
92
- function report_excluded_paths(status_output: string): void {
93
- const excluded = status_output
101
+ function report_excluded_paths(status_output: string, excluded: ReadonlyArray<string>): void {
102
+ const left_out = status_output
94
103
  .split('\n')
95
104
  .filter((line) => line.trim().length > 0)
96
105
  .map((line) => observation_ledger.status_path(line))
97
- .filter((file_path) => !is_stageable(file_path))
106
+ .filter((file_path) => excluded.includes(file_path))
98
107
 
99
- for (const file_path of excluded) {
100
- console.info(`💡 ${file_path} is not staged; commit it with \`pnpm josh observations:flush\`.`)
108
+ for (const file_path of left_out) {
109
+ console.info(
110
+ `💡 ${file_path} is not staged: a ledger line breaks the grammar; \`pnpm josh observations:flush\` names it.`,
111
+ )
101
112
  }
102
113
  }
103
114
 
115
+ // The status is read after the migration, so an old-path ledger it moved is not staged by its old name.
104
116
  async function stage_tracked_files(): Promise<void> {
117
+ const excluded = await ledger_exclusions()
105
118
  const status_output = await git_command.status()
106
119
  const untracked = git_status
107
120
  .list_untracked_files(status_output)
108
- .filter((file_path) => is_stageable(file_path))
121
+ .filter((file_path) => is_stageable(file_path, excluded))
109
122
 
110
- await git_command.add_tracked(PATHS_EXCLUDED_FROM_STAGING)
123
+ await git_command.add_tracked(excluded)
111
124
  console.info('💡 Auto-staged tracked modified files (git add -u).')
112
- report_excluded_paths(status_output)
125
+ report_excluded_paths(status_output, excluded)
113
126
  await stage_untracked_files(untracked)
114
127
  }
115
128
 
@@ -10,6 +10,15 @@ const IN_PROGRESS_LABEL = 'in-progress'
10
10
  // it: a parked child is why a run reports "nothing left that time will fix" rather than waiting
11
11
  // forever (joshuafolkken/kit#860).
12
12
  const NEEDS_DECISION_LABEL = 'needs-decision'
13
+ // Marks an issue whose defect reaches the verification path, so it runs with nothing beside it
14
+ // (joshuafolkken/kit#2776). A run applies it from the verification-path enumeration in `wip-cap.md`;
15
+ // `backlog:next` enforces it (`backlog-solo.ts`), so the rule no longer rests on a judgement at
16
+ // dispatch time.
17
+ const RUN_SOLO_LABEL = 'run:solo'
18
+ // The other half of the same judgement: an issue a run read and found safe to run beside others
19
+ // (joshuafolkken/kit#2779). Without it an issue lacking `run:solo` could mean "judged parallel" or
20
+ // "never judged", and `backlog:next` withholds the second (`epic-triage.ts`).
21
+ const RUN_LANE_LABEL = 'run:lane'
13
22
  // Opts one issue outside any epic into unattended execution (joshuafolkken/kit#906). **Only a
14
23
  // person applies it.** `epicrun #<E>` approves the merges inside `#<E>`; this label is the only way
15
24
  // a person extends that approval past the epic's edge, so a label an AI could apply to itself would
@@ -177,6 +186,34 @@ const DEPTH_LABELS: ReadonlyArray<{
177
186
  },
178
187
  ]
179
188
 
189
+ // What `josh start` provisions on a new repository so the first `kickoff` finds every label a run
190
+ // applies with its intended color (joshuafolkken/kit#2197). The `epic` and `in-progress` metadata is
191
+ // the one the workflow documents create them with; the labels only a person applies are left out.
192
+ const WORKFLOW_LABELS: ReadonlyArray<{
193
+ name: string
194
+ color: string
195
+ description: string
196
+ }> = [
197
+ {
198
+ name: EPIC_LABEL,
199
+ color: '5319e7',
200
+ description: 'Tracks a batch of child issues from one split',
201
+ },
202
+ { name: IN_PROGRESS_LABEL, color: '0075ca', description: 'Work is actively in progress' },
203
+ {
204
+ name: RUN_SOLO_LABEL,
205
+ color: 'b60205',
206
+ description: 'Runs alone in a backlogrun: nothing starts beside it',
207
+ },
208
+ {
209
+ name: RUN_LANE_LABEL,
210
+ color: '0e8a16',
211
+ description: 'Judged safe to run beside others in a backlogrun',
212
+ },
213
+ ...FILING_ROUTE_LABELS,
214
+ ...DEPTH_LABELS,
215
+ ]
216
+
180
217
  // The shape `gh issue list --json labels` returns; narrowed here so the predicate below takes any
181
218
  // listing row without importing a schema.
182
219
  interface LabelReference {
@@ -251,6 +288,8 @@ const ALL_LABELS: ReadonlySet<string> = new Set([
251
288
  INTERRUPT_ROUTE_LABEL,
252
289
  REVIEW_CAP_ROUTE_LABEL,
253
290
  REVIEW_ROUND2_SKIPPED_LABEL,
291
+ RUN_LANE_LABEL,
292
+ RUN_SOLO_LABEL,
254
293
  SPLIT_ROUTE_LABEL,
255
294
  TIER_A_ROUTE_LABEL,
256
295
  DEPTH_0_LABEL,
@@ -283,6 +322,9 @@ export {
283
322
  NOT_DIRECTLY_RUNNABLE_LABELS,
284
323
  REVIEW_CAP_ROUTE_LABEL,
285
324
  REVIEW_ROUND2_SKIPPED_LABEL,
325
+ RUN_LANE_LABEL,
326
+ RUN_SOLO_LABEL,
286
327
  SPLIT_ROUTE_LABEL,
287
328
  TIER_A_ROUTE_LABEL,
329
+ WORKFLOW_LABELS,
288
330
  }
@@ -78,6 +78,7 @@ async function build_context(
78
78
  headless_refusals: stop_rules.count_headless_refusals(tail),
79
79
  lane_child: lane_child_marker.is_child_of(process.cwd()),
80
80
  background_pending: lane_background.pending_background_ids(tail).length > 0,
81
+ agent_pending: lane_background.pending_agent_ids(tail).length > 0,
81
82
  session_lang: session_lang(),
82
83
  }
83
84
  }
@@ -0,0 +1,64 @@
1
+ import { execaSync } from 'execa'
2
+
3
+ interface InstallStep {
4
+ label: string
5
+ command: string
6
+ args: ReadonlyArray<string>
7
+ }
8
+
9
+ interface InstallRequest {
10
+ is_install: boolean
11
+ rest: ReadonlyArray<string>
12
+ }
13
+
14
+ const NO_INSTALL_FLAG = '--no-install'
15
+ const INSTALL_STEP: InstallStep = { label: 'pnpm install', command: 'pnpm', args: ['install'] }
16
+ const FORMAT_STEP: InstallStep = {
17
+ label: 'pnpm josh format',
18
+ command: 'pnpm',
19
+ args: ['exec', 'josh', 'format'],
20
+ }
21
+ const INSTALL_FAILURE = [
22
+ `${INSTALL_STEP.label} failed — fix the cause above,`,
23
+ `then run it manually: ${INSTALL_STEP.label} && ${FORMAT_STEP.label}`,
24
+ ].join(' ')
25
+ const FORMAT_WARNING = [
26
+ ` ⚠ ${FORMAT_STEP.label} left problems it could not fix in the project's own files —`,
27
+ `the setup itself succeeded; fix them, then run: ${FORMAT_STEP.label}`,
28
+ ].join(' ')
29
+
30
+ // The flag is peeled off before the `--profile` pair is parsed, so the profile parser stays the one
31
+ // `josh start` shares.
32
+ function split_install_flag(args: ReadonlyArray<string>): InstallRequest {
33
+ return {
34
+ is_install: !args.includes(NO_INSTALL_FLAG),
35
+ rest: args.filter((argument) => argument !== NO_INSTALL_FLAG),
36
+ }
37
+ }
38
+
39
+ function did_step_succeed(step: InstallStep, project_root: string): boolean {
40
+ console.info(`\n$ ${step.label}`)
41
+ const result = execaSync(step.command, step.args, {
42
+ cwd: project_root,
43
+ stdio: 'inherit',
44
+ reject: false,
45
+ })
46
+
47
+ return result.exitCode === 0
48
+ }
49
+
50
+ // Returns the failure to report, or undefined once the install passed. A failed install stops
51
+ // there: `josh format` runs prettier and the kit itself, both of which the install provides. A failed
52
+ // format does not fail `init` — in a node project it also runs `eslint --fix`, which exits non-zero on
53
+ // the project's own unfixable errors, and those are the project's result, not the setup's (the
54
+ // reason `josh gate` is left out of `init` too).
55
+ function run_post_init_steps(project_root: string): string | undefined {
56
+ console.info('\nDependencies:')
57
+ if (!did_step_succeed(INSTALL_STEP, project_root)) return INSTALL_FAILURE
58
+ if (!did_step_succeed(FORMAT_STEP, project_root)) console.warn(FORMAT_WARNING)
59
+
60
+ return undefined
61
+ }
62
+
63
+ const init_install = { NO_INSTALL_FLAG, split_install_flag, run_post_init_steps }
64
+ export { init_install }
@@ -15,6 +15,7 @@ import { execaSync } from 'execa'
15
15
  import { z } from 'zod'
16
16
  import { init_actions, PRETTIER_CONFIG_JS, type FileAction } from './init-actions'
17
17
  import { init_ai_copy } from './init-ai-copy'
18
+ import { init_install } from './init-install'
18
19
  import { init_logic } from './init-logic'
19
20
  import { PACKAGE_DIR, PROJECT_ROOT } from './init-paths'
20
21
  import { init_static } from './init-static'
@@ -28,6 +29,7 @@ const SAMPLE_INDENT_WIDTH = 4
28
29
  const ARGUMENT_START_INDEX = 2
29
30
  const SAMPLE_INDENT = ' '.repeat(SAMPLE_INDENT_WIDTH)
30
31
  const INSTALL_HINT = '→ run `pnpm install` to install what package.json lists'
32
+ const INIT_USAGE = 'josh init [--profile static|node] [--no-install]'
31
33
 
32
34
  function write_new_file(action: FileAction, destination_path: string): void {
33
35
  mkdirSync(path.dirname(destination_path), { recursive: true })
@@ -218,14 +220,10 @@ function merge_project_package_json(shape: ProjectShape): void {
218
220
 
219
221
  writeFileSync(package_json_path, merged)
220
222
  console.info(` ✔ ${is_existing ? 'updated' : 'created'} package.json`)
221
- // `init` writes the manifest but installs nothing, so a tool it just listed is absent until the
222
- // user installs it — and `josh lint` then fails on it rather than skipping it
223
- // (joshuafolkken/kit#2709). Saying so here is what tells the user the next step.
224
- console.info(` ${INSTALL_HINT}`)
225
223
  }
226
224
 
227
- // A fresh project runs `init` before its first `pnpm install`, so lefthook — added to devDependencies
228
- // above — is not there yet; the `prepare` script installs the hooks once it is.
225
+ // Under `--no-install` a fresh project has no lefthook yet — added to devDependencies above — so the
226
+ // `prepare` script of the user's own `pnpm install` installs the hooks once it is.
229
227
  function install_lefthook(project_root: string = PROJECT_ROOT): void {
230
228
  console.info('\nLefthook:')
231
229
  const bin = resolve_local_bin(project_root, LEFTHOOK_BIN)
@@ -285,8 +283,6 @@ function run_ai_file_actions(shape: ProjectShape): void {
285
283
  // resolved, so `gh repo view` runs once; the position is the pre-existing one.
286
284
  const name_with_owner = init_ai_copy.run_ai_copies(shape)
287
285
 
288
- if (shape.has_git && shape.profile === 'node') install_lefthook()
289
-
290
286
  if (shape.has_github) report_repository_settings(name_with_owner)
291
287
 
292
288
  if (shape.profile === 'node') plugin_install_hint_module.report_plugin_install_hint()
@@ -302,19 +298,50 @@ function initialize_project(shape: ProjectShape): void {
302
298
 
303
299
  run_ai_file_actions(shape)
304
300
  if (shape.profile === 'node') project_config.sync_project_config(PROJECT_ROOT)
305
- console.info('\n✅ Done.\n')
301
+ }
302
+
303
+ // A tool `init` just listed is absent until the user installs it — and `josh lint` then fails on it
304
+ // rather than skipping it (joshuafolkken/kit#2709). Saying so is what tells the user the next step.
305
+ function report_manual_install(shape: ProjectShape): void {
306
+ console.info(`\nDependencies:\n ${INSTALL_HINT}`)
307
+ if (shape.has_git && shape.profile === 'node') install_lefthook()
308
+ }
309
+
310
+ // The install runs the `prepare` script, which installs the git hooks. A failure is thrown rather
311
+ // than printed, so `josh start` — which calls this entry point — stops before its first commit.
312
+ function finish_dependencies(shape: ProjectShape, is_install: boolean): void {
313
+ if (!is_install) {
314
+ report_manual_install(shape)
315
+
316
+ return
317
+ }
318
+
319
+ const failure = init_install.run_post_init_steps(PROJECT_ROOT)
320
+ if (failure !== undefined) throw new Error(failure)
306
321
  }
307
322
 
308
323
  function main(args: ReadonlyArray<string> = []): void {
309
324
  if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return
310
- const requested = project_profile.requested_profile(args)
325
+ const { is_install, rest } = init_install.split_install_flag(args)
326
+ const requested = project_profile.requested_profile(rest, INIT_USAGE)
311
327
  const shape = project_profile.inspect_project(PROJECT_ROOT, requested)
312
328
 
313
329
  initialize_project(shape)
330
+ finish_dependencies(shape, is_install)
331
+ console.info('\n✅ Done.\n')
332
+ }
333
+
334
+ function run_cli(args: ReadonlyArray<string>): void {
335
+ try {
336
+ main(args)
337
+ } catch (error) {
338
+ console.error(`\n✖ ${error instanceof Error ? error.message : String(error)}\n`)
339
+ process.exitCode = 1
340
+ }
314
341
  }
315
342
 
316
343
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
317
- main(process.argv.slice(ARGUMENT_START_INDEX))
344
+ run_cli(process.argv.slice(ARGUMENT_START_INDEX))
318
345
  }
319
346
 
320
347
  const init = {
@@ -29,12 +29,16 @@ function parse_profile(value: unknown): ProjectProfile | undefined {
29
29
  return undefined
30
30
  }
31
31
 
32
- function requested_profile(args: ReadonlyArray<string>): ProjectProfile | undefined {
33
- if (args.length === 0) return undefined
32
+ function is_profile_pair(args: ReadonlyArray<string>): boolean {
33
+ return args.length === PROFILE_ARG_LENGTH && args[0] === '--profile'
34
+ }
34
35
 
35
- if (args.length !== PROFILE_ARG_LENGTH || args[0] !== '--profile') {
36
- throw new Error('Usage: josh init [--profile static|node]')
37
- }
36
+ function requested_profile(
37
+ args: ReadonlyArray<string>,
38
+ usage = 'josh init [--profile static|node]',
39
+ ): ProjectProfile | undefined {
40
+ if (args.length === 0) return undefined
41
+ if (!is_profile_pair(args)) throw new Error(`Usage: ${usage}`)
38
42
 
39
43
  const profile = parse_profile(args[1])
40
44
  if (profile !== undefined) return profile
@@ -0,0 +1,145 @@
1
+ import type { ProjectProfile } from './project-profile'
2
+
3
+ type Visibility = 'private' | 'public'
4
+
5
+ interface StartOptions {
6
+ profile: ProjectProfile | undefined
7
+ is_yes: boolean
8
+ is_github: boolean
9
+ visibility: Visibility
10
+ }
11
+
12
+ type StartStep = 'git_init' | 'initialize' | 'commit' | 'repository' | 'labels'
13
+
14
+ interface GitState {
15
+ has_git: boolean
16
+ has_github: boolean
17
+ has_origin: boolean
18
+ branch: string | undefined
19
+ has_commits: boolean
20
+ }
21
+
22
+ interface StepPlan {
23
+ steps: ReadonlyArray<StartStep>
24
+ refusal: string | undefined
25
+ }
26
+
27
+ type Interaction = 'interactive' | 'unattended'
28
+ type Consent = 'granted' | 'ask'
29
+
30
+ interface Verdict<T> {
31
+ value: T | undefined
32
+ refusal: string | undefined
33
+ }
34
+
35
+ const DEFAULT_BRANCH = 'main'
36
+ const FULL_STEPS: ReadonlyArray<StartStep> = [
37
+ 'git_init',
38
+ 'initialize',
39
+ 'commit',
40
+ 'repository',
41
+ 'labels',
42
+ ]
43
+
44
+ const STEP_LABELS: Readonly<Record<StartStep, string>> = {
45
+ git_init: 'Create a Git repository on main',
46
+ initialize: 'Initialize kit (the same setup as josh init)',
47
+ commit: 'Commit every file as the initial commit',
48
+ repository: 'Create the GitHub repository and push main',
49
+ labels: 'Add the workflow labels the repository is missing',
50
+ }
51
+
52
+ function refused(refusal: string): StepPlan {
53
+ return { steps: [], refusal }
54
+ }
55
+
56
+ // A repository that already has a history keeps it: nothing is committed on the user's behalf, and
57
+ // only `main` is pushed, because the Issue workflow branches from and merges into it.
58
+ function existing_git_steps(state: GitState): StepPlan {
59
+ if (!state.has_commits) return { steps: FULL_STEPS.slice(1), refusal: undefined }
60
+
61
+ if (state.branch !== DEFAULT_BRANCH) {
62
+ return refused(
63
+ `josh start pushes ${DEFAULT_BRANCH}, but this repository is on ${state.branch ?? 'a detached HEAD'}. Switch to ${DEFAULT_BRANCH}, or run josh init to set up without GitHub.`,
64
+ )
65
+ }
66
+
67
+ return { steps: ['initialize', 'repository', 'labels'], refusal: undefined }
68
+ }
69
+
70
+ // An existing GitHub origin is never replaced or pushed to: only the additive steps run. Any other
71
+ // origin is refused here, before the first write — `gh repo create --remote origin` cannot add a
72
+ // remote that already exists, so the run would otherwise fail after initializing the directory.
73
+ function plan_steps(state: GitState): StepPlan {
74
+ if (state.has_github) return { steps: ['initialize', 'labels'], refusal: undefined }
75
+
76
+ if (state.has_origin) {
77
+ return refused(
78
+ 'josh start creates the GitHub repository as origin, but this repository already has an origin that is not on GitHub. Remove or rename it, or run josh init to set up without GitHub.',
79
+ )
80
+ }
81
+
82
+ if (state.has_git) return existing_git_steps(state)
83
+
84
+ return { steps: FULL_STEPS, refusal: undefined }
85
+ }
86
+
87
+ function interaction_of(options: StartOptions, is_tty: boolean): Verdict<Interaction> {
88
+ if (options.is_yes) return { value: 'unattended', refusal: undefined }
89
+ if (is_tty) return { value: 'interactive', refusal: undefined }
90
+
91
+ return {
92
+ value: undefined,
93
+ refusal:
94
+ 'josh start needs a terminal to confirm its choices. Pass --yes to accept the detected profile, and --github to create the GitHub repository.',
95
+ }
96
+ }
97
+
98
+ // `--yes` accepts the detected defaults, never the creation of a public-facing resource: an
99
+ // unattended run creates and pushes a repository only when `--github` asks for it by name.
100
+ function github_consent(
101
+ steps: ReadonlyArray<StartStep>,
102
+ options: StartOptions,
103
+ interaction: Interaction,
104
+ ): Verdict<Consent> {
105
+ if (!steps.includes('repository') || options.is_github) {
106
+ return { value: 'granted', refusal: undefined }
107
+ }
108
+
109
+ if (interaction === 'interactive') return { value: 'ask', refusal: undefined }
110
+
111
+ return {
112
+ value: undefined,
113
+ refusal:
114
+ 'josh start --yes does not create a GitHub repository or push. Add --github to create one, or run josh init to set up without GitHub.',
115
+ }
116
+ }
117
+
118
+ // What a failed run prints, so the user can tell how far it got before deciding how to resume.
119
+ function progress_report(
120
+ failed: StartStep,
121
+ completed: ReadonlyArray<StartStep>,
122
+ cause: string,
123
+ ): string {
124
+ const done =
125
+ completed.length === 0 ? 'nothing' : completed.map((step) => STEP_LABELS[step]).join('; ')
126
+
127
+ return `josh start stopped at: ${STEP_LABELS[failed]}\nCompleted: ${done}\nCause: ${cause}`
128
+ }
129
+
130
+ function plan_summary(steps: ReadonlyArray<StartStep>): string {
131
+ const lines = steps.map((step, index) => ` ${String(index + 1)}. ${STEP_LABELS[step]}`)
132
+
133
+ return ['josh start will:', ...lines].join('\n')
134
+ }
135
+
136
+ const start_plan = {
137
+ plan_steps,
138
+ interaction_of,
139
+ github_consent,
140
+ progress_report,
141
+ plan_summary,
142
+ STEP_LABELS,
143
+ }
144
+ export { start_plan }
145
+ export type { Consent, GitState, Interaction, StartOptions, StartStep, StepPlan, Visibility }