@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.
- package/.claude/skills/workflow-commands/background-commands.md +3 -0
- package/.claude/skills/workflow-commands/backlogrun-child.md +75 -120
- package/.claude/skills/workflow-commands/backlogrun-lanes.md +78 -115
- package/.claude/skills/workflow-commands/backlogrun-progress.md +82 -146
- package/.claude/skills/workflow-commands/backlogrun-steps.md +173 -229
- package/.claude/skills/workflow-commands/backlogrun.md +3 -1
- package/.claude/skills/workflow-commands/followup.md +3 -0
- package/.claude/skills/workflow-commands/fullrun.md +3 -4
- package/.claude/skills/workflow-commands/observation-filing.md +31 -25
- package/.claude/skills/workflow-commands/pre-gate-cut.md +96 -161
- package/.github/workflows/pr-classification.yml +12 -1
- package/CLAUDE.md +3 -3
- package/README.md +5 -3
- package/dist/CLAUDE.md +3 -3
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-OM5VDM6F.js → chunk-2K52BRWV.js} +2 -2
- package/dist/hooks/{chunk-Z5TVZ4AP.js → chunk-BNWIOJFU.js} +29 -16
- package/dist/hooks/{chunk-NKVLBB65.js → chunk-BVMSPG3M.js} +3 -3
- package/dist/hooks/{chunk-IA7WC4NS.js → chunk-CRGJTD6E.js} +1 -1
- package/dist/hooks/{chunk-M4U4YNRE.js → chunk-F6OM6SIG.js} +3 -3
- package/dist/hooks/{chunk-O3PEDF3M.js → chunk-KQG4776P.js} +1 -1
- package/dist/hooks/{chunk-V7RH56HU.js → chunk-NXL3SBXY.js} +2 -2
- package/dist/hooks/{chunk-452OKRQX.js → chunk-WWTMHHZU.js} +120 -18
- package/dist/hooks/{chunk-EYFJ74MJ.js → chunk-ZPLXLJ7O.js} +21 -7
- package/dist/hooks/{chunk-IBFC25IX.js → chunk-ZYYSBNR6.js} +4 -4
- package/dist/hooks/codex-hook-adapter.js +10 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +206 -176
- package/dist/josh.js +14 -6
- package/package.json +3 -3
- package/prompts/collaboration-workflow/wip-cap.md +1 -1
- package/scripts/backlog/backlog-arrival.ts +11 -1
- package/scripts/backlog/backlog-budget.ts +25 -8
- package/scripts/backlog/backlog-drive-cli.ts +5 -1
- package/scripts/backlog/backlog-drive-retrospective.ts +20 -0
- package/scripts/backlog/backlog-drive.ts +7 -3
- package/scripts/backlog/backlog-next.ts +45 -2
- package/scripts/backlog/backlog-offer.ts +1 -0
- package/scripts/backlog/backlog-plan-cli.ts +66 -27
- package/scripts/backlog/backlog-plan.ts +30 -2
- package/scripts/backlog/backlog-stalled.ts +9 -0
- package/scripts/backlog/backlog-waves.ts +169 -0
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/document/entry-read-budget.ts +7 -5
- package/scripts/epic/epic-lane-offer.ts +69 -4
- package/scripts/epic/epic-next.ts +2 -2
- package/scripts/epic/epic-solo.ts +147 -0
- package/scripts/epic/epic-triage.ts +47 -0
- package/scripts/gate/hook-gate-reuse.ts +7 -8
- package/scripts/git/git-closes-keyword.ts +15 -0
- package/scripts/git/git-followup-flush.ts +5 -6
- package/scripts/git/git-followup-issue-close.ts +75 -0
- package/scripts/git/git-pr-followup-wrapup.ts +36 -6
- package/scripts/git/git-pr-followup.ts +9 -16
- package/scripts/git/git-staging.ts +33 -20
- package/scripts/git/issue-labels.ts +42 -0
- package/scripts/hooks/stop-guard.ts +1 -0
- package/scripts/init/init-install.ts +64 -0
- package/scripts/init/init.ts +38 -11
- package/scripts/init/project-profile.ts +9 -5
- package/scripts/init/start-plan.ts +145 -0
- package/scripts/init/start-prompt.ts +55 -0
- package/scripts/init/start-steps.ts +167 -0
- package/scripts/init/start.ts +92 -16
- package/scripts/issue/issue-closing-pr.ts +93 -0
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +4 -2
- package/scripts/josh/josh-commands-project.ts +11 -3
- package/scripts/josh/josh-commands-workflow.ts +3 -3
- package/scripts/observations/observation-ledger-prepare.ts +31 -0
- package/scripts/observations/observation-ledger.ts +5 -5
- package/scripts/observations/observations-flush.ts +11 -30
- package/scripts/rules/implementation-cut.ts +49 -16
- package/scripts/rules/lane-background.ts +25 -5
- package/scripts/rules/stop-rules.ts +16 -4
- package/scripts/run/run-carry-cli.ts +21 -0
- package/scripts/run/run-cut-cli.ts +92 -110
- package/scripts/run/run-cut-report.ts +167 -0
- package/scripts/run/run-cut.ts +23 -7
- package/scripts/run/run-entry-cli.ts +36 -2
- package/scripts/run/run-headless.ts +1 -0
- package/scripts/run/run-hold-cli.ts +36 -15
- package/scripts/run/run-hold.ts +15 -4
- package/scripts/run/run-merge-cli.ts +28 -14
- package/scripts/run/run-merge-steps.ts +38 -2
- package/scripts/run/run-merge.ts +41 -3
- package/scripts/run/run-prep-cli.ts +8 -2
- package/scripts/run/run-prep-locate.ts +165 -0
- package/scripts/run/run-prep.ts +12 -1
- package/scripts/run/run-retrospective.ts +34 -0
- package/scripts/run/run-step-cli.ts +19 -5
- package/scripts/run/run-step.ts +31 -19
- package/scripts/run/run-tail-cli.ts +4 -2
- package/scripts/run/run-wake-work.ts +4 -0
- package/scripts/safe-chain/ci-installer-pin.ts +8 -4
- package/scripts/time-runtime/time-background.ts +11 -0
- package/scripts/time-runtime/time-transcript-line.ts +4 -0
- 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,
|
|
169
|
-
//
|
|
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,
|
|
182
|
-
//
|
|
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([
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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) =>
|
|
106
|
+
.filter((file_path) => excluded.includes(file_path))
|
|
98
107
|
|
|
99
|
-
for (const file_path of
|
|
100
|
-
console.info(
|
|
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(
|
|
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 }
|
package/scripts/init/init.ts
CHANGED
|
@@ -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
|
-
//
|
|
228
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
33
|
-
|
|
32
|
+
function is_profile_pair(args: ReadonlyArray<string>): boolean {
|
|
33
|
+
return args.length === PROFILE_ARG_LENGTH && args[0] === '--profile'
|
|
34
|
+
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
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 }
|