@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
@@ -0,0 +1,55 @@
1
+ import path from 'node:path'
2
+ import { stdin as input, stdout as output } from 'node:process'
3
+ import { createInterface, type Interface } from 'node:readline/promises'
4
+ import { ask_yes_no_simple } from '#scripts/git/git-prompt'
5
+ import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
6
+ import type { Consent, StartOptions } from './start-plan'
7
+
8
+ interface Choices {
9
+ options: StartOptions
10
+ shape: ProjectShape
11
+ consent: Consent
12
+ root: string
13
+ }
14
+
15
+ const DECLINED =
16
+ 'Stopped before creating the GitHub repository. Nothing was changed. Run josh init to set up without GitHub.'
17
+
18
+ // The detected profile is the default, so pressing Enter accepts it; the reason is shown because an
19
+ // ambiguous project is exactly the case this question exists for.
20
+ async function ask_profile(prompt: Interface, shape: ProjectShape): Promise<ProjectProfile> {
21
+ const question = `Profile — static or node [${shape.profile}, ${shape.reason}]: `
22
+ const raw_answer = await prompt.question(question)
23
+ const answer = raw_answer.trim()
24
+ if (answer.length === 0) return shape.profile
25
+
26
+ return project_profile.requested_profile(['--profile', answer]) ?? shape.profile
27
+ }
28
+
29
+ async function ask_repository(prompt: Interface, choices: Choices): Promise<void> {
30
+ const name = path.basename(choices.root)
31
+ const question = `Create a ${choices.options.visibility} GitHub repository "${name}" and push main? (y/n): `
32
+
33
+ if (!(await ask_yes_no_simple(prompt, question))) throw new Error(DECLINED)
34
+ }
35
+
36
+ async function ask_choices(prompt: Interface, choices: Choices): Promise<ProjectProfile> {
37
+ const profile = choices.options.profile ?? (await ask_profile(prompt, choices.shape))
38
+ if (choices.consent === 'ask') await ask_repository(prompt, choices)
39
+
40
+ return profile
41
+ }
42
+
43
+ async function confirm_choices(choices: Choices): Promise<ProjectProfile> {
44
+ const prompt = createInterface({ input, output })
45
+
46
+ try {
47
+ return await ask_choices(prompt, choices)
48
+ } finally {
49
+ prompt.close()
50
+ }
51
+ }
52
+
53
+ const start_prompt = { confirm_choices }
54
+ export { start_prompt }
55
+ export type { Choices }
@@ -0,0 +1,167 @@
1
+ import path from 'node:path'
2
+ import { git_gh_exec } from '#scripts/git/git-gh-exec'
3
+ import { WORKFLOW_LABELS } from '#scripts/git/issue-labels'
4
+ import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
5
+ import { execaSync } from 'execa'
6
+ import { main as init_main } from './init'
7
+ import { PACKAGE_DIR } from './init-paths'
8
+ import type { ProjectProfile, ProjectShape } from './project-profile'
9
+ import { start_plan, type GitState, type StartStep, type Visibility } from './start-plan'
10
+
11
+ interface StepContext {
12
+ root: string
13
+ profile: ProjectProfile
14
+ visibility: Visibility
15
+ }
16
+
17
+ type WorkflowLabel = (typeof WORKFLOW_LABELS)[number]
18
+
19
+ const DEFAULT_BRANCH = 'main'
20
+ const LABELS_ENDPOINT = 'repos/{owner}/{repo}/labels'
21
+ const GH_INSTALL_HINT =
22
+ 'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/, run gh auth login, then run josh start again. Nothing was changed.'
23
+ const GH_AUTH_HINT =
24
+ 'GitHub CLI is not signed in. Run gh auth login, then run josh start again. Nothing was changed.'
25
+
26
+ function succeeds(command: string, args: ReadonlyArray<string>, root: string): boolean {
27
+ return execaSync(command, args, { cwd: root, reject: false }).exitCode === 0
28
+ }
29
+
30
+ function read_output(
31
+ command: string,
32
+ args: ReadonlyArray<string>,
33
+ root: string,
34
+ ): string | undefined {
35
+ const result = execaSync(command, args, { cwd: root, reject: false })
36
+
37
+ return result.exitCode === 0 ? result.stdout.trim() : undefined
38
+ }
39
+
40
+ // Every step spawned here dials GitHub directly, past a scanner's loopback proxy, the same as every
41
+ // other `gh` spawn under scripts/ (joshuafolkken/kit#2436); a local `git` step is unaffected by it.
42
+ function run(command: string, args: ReadonlyArray<string>, root: string): void {
43
+ execaSync(command, args, { ...git_gh_exec.direct_environment(), cwd: root, stdio: 'inherit' })
44
+ }
45
+
46
+ function read_git_state(root: string, shape: ProjectShape): GitState {
47
+ const { has_git, has_github } = shape
48
+
49
+ if (!has_git) {
50
+ return { has_git, has_github, has_origin: false, branch: undefined, has_commits: false }
51
+ }
52
+
53
+ const has_origin = succeeds('git', ['remote', 'get-url', 'origin'], root)
54
+ const branch = read_output('git', ['symbolic-ref', '--short', 'HEAD'], root)
55
+ const has_commits = succeeds('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], root)
56
+
57
+ return { has_git, has_github, has_origin, branch, has_commits }
58
+ }
59
+
60
+ // Every step talks to GitHub, so the CLI is checked before the first write rather than at the step
61
+ // that needs it — a missing `gh` then leaves the directory exactly as it was.
62
+ function github_cli_refusal(root: string): string | undefined {
63
+ if (!succeeds('gh', ['--version'], root)) return GH_INSTALL_HINT
64
+ if (!succeeds('gh', ['auth', 'status'], root)) return GH_AUTH_HINT
65
+
66
+ return undefined
67
+ }
68
+
69
+ // `josh init` refuses to run inside the kit source repository by printing and setting the exit code
70
+ // rather than throwing, so its step would pass and the later steps would commit and publish the
71
+ // source repository. The same check is raised before the first write instead.
72
+ function self_run_refusal(root: string): string | undefined {
73
+ return self_sync_guard.self_sync_refusal(PACKAGE_DIR, root)
74
+ }
75
+
76
+ function create_git_repository(context: StepContext): void {
77
+ run('git', ['init', `--initial-branch=${DEFAULT_BRANCH}`], context.root)
78
+ }
79
+
80
+ // The same setup `josh init` runs, called rather than copied, with the profile the user confirmed.
81
+ function initialize(context: StepContext): void {
82
+ init_main(['--profile', context.profile])
83
+ }
84
+
85
+ function commit_all(context: StepContext): void {
86
+ run('git', ['add', '--all'], context.root)
87
+ // The first commit of a new repository has no branch to come from, so the hook that keeps commits
88
+ // off main — installed by the initialize step in a node project — would refuse the only commit
89
+ // that has to land there.
90
+ run('git', ['commit', '--no-verify', '--message', 'Initial commit'], context.root)
91
+ // A repository created by an older `git init` may name its unborn branch `master`.
92
+ run('git', ['branch', '--move', '--force', DEFAULT_BRANCH], context.root)
93
+ }
94
+
95
+ function create_github_repository(context: StepContext): void {
96
+ const name = path.basename(context.root)
97
+ const visibility = `--${context.visibility}`
98
+ const source = ['--source', '.', '--remote', 'origin', '--push']
99
+
100
+ run('gh', ['repo', 'create', name, visibility, ...source], context.root)
101
+ }
102
+
103
+ // Labels already present are left as they are — only the missing ones are created, so a repository
104
+ // that recolored a label keeps its choice. GitHub compares label names case-insensitively.
105
+ function missing_workflow_labels(existing: ReadonlyArray<string>): ReadonlyArray<WorkflowLabel> {
106
+ const names = new Set(existing.map((name) => name.toLowerCase()))
107
+
108
+ return WORKFLOW_LABELS.filter((label) => !names.has(label.name))
109
+ }
110
+
111
+ function create_label(label: WorkflowLabel, root: string): void {
112
+ const fields = [`name=${label.name}`, `color=${label.color}`, `description=${label.description}`]
113
+
114
+ run('gh', ['api', LABELS_ENDPOINT, ...fields.flatMap((field) => ['-f', field]), '--silent'], root)
115
+ console.info(` ✔ label ${label.name}`)
116
+ }
117
+
118
+ function add_missing_labels(context: StepContext): void {
119
+ const listing = execaSync('gh', ['api', '--paginate', LABELS_ENDPOINT, '--jq', '.[].name'], {
120
+ ...git_gh_exec.direct_environment(),
121
+ cwd: context.root,
122
+ })
123
+ const existing = listing.stdout.split('\n').filter((name) => name.length > 0)
124
+
125
+ for (const label of missing_workflow_labels(existing)) create_label(label, context.root)
126
+ }
127
+
128
+ const STEP_ACTIONS: Readonly<Record<StartStep, (context: StepContext) => void>> = {
129
+ git_init: create_git_repository,
130
+ initialize,
131
+ commit: commit_all,
132
+ repository: create_github_repository,
133
+ labels: add_missing_labels,
134
+ }
135
+
136
+ function run_step(
137
+ step: StartStep,
138
+ context: StepContext,
139
+ completed: ReadonlyArray<StartStep>,
140
+ ): void {
141
+ try {
142
+ STEP_ACTIONS[step](context)
143
+ } catch (error) {
144
+ const cause = error instanceof Error ? error.message : String(error)
145
+
146
+ throw new Error(start_plan.progress_report(step, completed, cause), { cause: error })
147
+ }
148
+ }
149
+
150
+ function run_steps(steps: ReadonlyArray<StartStep>, context: StepContext): void {
151
+ for (const [index, step] of steps.entries()) {
152
+ const position = `${String(index + 1)}/${String(steps.length)}`
153
+
154
+ console.info(`\n[${position}] ${start_plan.STEP_LABELS[step]}`)
155
+ run_step(step, context, steps.slice(0, index))
156
+ }
157
+ }
158
+
159
+ const start_steps = {
160
+ read_git_state,
161
+ github_cli_refusal,
162
+ self_run_refusal,
163
+ missing_workflow_labels,
164
+ run_steps,
165
+ }
166
+ export { start_steps }
167
+ export type { StepContext }
@@ -1,30 +1,106 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
- import { main as init_main } from './init'
4
- import { project_profile, type ProjectShape } from './project-profile'
3
+ import { project_profile, type ProjectProfile } from './project-profile'
4
+ import {
5
+ start_plan,
6
+ type Consent,
7
+ type Interaction,
8
+ type StartOptions,
9
+ type StartStep,
10
+ } from './start-plan'
11
+ import { start_prompt, type Choices } from './start-prompt'
12
+ import { start_steps } from './start-steps'
5
13
 
6
14
  const ARGUMENT_START_INDEX = 2
15
+ const READY_MESSAGE = 'GitHub workflow ready. Use kickoff new in your assistant to plan an Issue.'
16
+ const START_USAGE = 'josh start [--profile static|node] [--yes] [--github] [--public]'
17
+ const YES_FLAG = '--yes'
18
+ const GITHUB_FLAG = '--github'
19
+ const PUBLIC_FLAG = '--public'
20
+ const SWITCHES: ReadonlySet<string> = new Set([YES_FLAG, GITHUB_FLAG, PUBLIC_FLAG])
7
21
 
8
- function prerequisite(shape: ProjectShape): string | undefined {
9
- if (!shape.has_git) return 'josh start requires Git. Run git init, or use josh init without Git.'
10
- if (!shape.has_github) return 'josh start requires a GitHub origin. Add one, or use josh init.'
22
+ interface Prepared {
23
+ steps: ReadonlyArray<StartStep>
24
+ interaction: Interaction
25
+ choices: Choices
26
+ }
27
+
28
+ // The switches are peeled off first, so what remains is exactly the `--profile` pair `josh init`
29
+ // accepts — one parser for the profile in both commands.
30
+ function parse_start_options(args: ReadonlyArray<string>): StartOptions {
31
+ const profile_args = args.filter((argument) => !SWITCHES.has(argument))
32
+
33
+ return {
34
+ profile: project_profile.requested_profile(profile_args, START_USAGE),
35
+ is_yes: args.includes(YES_FLAG),
36
+ is_github: args.includes(GITHUB_FLAG),
37
+ visibility: args.includes(PUBLIC_FLAG) ? 'public' : 'private',
38
+ }
39
+ }
40
+
41
+ function settle<T>(verdict: { value: T | undefined; refusal: string | undefined }): T {
42
+ if (verdict.value === undefined) throw new Error(verdict.refusal)
11
43
 
12
- return undefined
44
+ return verdict.value
13
45
  }
14
46
 
15
- function main(args: ReadonlyArray<string> = []): void {
16
- // --profile uses the same override as josh init.
17
- const requested = project_profile.requested_profile(args)
18
- const shape = project_profile.inspect_project(process.cwd(), requested)
19
- const message = prerequisite(shape)
20
- if (message !== undefined) throw new Error(message)
21
- init_main(args)
22
- console.info('GitHub workflow ready. Use kickoff in your assistant to plan an Issue.')
47
+ function stop_on(refusal: string | undefined): void {
48
+ if (refusal !== undefined) throw new Error(refusal)
49
+ }
50
+
51
+ // An unattended run already holds every answer: `--profile` or the detected one, and a consent that
52
+ // `github_consent` only grants when `--github` was given.
53
+ async function resolve_profile(
54
+ choices: Choices,
55
+ interaction: Interaction,
56
+ ): Promise<ProjectProfile> {
57
+ if (interaction === 'unattended') return choices.options.profile ?? choices.shape.profile
58
+
59
+ return await start_prompt.confirm_choices(choices)
60
+ }
61
+
62
+ // Every refusal is raised here, before the first write, so a run that stops here changed nothing.
63
+ function prepare(args: ReadonlyArray<string>, is_tty: boolean, root: string): Prepared {
64
+ stop_on(start_steps.self_run_refusal(root))
65
+ const options = parse_start_options(args)
66
+ const interaction = settle(start_plan.interaction_of(options, is_tty))
67
+ const shape = project_profile.inspect_project(root, options.profile)
68
+ const plan = start_plan.plan_steps(start_steps.read_git_state(root, shape))
69
+
70
+ stop_on(plan.refusal)
71
+ const consent: Consent = settle(start_plan.github_consent(plan.steps, options, interaction))
72
+
73
+ stop_on(start_steps.github_cli_refusal(root))
74
+
75
+ return { steps: plan.steps, interaction, choices: { options, shape, consent, root } }
76
+ }
77
+
78
+ async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void> {
79
+ const { steps, interaction, choices } = prepare(args, is_tty, process.cwd())
80
+
81
+ console.info(start_plan.plan_summary(steps))
82
+ const profile = await resolve_profile(choices, interaction)
83
+ const { root, options } = choices
84
+
85
+ start_steps.run_steps(steps, { root, profile, visibility: options.visibility })
86
+ console.info(`\n${READY_MESSAGE}`)
87
+ }
88
+
89
+ async function run(args: ReadonlyArray<string>, is_tty: boolean): Promise<number> {
90
+ try {
91
+ await main(args, is_tty)
92
+
93
+ return 0
94
+ } catch (error) {
95
+ console.error(error instanceof Error ? error.message : String(error))
96
+
97
+ return 1
98
+ }
23
99
  }
24
100
 
25
101
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
26
- main(process.argv.slice(ARGUMENT_START_INDEX))
102
+ process.exitCode = await run(process.argv.slice(ARGUMENT_START_INDEX), process.stdin.isTTY)
27
103
  }
28
104
 
29
- const start = { prerequisite }
105
+ const start = { run, parse_start_options }
30
106
  export { start }
@@ -0,0 +1,93 @@
1
+ import { git_closes_keyword } from '#scripts/git/git-closes-keyword'
2
+ import { git_gh_api_path } from '#scripts/git/git-gh-api-path'
3
+ import { git_gh_exec } from '#scripts/git/git-gh-exec'
4
+ import { git_gh_repo } from '#scripts/git/git-gh-repo'
5
+ import { parse_json_object_safe } from '#scripts/git/parse-json-array'
6
+ import { z } from 'zod'
7
+
8
+ // **The merged pull request that closes an issue GitHub left open** (joshuafolkken/kit#2769). A
9
+ // `backlogrun` child whose pull request merged while GitHub did not apply its `closes #N`
10
+ // (joshuafolkken/kit#2770) came back OPEN, and `run:merge` counted it a failure and parked it — the
11
+ // work had landed, only the issue's close had not.
12
+ //
13
+ // **A merged reference is not enough; the body must close this issue.** A merged pull request that
14
+ // merely mentions the issue — a follow-up, a sibling in the same epic — cross-references it too, and
15
+ // closing on that would close unfinished work. So the pull request counts only when its body's
16
+ // `closes #N` names this issue, read through the one parser every `closes #N` reader shares.
17
+ //
18
+ // **Only references after the last reopen count.** A reopened issue whose earlier attempt merged
19
+ // still carries that pull request's reference, and reading it would close the reopened work — the
20
+ // same reason `issue-merged.ts` reads the last closed/reopened event.
21
+ //
22
+ // **Only a pull request in this repository counts.** A bare `closes #N` names an issue in the pull
23
+ // request's own repository, so a merged pull request elsewhere whose `closes #N` carries the same
24
+ // number closes a different issue — its cross-reference here is a mention, not a close.
25
+
26
+ const LINE_SEPARATOR = '\n'
27
+ // One compact JSON object per merged pull request that cross-references the issue, and a
28
+ // `{"reopened":true}` marker per reopen, in timeline order.
29
+ const TIMELINE_FILTER = [
30
+ '.[]',
31
+ '| if .event == "cross-referenced" and .source.issue.pull_request.merged_at != null',
32
+ 'then {url: .source.issue.html_url, repo: .source.issue.repository.full_name, body: (.source.issue.body // "")}',
33
+ 'elif .event == "reopened" then {reopened: true}',
34
+ 'else empty end',
35
+ ].join(' ')
36
+
37
+ const reference_schema = z.object({ url: z.string(), repo: z.string(), body: z.string() })
38
+ const reopen_schema = z.object({ reopened: z.literal(true) })
39
+ const row_schema = z.union([reference_schema, reopen_schema])
40
+
41
+ type MergedReference = z.infer<typeof reference_schema>
42
+ type TimelineRow = z.infer<typeof row_schema>
43
+
44
+ function is_reference(row: TimelineRow): row is MergedReference {
45
+ return 'url' in row
46
+ }
47
+
48
+ function parse_references(raw: string): Array<MergedReference> {
49
+ const rows = raw
50
+ .split(LINE_SEPARATOR)
51
+ .map((line) => parse_json_object_safe(line, row_schema))
52
+ .filter((row) => row !== undefined)
53
+ const last_reopen = rows.findLastIndex((row) => !is_reference(row))
54
+
55
+ return rows.slice(last_reopen + 1).filter((row) => is_reference(row))
56
+ }
57
+
58
+ function pick_closing_pr(
59
+ references: ReadonlyArray<MergedReference>,
60
+ issue: string,
61
+ repo: string,
62
+ ): string | undefined {
63
+ return references.find(
64
+ (reference) =>
65
+ reference.repo === repo &&
66
+ git_closes_keyword.parse_closes_issue_number(reference.body) === issue,
67
+ )?.url
68
+ }
69
+
70
+ // Every doubt falls to `undefined` — an unreadable timeline or repository name leaves the child
71
+ // judged as it was, never closed on a guess.
72
+ async function read_closing_pr(issue: string): Promise<string | undefined> {
73
+ try {
74
+ const [raw, repo] = await Promise.all([
75
+ git_gh_exec.exec_gh_api({
76
+ path: `${git_gh_api_path.issue_api_path(issue)}/timeline?per_page=100`,
77
+ should_paginate: true,
78
+ jq_filter: TIMELINE_FILTER,
79
+ }),
80
+ git_gh_repo.repo_get_name_with_owner(),
81
+ ])
82
+
83
+ if (repo === undefined) return undefined
84
+
85
+ return pick_closing_pr(parse_references(raw), issue, repo)
86
+ } catch {
87
+ return undefined
88
+ }
89
+ }
90
+
91
+ const issue_closing_pr = { parse_references, pick_closing_pr, read_closing_pr }
92
+
93
+ export { issue_closing_pr }
@@ -449,10 +449,11 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
449
449
  ],
450
450
  ],
451
451
  "init": [
452
- "[--profile static|node]",
452
+ "[--profile static|node] [--no-install]",
453
453
  "developer",
454
454
  [
455
455
  "files",
456
+ "network",
456
457
  "processes",
457
458
  ],
458
459
  ],
@@ -1103,12 +1104,13 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
1103
1104
  ],
1104
1105
  ],
1105
1106
  "start": [
1106
- "[--profile static|node]",
1107
+ "[--profile static|node] [--yes] [--github] [--public]",
1107
1108
  "developer",
1108
1109
  [
1109
1110
  "files",
1110
1111
  "git",
1111
1112
  "network",
1113
+ "processes",
1112
1114
  ],
1113
1115
  ],
1114
1116
  "stash:pop": [
@@ -14,15 +14,23 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
14
14
  },
15
15
  start: {
16
16
  script: 'scripts/init/start.ts',
17
- description: 'Initialize a project for the GitHub Issue workflow',
17
+ description: 'Create a new project for the GitHub Issue workflow, from git init to labels',
18
18
  category: 'Project',
19
- reference: [PROFILE_ARGUMENTS, 'developer', ['files', 'git', 'network']],
19
+ reference: [
20
+ `${PROFILE_ARGUMENTS} [--yes] [--github] [--public]`,
21
+ 'developer',
22
+ ['files', 'git', 'network', 'processes'],
23
+ ],
20
24
  },
21
25
  init: {
22
26
  script: 'scripts/init/init.ts',
23
27
  description: 'Initialize config in a new project',
24
28
  category: 'Project',
25
- reference: [PROFILE_ARGUMENTS, 'developer', ['files', 'processes']],
29
+ reference: [
30
+ `${PROFILE_ARGUMENTS} [--no-install]`,
31
+ 'developer',
32
+ ['files', 'network', 'processes'],
33
+ ],
26
34
  },
27
35
  sync: {
28
36
  script: 'scripts/sync/sync.ts',
@@ -52,9 +52,9 @@ const WORKFLOW_COMMANDS: Record<string, CommandEntry> = {
52
52
  reference: ['--task-type <type> --body <text>', 'automation', ['notifications']],
53
53
  tsx_arguments: OPTIONAL_ENV_FILE_FLAGS,
54
54
  },
55
- // The observation ledger's only commit path (joshuafolkken/kit#1756). `pnpm josh git` excludes the
56
- // ledger from what it stages, so without this command a parent's appended line has no route to the
57
- // default branch at all — and the recurrence count the promotion rule reads is a count of main.
55
+ // The observation ledger's commit path for the lines no run's own commit carried
56
+ // (joshuafolkken/kit#1756, joshuafolkken/kit#2763): a lane's lines sit in the primary checkout, which
57
+ // its commit cannot see, and the recurrence count the promotion rule reads is a count of main.
58
58
  'observations:flush': {
59
59
  script: 'scripts/observations/observations-flush-cli.ts',
60
60
  description: 'Commit the observation ledger as a pull request of its own, and merge it',
@@ -0,0 +1,31 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { OBSERVATION_LEDGER_PATH } from './observation-ledger'
4
+ import { observation_ledger_line, type BrokenLine } from './observation-ledger-line'
5
+ import { observation_ledger_migrate } from './observation-ledger-migrate'
6
+
7
+ // **What has to hold before a ledger line is committed, asked by both of the paths that commit one**
8
+ // (joshuafolkken/kit#2763): `pnpm josh observations:flush` and the run's own commit in
9
+ // `scripts/git/git-staging.ts`. Lines still on the old path are moved first (joshuafolkken/kit#2724),
10
+ // so the commit carries the move and the lines together, and every entry line is then parsed against
11
+ // the grammar (joshuafolkken/kit#2123) — each caller decides what a broken line costs it.
12
+ //
13
+ // **A ledger that is not there has no line to break** — the change a status saw is then its deletion,
14
+ // and a commit carries that like any other change to it.
15
+ async function read_ledger(root: string): Promise<string> {
16
+ try {
17
+ return await readFile(path.join(root, OBSERVATION_LEDGER_PATH), 'utf8')
18
+ } catch {
19
+ return ''
20
+ }
21
+ }
22
+
23
+ async function prepare(root: string): Promise<ReadonlyArray<BrokenLine>> {
24
+ observation_ledger_migrate.migrate(root)
25
+
26
+ return observation_ledger_line.broken_ledger_lines(await read_ledger(root))
27
+ }
28
+
29
+ const observation_ledger_prepare = { prepare }
30
+
31
+ export { observation_ledger_prepare }
@@ -1,9 +1,8 @@
1
1
  // The observation ledger's one path, named here rather than inside any of the places that act on it
2
- // (joshuafolkken/kit#1756). `git-staging.ts` excludes it from every ordinary commit,
3
- // `hook-gate-reuse.ts` excludes it from what a hook calls a carried tree, and
4
- // `observations-flush.ts` is the only thing that stages it — so a literal in each would be three
5
- // answers to one question, and a rename reaching only some of them would silently restore the
6
- // contamination the exclusion exists to prevent.
2
+ // (joshuafolkken/kit#1756). `git-staging.ts` stages it with the run's own commit when its grammar
3
+ // holds (joshuafolkken/kit#2763), `hook-gate-reuse.ts` excludes it from what a hook calls a carried
4
+ // tree, and `observations-flush.ts` commits what no run carried — so a literal in each would be three
5
+ // answers to one question, and a rename reaching only some of them would silently split them.
7
6
  //
8
7
  // **It sits under `docs/maintainers/`, apart from the reader-facing documents** (joshuafolkken/kit#2724).
9
8
  const OBSERVATION_LEDGER_PATH = 'docs/maintainers/observations.md'
@@ -70,6 +69,7 @@ const observation_ledger = {
70
69
  has_pending_append,
71
70
  is_ledger_line,
72
71
  is_ledger_path,
72
+ is_migration_claim,
73
73
  ledger_paths,
74
74
  status_path,
75
75
  }
@@ -1,17 +1,15 @@
1
- import { readFile } from 'node:fs/promises'
2
1
  import { git_command } from '#scripts/git/git-command'
3
2
  import { git_gh_command } from '#scripts/git/git-gh-command'
4
3
  import { main_sync } from '#scripts/git/main-sync'
5
4
  import { observation_ledger, OBSERVATION_LEDGER_PATH } from './observation-ledger'
6
- import { observation_ledger_line, type BrokenLine } from './observation-ledger-line'
7
- import { observation_ledger_migrate } from './observation-ledger-migrate'
5
+ import type { BrokenLine } from './observation-ledger-line'
6
+ import { observation_ledger_prepare } from './observation-ledger-prepare'
8
7
  import { observations_flush_landing } from './observations-flush-landing'
9
8
 
10
- // The commit path the observation ledger did not have (joshuafolkken/kit#1756). The parent session
11
- // that appends a line never runs `pnpm josh git`; a child runs it inside a lane work tree, which
12
- // cannot see the parent's checkout at all; and in the primary checkout `git add -u` swept the line
13
- // into whatever unrelated pull request that run was opening. So the ledger is excluded from ordinary
14
- // staging (`scripts/git/git-staging.ts`) and this is the one thing that stages it.
9
+ // The commit path for the ledger lines no run's own commit carried (joshuafolkken/kit#1756). A run in
10
+ // the primary checkout commits its appended lines with its own pull request (`scripts/git/
11
+ // git-staging.ts`, joshuafolkken/kit#2763), so what is left here is a lane's lines — they sit in the
12
+ // primary checkout, which a lane's commit cannot see — and whatever was appended after a commit.
15
13
  //
16
14
  // **The shape is `scripts/release/release-publish.ts`'s, deliberately.** Both open a pull request of
17
15
  // their own over one path, wait on the same required checks every other pull request waits on, and
@@ -57,7 +55,7 @@ function branch_name_for(stamp: string): string {
57
55
  // single issue, and the ledger's own lines say where each one was seen.
58
56
  function pull_request_body(): string {
59
57
  return [
60
- `Appended observations from \`${OBSERVATION_LEDGER_PATH}\`, which \`pnpm josh git\` never stages.`,
58
+ `Appended observations from \`${OBSERVATION_LEDGER_PATH}\` that no run's own commit carried.`,
61
59
  '',
62
60
  'The ledger is append-only, and a second line under one key is what promotes an observation to an issue — so this pull request adds lines and changes none.',
63
61
  '',
@@ -373,31 +371,14 @@ function broken_lines_message(broken: ReadonlyArray<BrokenLine>): string {
373
371
 
374
372
  // **A malformed line is refused before a branch is cut, not after.** The ledger path was collected
375
373
  // without a line ever being parsed (joshuafolkken/kit#2123), so a broken append could ride a flush
376
- // through; validating here keeps the committed ledger to its grammar.
377
- //
378
- // **A ledger that is not there has no line to break** — the change the status saw is then its
379
- // deletion, and a flush commits that like any other change to it.
380
- async function read_ledger(): Promise<string> {
381
- try {
382
- return await readFile(OBSERVATION_LEDGER_PATH, 'utf8')
383
- } catch {
384
- return ''
385
- }
386
- }
387
-
388
- async function refuse_broken_ledger(): Promise<void> {
389
- const broken = observation_ledger_line.broken_ledger_lines(await read_ledger())
374
+ // through; validating here keeps the committed ledger to its grammar. The migration and the parse are
375
+ // `observation-ledger-prepare.ts`'s, shared with the run's own commit (joshuafolkken/kit#2763).
376
+ async function prepare_ledger(): Promise<void> {
377
+ const broken = await observation_ledger_prepare.prepare(process.cwd())
390
378
 
391
379
  if (broken.length > 0) throw new Error(broken_lines_message(broken))
392
380
  }
393
381
 
394
- // Lines still on the ledger's old path are moved before anything is validated or staged
395
- // (joshuafolkken/kit#2724), so the flush commits the move and the lines together.
396
- async function prepare_ledger(): Promise<void> {
397
- observation_ledger_migrate.migrate(process.cwd())
398
- await refuse_broken_ledger()
399
- }
400
-
401
382
  // **Nothing pulls in front of the branch, and that is deliberate.** The ledger is dirty by
402
383
  // definition at this point, so `git pull --ff-only` aborts on it in exactly the case a pull would
403
384
  // have been for — an upstream flush that already advanced the ledger — and reports a failure about