@joshuafolkken/kit 1.1435.0 → 1.1453.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-park.md +2 -0
  2. package/.claude/skills/workflow-commands/backlogrun-steps.md +9 -7
  3. package/.claude/skills/workflow-commands/backlogrun.md +2 -2
  4. package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
  5. package/.claude/skills/workflow-commands/halfrun.md +1 -1
  6. package/.claude/skills/workflow-commands/kickoff.md +12 -13
  7. package/.claude/skills/workflow-commands/observation-filing.md +1 -1
  8. package/.claude/skills/workflow-commands/prerequisite.md +6 -0
  9. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  10. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  11. package/README.md +15 -17
  12. package/dist/hooks/batch-guard.js +6 -6
  13. package/dist/hooks/{chunk-3LE6VLSR.js → chunk-3C4XRYG6.js} +18 -5
  14. package/dist/hooks/{chunk-JXQBK323.js → chunk-5C7L6FRC.js} +131 -104
  15. package/dist/hooks/{chunk-EMB55BDF.js → chunk-D3NJXN5M.js} +2 -2
  16. package/dist/hooks/{chunk-7KMN5QB5.js → chunk-EF5GQ6WS.js} +3 -3
  17. package/dist/hooks/{chunk-2G5F2YCD.js → chunk-LBFYEOXJ.js} +3 -3
  18. package/dist/hooks/{chunk-XER6SSIB.js → chunk-MBJYQPEK.js} +16 -6
  19. package/dist/hooks/{chunk-QL6QTPWN.js → chunk-NO4AALP5.js} +3 -3
  20. package/dist/hooks/{chunk-4BHUPFKF.js → chunk-OLMO72ON.js} +1 -1
  21. package/dist/hooks/{chunk-MSYSUSY6.js → chunk-SHYUQOIS.js} +5 -5
  22. package/dist/hooks/{chunk-5SJSSCJG.js → chunk-UT7YC3N2.js} +1 -1
  23. package/dist/hooks/{chunk-MU4LJFIV.js → chunk-W5ZKGTSY.js} +3 -3
  24. package/dist/hooks/{chunk-YZXYOAZ3.js → chunk-Y3GKIKGW.js} +11 -5
  25. package/dist/hooks/{chunk-YW452ZAX.js → chunk-Z4XI7CZS.js} +2 -2
  26. package/dist/hooks/codex-hook-adapter.js +13 -13
  27. package/dist/hooks/duplicate-read-guard.js +7 -7
  28. package/dist/hooks/format-edited.js +3 -3
  29. package/dist/hooks/inputs.json +241 -240
  30. package/dist/hooks/investigation-guard.js +7 -7
  31. package/dist/hooks/pretool-guard.js +12 -12
  32. package/dist/hooks/session-lang.js +3 -3
  33. package/dist/hooks/stop-guard.js +6 -6
  34. package/dist/josh.js +33 -10
  35. package/package.json +1 -1
  36. package/prompts/collaboration-workflow/issue-template.md +10 -8
  37. package/prompts/review.md +8 -4
  38. package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
  39. package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
  40. package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
  41. package/scripts/auto-ok/auto-ok-judge.ts +67 -0
  42. package/scripts/backlog/backlog-drive-cli.ts +14 -0
  43. package/scripts/backlog/backlog-drive.ts +22 -3
  44. package/scripts/document/document-byte-budget.ts +1 -1
  45. package/scripts/fresh-machine/fresh-machine-patterns.ts +60 -15
  46. package/scripts/gate/project-checks.ts +5 -2
  47. package/scripts/gh/git-gh-issue-rest.ts +1 -0
  48. package/scripts/gh/git-gh-issue.ts +9 -0
  49. package/scripts/init/init-actions.ts +3 -3
  50. package/scripts/init/init-ai-copy.ts +1 -1
  51. package/scripts/init/init-basic.ts +1 -1
  52. package/scripts/init/init-bootstrap.ts +6 -2
  53. package/scripts/init/init-dry-run-report.ts +150 -0
  54. package/scripts/init/init-dry-run.ts +189 -0
  55. package/scripts/init/init-install.ts +8 -1
  56. package/scripts/init/init.ts +57 -19
  57. package/scripts/init/kit-setup-state.ts +11 -17
  58. package/scripts/init/own-formatter.ts +21 -0
  59. package/scripts/init/project-profile.ts +25 -6
  60. package/scripts/init/start-dry-run.ts +70 -0
  61. package/scripts/init/start-plan.ts +8 -3
  62. package/scripts/init/start-setup-pr.ts +13 -24
  63. package/scripts/init/start-steps.ts +16 -1
  64. package/scripts/init/start.ts +38 -18
  65. package/scripts/init/working-tree.ts +116 -0
  66. package/scripts/issue/issue-auto-ok.ts +68 -99
  67. package/scripts/issue/issue-file-cli.ts +17 -17
  68. package/scripts/issue/issue-file.ts +1 -6
  69. package/scripts/issue/issue-labels.ts +6 -2
  70. package/scripts/issue/issue-release-cli.ts +69 -23
  71. package/scripts/issue/issue-release.ts +3 -1
  72. package/scripts/josh/josh-commands-ai.ts +11 -0
  73. package/scripts/josh/josh-commands-issue.ts +3 -3
  74. package/scripts/josh/josh-commands-project.ts +2 -2
  75. package/scripts/josh/process-identity.ts +32 -6
  76. package/scripts/lane/lane-broker-server.ts +69 -0
  77. package/scripts/lane/lane-session-broker.ts +37 -14
  78. package/scripts/observations/observation-ledger-migrate.ts +3 -15
  79. package/scripts/release/release-issue-close.ts +133 -0
  80. package/scripts/release/release-publish.ts +12 -1
  81. package/scripts/repo/repository-labels.ts +11 -1
  82. package/scripts/review/comment-ranges.ts +199 -0
  83. package/scripts/review/review-brief-cli.ts +22 -5
  84. package/scripts/review/review-code-tree.ts +119 -0
  85. package/scripts/review/review-round2-cli.ts +16 -5
  86. package/scripts/review/review-round2.ts +30 -10
  87. package/scripts/review/review-stamps.ts +22 -1
  88. package/scripts/uninstall/distributed-files.ts +1 -1
@@ -15,6 +15,7 @@ import { start_exec } from './start-exec'
15
15
  import { start_plan, type GitState, type StartStep, type Visibility } from './start-plan'
16
16
  import { start_setup_pr } from './start-setup-pr'
17
17
  import { workflow_mark } from './workflow-mark'
18
+ import { working_tree } from './working-tree'
18
19
 
19
20
  interface StepContext {
20
21
  root: string
@@ -83,6 +84,17 @@ function git_identity_refusal(steps: ReadonlyArray<StartStep>, root: string): st
83
84
  : undefined
84
85
  }
85
86
 
87
+ // A repository with a history receives the setup as a commit, and the user's own uncommitted change to
88
+ // a file kit also writes would land in it, so the tree must be clean before the first write (#3835).
89
+ // The setup branch a failed commit or push left checked out is the exception: what is uncommitted
90
+ // there is that run's own output, which the re-run resumes with. Without a commit there is nothing to
91
+ // keep apart — the initial commit takes every file.
92
+ function uncommitted_refusal(root: string, state: GitState): string | undefined {
93
+ if (!state.has_commits || start_setup_pr.is_setup_branch(state.branch)) return undefined
94
+
95
+ return working_tree.dirty_tree_refusal(root, 'josh start')
96
+ }
97
+
86
98
  // `josh init` refuses to run inside the kit source repository by printing and setting the exit code
87
99
  // rather than throwing, so its step would pass and the later steps would commit and publish the
88
100
  // source repository. The same check is raised before the first write instead.
@@ -141,7 +153,9 @@ async function open_setup_pull_request(context: RunContext): Promise<void> {
141
153
  // What was already changed before the setup ran, read whenever a setup pull request will follow, so
142
154
  // it can tell the setup's files from the user's own (#2872). Kit's own `josh init` needs it as much as
143
155
  // a caller's command: its `josh format` rewrites the project's own sources, which kit cannot name, and
144
- // leaving them out failed the pull request's Prettier check.
156
+ // leaving them out failed the pull request's Prettier check. On `main` it is empty, since a dirty tree
157
+ // is refused there (#3835); it is kept for the setup branch a failed commit left, where the earlier
158
+ // run's output is already uncommitted when the re-run starts.
145
159
  function baseline_of(
146
160
  steps: ReadonlyArray<StartStep>,
147
161
  context: StepContext,
@@ -239,6 +253,7 @@ const start_steps = {
239
253
  github_cli_refusal,
240
254
  git_identity_refusal,
241
255
  self_run_refusal,
256
+ uncommitted_refusal,
242
257
  run_steps,
243
258
  }
244
259
  export { start_steps }
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { error_text } from '#scripts/lib/error-message'
4
- import { project_profile, type ProjectProfile } from './project-profile'
4
+ import { init_dry_run } from './init-dry-run'
5
+ import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
6
+ import { start_dry_run } from './start-dry-run'
5
7
  import {
6
8
  start_plan,
7
9
  type Consent,
@@ -15,13 +17,14 @@ import { start_steps } from './start-steps'
15
17
  const ARGUMENT_START_INDEX = 2
16
18
  const READY_MESSAGE = 'GitHub workflow ready. Use kickoff new in your assistant to plan an Issue.'
17
19
  const START_USAGE =
18
- 'josh start [--profile basic|full] [--yes] [--github] [--public] [--init-command <command>] [--push-preflight-command <command>]'
20
+ 'josh start [--profile basic|full] [--yes] [--github] [--public] [--dry-run] [--init-command <command>] [--push-preflight-command <command>]'
19
21
  const INIT_COMMAND_FLAG = '--init-command'
20
22
  const PUSH_PREFLIGHT_COMMAND_FLAG = '--push-preflight-command'
21
23
  const YES_FLAG = '--yes'
22
24
  const GITHUB_FLAG = '--github'
23
25
  const PUBLIC_FLAG = '--public'
24
- const SWITCHES: ReadonlySet<string> = new Set([YES_FLAG, GITHUB_FLAG, PUBLIC_FLAG])
26
+ const { DRY_RUN_FLAG } = init_dry_run
27
+ const SWITCHES: ReadonlySet<string> = new Set([YES_FLAG, GITHUB_FLAG, PUBLIC_FLAG, DRY_RUN_FLAG])
25
28
 
26
29
  interface Prepared {
27
30
  steps: ReadonlyArray<StartStep>
@@ -67,6 +70,7 @@ function parse_start_options(args: ReadonlyArray<string>): StartOptions {
67
70
  profile: project_profile.requested_profile(profile_args, START_USAGE),
68
71
  is_yes: rest.includes(YES_FLAG),
69
72
  is_github: rest.includes(GITHUB_FLAG),
73
+ is_dry_run: rest.includes(DRY_RUN_FLAG),
70
74
  visibility: rest.includes(PUBLIC_FLAG) ? 'public' : 'private',
71
75
  init_command: init.command,
72
76
  push_preflight_command,
@@ -94,34 +98,34 @@ async function resolve_profile(
94
98
  return await start_prompt.confirm_choices(choices)
95
99
  }
96
100
 
101
+ function planned_steps(root: string, shape: ProjectShape): ReadonlyArray<StartStep> {
102
+ const state = start_steps.read_git_state(root, shape)
103
+ const plan = start_plan.plan_steps(state)
104
+
105
+ stop_on(plan.refusal)
106
+ stop_on(start_steps.uncommitted_refusal(root, state))
107
+
108
+ return plan.steps
109
+ }
110
+
97
111
  // Every refusal is raised here, before the first write, so a run that stops here changed nothing.
98
112
  function prepare(args: ReadonlyArray<string>, is_tty: boolean, root: string): Prepared {
99
113
  stop_on(start_steps.self_run_refusal(root))
100
114
  const options = parse_start_options(args)
101
115
  const interaction = settle(start_plan.interaction_of(options, is_tty))
102
116
  const shape = project_profile.inspect_project(root, options.profile)
103
- const plan = start_plan.plan_steps(start_steps.read_git_state(root, shape))
104
-
105
- stop_on(plan.refusal)
106
- const consent: Consent = settle(start_plan.github_consent(plan.steps, options, interaction))
117
+ const steps = planned_steps(root, shape)
118
+ const consent: Consent = settle(start_plan.github_consent(steps, options, interaction))
107
119
 
108
120
  stop_on(start_steps.github_cli_refusal(root))
109
- stop_on(start_steps.git_identity_refusal(plan.steps, root))
121
+ stop_on(start_steps.git_identity_refusal(steps, root))
110
122
 
111
- return {
112
- steps: plan.steps,
113
- interaction,
114
- choices: { options, shape, consent, root, steps: plan.steps },
115
- }
123
+ return { steps, interaction, choices: { options, shape, consent, root, steps } }
116
124
  }
117
125
 
118
- async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void> {
119
- const { steps, interaction, choices } = prepare(args, is_tty, process.cwd())
120
-
126
+ async function run_steps({ steps, interaction, choices }: Prepared): Promise<void> {
121
127
  const { root, options } = choices
122
128
  const { visibility, init_command, push_preflight_command } = options
123
-
124
- console.info(start_plan.plan_summary(steps, start_plan.step_labels(init_command)))
125
129
  const profile = await resolve_profile(choices, interaction)
126
130
  const context = { root, profile, visibility, init_command, push_preflight_command }
127
131
 
@@ -129,6 +133,22 @@ async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void>
129
133
  console.info(`\n${READY_MESSAGE}`)
130
134
  }
131
135
 
136
+ // A dry run stops after the plan, adding what each step would create, before the first write.
137
+ async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void> {
138
+ const prepared = prepare(args, is_tty, process.cwd())
139
+ const { steps, choices } = prepared
140
+
141
+ console.info(start_plan.plan_summary(steps, start_plan.step_labels(choices.options.init_command)))
142
+
143
+ if (choices.options.is_dry_run) {
144
+ console.info(start_dry_run.report(choices))
145
+
146
+ return
147
+ }
148
+
149
+ await run_steps(prepared)
150
+ }
151
+
132
152
  async function run(args: ReadonlyArray<string>, is_tty: boolean): Promise<number> {
133
153
  try {
134
154
  await main(args, is_tty)
@@ -0,0 +1,116 @@
1
+ import { git_location_environment } from '#scripts/git/git-location-environment'
2
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
3
+ import { start_exec } from './start-exec'
4
+
5
+ const UNTRACKED_ARGUMENTS = ['ls-files', '--others', '--exclude-standard']
6
+ // `--relative` keeps the listing to the project and its paths relative to it, the way `ls-files`
7
+ // already reads them, for a project that sits below its repository's top level.
8
+ const MODIFIED_ARGUMENTS = ['diff', '--name-only', '--relative', 'HEAD']
9
+ // Under an unborn HEAD every indexed file is uncommitted, so the index itself is the listing.
10
+ const INDEXED_ARGUMENTS = ['ls-files', '--cached']
11
+ const HISTORY_ARGUMENTS = ['rev-list', '--max-count=1', '--all']
12
+ const UNBORN_HEAD_EXIT_CODE = 1
13
+ const NOT_A_REPOSITORY = 'not a git repository'
14
+
15
+ // What a project's working tree holds that is not committed, read one way for every setup command:
16
+ // the setup pull request tells the setup's files from the user's own with it, and `josh init` refuses
17
+ // to write over it.
18
+
19
+ // Listings are read NUL-separated (`-z`), so a path is never quoted — a name a caller's initialize
20
+ // command wrote need not be ASCII the way kit's own are (#2872).
21
+ function paths_of(output: string | undefined = ''): Array<string> {
22
+ return output.split('\0').filter((listed) => listed.length > 0)
23
+ }
24
+
25
+ function listing(args: ReadonlyArray<string>, root: string): Array<string> {
26
+ return paths_of(start_exec.git_read([...args, '-z'], root))
27
+ }
28
+
29
+ // Untracked files the ignore rules let through, plus tracked files changed since HEAD, staged or not
30
+ // — two plain listings, so nothing has to be parsed out of `git status`.
31
+ function changed_paths(root: string): Array<string> {
32
+ const untracked = listing(UNTRACKED_ARGUMENTS, root)
33
+ const modified = listing(MODIFIED_ARGUMENTS, root)
34
+
35
+ return [...untracked, ...modified]
36
+ }
37
+
38
+ // A read that failed is thrown rather than read as an empty listing: the refusal guards the user's
39
+ // work, so a git that did not answer must never pass for a clean tree.
40
+ function strict_listing(args: ReadonlyArray<string>, root: string): Array<string> {
41
+ const output = start_exec.git_read([...args, '-z'], root)
42
+
43
+ if (output === undefined) throw new Error(`git ${args.join(' ')} failed in ${root}`)
44
+
45
+ return paths_of(output)
46
+ }
47
+
48
+ type HeadState = 'born' | 'unborn' | 'absent'
49
+
50
+ // Asked of git rather than of a `.git` beside the project, so a project below its repository's top
51
+ // level is still found. `--verify --quiet` exits 1 for an unborn HEAD, and only `not a git repository`
52
+ // (read under the C locale) proves there is no repository; any other failure is an unanswered
53
+ // question, not a missing commit.
54
+ function head_state(root: string): HeadState {
55
+ const { exit_code, stderr } = git_spawn_sync.run(['rev-parse', '--verify', '--quiet', 'HEAD'], {
56
+ cwd: root,
57
+ env: { ...git_spawn_sync.C_LOCALE, ...git_location_environment.location_free_environment() },
58
+ })
59
+
60
+ if (exit_code === 0) return 'born'
61
+ if (stderr.includes(NOT_A_REPOSITORY)) return 'absent'
62
+ if (exit_code === UNBORN_HEAD_EXIT_CODE) return 'unborn'
63
+ throw new Error(`git rev-parse could not read HEAD in ${root}`)
64
+ }
65
+
66
+ // An unborn HEAD is either the repository `josh start` has yet to make its first commit in, or an
67
+ // orphan branch (`git checkout --orphan`) whose index still holds another branch's files — told
68
+ // apart by whether any ref reaches a commit.
69
+ function has_history(root: string): boolean {
70
+ const output = start_exec.git_read(HISTORY_ARGUMENTS, root)
71
+
72
+ if (output === undefined) throw new Error(`git ${HISTORY_ARGUMENTS.join(' ')} failed in ${root}`)
73
+
74
+ return output.trim().length > 0
75
+ }
76
+
77
+ // What the tracked files are compared with, or undefined when there is nothing to tell the user's
78
+ // work from — a directory outside any repository, or a repository without any commit.
79
+ function tracked_arguments(root: string): ReadonlyArray<string> | undefined {
80
+ const state = head_state(root)
81
+
82
+ if (state === 'born') return MODIFIED_ARGUMENTS
83
+ if (state === 'unborn' && has_history(root)) return INDEXED_ARGUMENTS
84
+
85
+ return undefined
86
+ }
87
+
88
+ function uncommitted_paths(root: string): Array<string> {
89
+ const tracked = tracked_arguments(root)
90
+
91
+ if (tracked === undefined) return []
92
+
93
+ const untracked = strict_listing(UNTRACKED_ARGUMENTS, root)
94
+ const modified = strict_listing(tracked, root)
95
+
96
+ return [...untracked, ...modified]
97
+ }
98
+
99
+ // The refusal a setup command stops with before its first write, or undefined when nothing is
100
+ // uncommitted: what kit then writes would land in the same files as the user's own work, and
101
+ // `git diff` could no longer tell the two apart.
102
+ function dirty_tree_refusal(root: string, command: string): string | undefined {
103
+ const paths = uncommitted_paths(root)
104
+
105
+ if (paths.length === 0) return undefined
106
+
107
+ return [
108
+ `${command} refuses to run: the working tree has uncommitted changes.`,
109
+ ...paths.map((listed) => ` ${listed}`),
110
+ `Commit them, or stash them with \`git stash push -u -- .\`, then run ${command} again.`,
111
+ ].join('\n')
112
+ }
113
+
114
+ const working_tree = { listing, changed_paths, uncommitted_paths, dirty_tree_refusal }
115
+
116
+ export { working_tree }
@@ -1,34 +1,30 @@
1
- import { git_branch } from '#scripts/git/git-branch'
2
- import { git_command } from '#scripts/git/git-command'
3
- import { run_carry } from '#scripts/run/carry/run-carry'
4
- import { issue_file } from './issue-file'
5
- import { AUTO_OK_LABEL, has_label_name, RELEASE_LABEL } from './issue-labels'
6
- import { issue_state_cli } from './issue-state-cli'
7
-
8
- // Whether `josh issue:file` applies `auto-ok` to the Issue it files. So that work found during an
9
- // unattended run does not wait in the backlog until a person labels it, the filing computes the
10
- // answer instead of leaving it to the agent's memory: an Issue filed while a
11
- // `backlogrun` carry record is live, or from a branch whose Issue carries `auto-ok`, is opted in by
12
- // default. `--no-auto-ok` is the one exception, declared for an Issue that needs a person's judgement
13
- // (a Tier B toss-up or a Tier C action). A filing to another repository is never opted in: that
14
- // repository's own `backlogrun` would implement and merge it though nobody opted that repository in.
15
- // A `release` Issue is never opted in either: a release is Tier C, so only a
16
- // person applies `auto-ok` to it, whatever the carry record or the branch's Issue says.
17
- // `--requested` withholds the default the same way: a person asked for this filing (a `new` entry, or
18
- // a request in conversation), so the run's own opt-in does not speak for them — `auto-ok` comes only
19
- // from their own `--label auto-ok`. A filing without the declaration — an observation, a
20
- // retrospective, a routed filing — keeps the default above.
21
-
22
- interface AutoOkSignals {
1
+ import {
2
+ BREAKING_CHANGE_LABEL,
3
+ DEFERRED_LABEL,
4
+ EPIC_LABEL,
5
+ has_label_name,
6
+ NEEDS_HUMAN_REVIEW_LABEL,
7
+ RELEASE_LABEL,
8
+ } from './issue-labels'
9
+ import { markdown_section } from './markdown-section'
10
+
11
+ // Whether `josh issue:file` applies `auto-ok` to the Issue it files, judged against the criteria
12
+ // `docs/how-to/run-backlog.md` records rather than the context the filing happens in, so a filing a
13
+ // person asked for (`kickoff new`) is judged the same as one an agent makes on its own. The answer is
14
+ // a pure reading of the filing — its labels, its body and its target — so `backlogrun`'s judgement
15
+ // of an Issue already open reuses `exclusion_of` rather than a second list (joshuafolkken/kit#3854).
16
+ // The order: `--no-auto-ok`, a filing to another repository (whose own `backlogrun` would implement
17
+ // and merge it though nobody opted that repository in), a label that keeps a person in the loop, and
18
+ // last the body's judgement declaration (`JUDGEMENT_NEEDED` / `JUDGEMENT_NOT_NEEDED`): whether a
19
+ // policy decision, an external account or a human visual check is needed.
20
+
21
+ interface AutoOkFiling {
22
+ // `--no-auto-ok`: the filer declared the Issue needs a person's judgement.
23
23
  is_opted_out: boolean
24
- // `--requested`: a person asked for the filing, so neither the carry record nor the branch speaks.
25
- is_requested: boolean
26
- // The filing carries the `release` label.
27
- is_release: boolean
28
- is_carried: boolean
29
- // The labels of the Issue the current branch names; `undefined` when the branch names none or the
30
- // Issue could not be read.
31
- branch_labels: ReadonlyArray<string> | undefined
24
+ is_cross_repository: boolean
25
+ // Every label the filing carries, including the ones the body's declarations add.
26
+ labels: ReadonlyArray<string>
27
+ body: string
32
28
  }
33
29
 
34
30
  interface AutoOkDecision {
@@ -36,98 +32,71 @@ interface AutoOkDecision {
36
32
  reason: string
37
33
  }
38
34
 
35
+ const BACKGROUND_HEADING = '## 背景'
36
+ const JUDGEMENT_NOT_NEEDED = '- 人の判断: 不要'
37
+ const JUDGEMENT_NEEDED = '- 人の判断: 要'
38
+ const DECLARATION_PROBLEM = `declare under \`${BACKGROUND_HEADING}\` whether a person's judgement (a policy decision, an external account, a human visual check) is needed: \`${JUDGEMENT_NOT_NEEDED}\` or \`${JUDGEMENT_NEEDED}(<which one>)\` — exactly one (\`issue-template.md\`)`
39
+
39
40
  const OPTED_OUT: AutoOkDecision = { is_applied: false, reason: '--no-auto-ok declared' }
40
41
  const CROSS_REPOSITORY: AutoOkDecision = {
41
42
  is_applied: false,
42
43
  reason: 'the Issue is filed to another repository',
43
44
  }
44
- const RELEASE: AutoOkDecision = {
45
- is_applied: false,
46
- reason: `a ${RELEASE_LABEL} Issue is opted in by a person only`,
47
- }
48
- const REQUESTED: AutoOkDecision = {
45
+ const JUDGEMENT: AutoOkDecision = {
49
46
  is_applied: false,
50
- reason: 'a person asked for this filing; auto-ok only when they declare it',
47
+ reason: `the body declares \`${JUDGEMENT_NEEDED}\``,
51
48
  }
52
- const CARRIED: AutoOkDecision = { is_applied: true, reason: 'a backlogrun carry record is live' }
53
- const BRANCH_OPTED_IN: AutoOkDecision = {
49
+ const APPLIED: AutoOkDecision = {
54
50
  is_applied: true,
55
- reason: 'the Issue this branch works on carries auto-ok',
56
- }
57
- const NOT_OPTED_IN: AutoOkDecision = {
58
- is_applied: false,
59
- reason: 'no backlogrun carry record is live and the branch names no auto-ok Issue',
60
- }
61
-
62
- // What the filing itself declares: `--no-auto-ok` and `--requested`.
63
- type AutoOkDeclared = Pick<AutoOkSignals, 'is_opted_out' | 'is_requested'>
64
- type Withholding = AutoOkDeclared & Pick<AutoOkSignals, 'is_release'>
65
-
66
- // The three signals that withhold `auto-ok` before any other is read, or `undefined` when none holds.
67
- function withheld(signals: Withholding): AutoOkDecision | undefined {
68
- if (signals.is_opted_out) return OPTED_OUT
69
- if (signals.is_release) return RELEASE
70
- if (signals.is_requested) return REQUESTED
71
-
72
- return undefined
51
+ reason: 'no exclusion holds and the body declares no judgement is needed',
73
52
  }
74
53
 
75
- // The opt-out, the `release` label and `--requested` win over every signal, then the carry record,
76
- // then the branch's Issue.
77
- function decide(signals: AutoOkSignals): AutoOkDecision {
78
- const decision = withheld(signals)
54
+ // The labels that keep a person in the loop, each with the reason it withholds `auto-ok`. A
55
+ // `breaking-change` declared in the body arrives here as its label, added before the decision.
56
+ const EXCLUDED_LABELS: ReadonlyArray<readonly [string, string]> = [
57
+ [RELEASE_LABEL, 'a release is Tier C'],
58
+ [BREAKING_CHANGE_LABEL, 'a breaking change needs a person to accept the migration'],
59
+ [DEFERRED_LABEL, 'the Issue is deferred'],
60
+ [NEEDS_HUMAN_REVIEW_LABEL, 'a person reviews the change before it lands'],
61
+ [EPIC_LABEL, 'an epic is opted in by a person, and that opts in every descendant'],
62
+ ]
79
63
 
80
- if (decision !== undefined) return decision
81
- if (signals.is_carried) return CARRIED
82
- if (has_label_name(signals.branch_labels ?? [], AUTO_OK_LABEL)) return BRANCH_OPTED_IN
64
+ // The first label that withholds `auto-ok`, or `undefined` when none of them is present.
65
+ function exclusion_of(labels: ReadonlyArray<string>): AutoOkDecision | undefined {
66
+ const found = EXCLUDED_LABELS.find(([label]) => has_label_name(labels, label))
83
67
 
84
- return NOT_OPTED_IN
85
- }
68
+ if (found === undefined) return undefined
86
69
 
87
- function line_of(decision: AutoOkDecision): string {
88
- return `auto-ok: ${decision.is_applied ? 'applied' : 'not applied'} — ${decision.reason}`
70
+ return { is_applied: false, reason: `a \`${found[0]}\` Issue — ${found[1]}` }
89
71
  }
90
72
 
91
- // Read from the common git directory, so a lane sees the record its batch wrote in the main tree.
92
- async function is_carried(): Promise<boolean> {
93
- const directory = await run_carry.repository_directory()
73
+ // `true` / `false` for a declared need, `undefined` when the background declares neither or both.
74
+ function judgement_of(body: string): boolean | undefined {
75
+ const background = markdown_section.section_lines(body, BACKGROUND_HEADING).join('\n')
76
+ const lines = markdown_section.unfenced_lines(background).map((line) => line.trim())
77
+ const is_not_needed = lines.some((line) => line.startsWith(JUDGEMENT_NOT_NEEDED))
78
+ const is_needed = lines.some((line) => line.startsWith(JUDGEMENT_NEEDED))
94
79
 
95
- if (directory === undefined) return false
96
-
97
- return run_carry.read_carry(run_carry.carry_path(directory)).kind === 'carried'
80
+ return is_not_needed === is_needed ? undefined : is_needed
98
81
  }
99
82
 
100
- // An unreadable Issue is read as no labels: the filing goes on without `auto-ok` rather than failing.
101
- // The branch names an Issue of `current`, read there by name: a filing to another repository has
102
- // already pointed `GH_REPO` at its target, which would otherwise answer with the target's Issue.
103
- async function branch_labels(current: string): Promise<ReadonlyArray<string> | undefined> {
104
- const issue_number = git_branch.issue_from_branch(await git_command.branch())
83
+ // Why the filing is refused for its declaration, or `undefined` when it declares exactly one answer.
84
+ function declaration_problem(body: string): string | undefined {
85
+ return judgement_of(body) === undefined ? DECLARATION_PROBLEM : undefined
86
+ }
105
87
 
106
- if (issue_number === undefined) return undefined
107
- const read = await issue_state_cli.read_issue(String(issue_number), current)
88
+ function decide(filing: AutoOkFiling): AutoOkDecision {
89
+ if (filing.is_opted_out) return OPTED_OUT
90
+ if (filing.is_cross_repository) return CROSS_REPOSITORY
108
91
 
109
- return read.kind === 'state' ? read.state.labels : undefined
92
+ return exclusion_of(filing.labels) ?? (judgement_of(filing.body) === false ? APPLIED : JUDGEMENT)
110
93
  }
111
94
 
112
- // Each read is skipped once an earlier signal has already decided the answer; a cross-repository
113
- // filing is refused before any signal is read. `labels` are the ones the filing declares.
114
- async function resolve(
115
- declared: AutoOkDeclared,
116
- target: string,
117
- current: string,
118
- labels: ReadonlyArray<string> = [],
119
- ): Promise<AutoOkDecision> {
120
- const withholding = { ...declared, is_release: has_label_name(labels, RELEASE_LABEL) }
121
- const decision = withheld(withholding)
122
-
123
- if (decision !== undefined) return decision
124
- if (!issue_file.is_same_repository(target, current)) return CROSS_REPOSITORY
125
- if (await is_carried()) return CARRIED
126
-
127
- return decide({ ...withholding, is_carried: false, branch_labels: await branch_labels(current) })
95
+ function line_of(decision: AutoOkDecision): string {
96
+ return `auto-ok: ${decision.is_applied ? 'applied' : 'not applied'} — ${decision.reason}`
128
97
  }
129
98
 
130
- const issue_auto_ok = { decide, line_of, resolve }
99
+ const issue_auto_ok = { declaration_problem, decide, exclusion_of, line_of }
131
100
 
132
- export type { AutoOkDeclared, AutoOkDecision, AutoOkSignals }
101
+ export type { AutoOkDecision, AutoOkFiling }
133
102
  export { issue_auto_ok }
@@ -24,7 +24,7 @@ import { issue_scout_cli } from './issue-scout-cli'
24
24
  import { issue_wip } from './issue-wip'
25
25
 
26
26
  // `josh issue:file "<title>" --body-file <path> --depth <0|1|2> [--route <route>] [--label <name>]…
27
- // [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok] [--requested] [--release]` — file an Issue
27
+ // [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok] [--release]` — file an Issue
28
28
  // with every filing step run in order: the third-party refusal, the body
29
29
  // lint, the `## Origin` check for another repository, the `auto-ok` decision
30
30
  // with the run label it owes, the fold question against the run's earlier
@@ -37,7 +37,7 @@ const SUCCESS_EXIT_CODE = 0
37
37
  const FAILURE_EXIT_CODE = 1
38
38
  const ARGV_OFFSET = 2
39
39
  const USAGE =
40
- 'Usage: josh issue:file "<title>" --body-file <path> --depth <0|1|2> [--route <tier-a|split|interrupt|review-cap>] [--label <name>]… [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok] [--requested] [--release]'
40
+ 'Usage: josh issue:file "<title>" --body-file <path> --depth <0|1|2> [--route <tier-a|split|interrupt|review-cap>] [--label <name>]… [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok] [--release]'
41
41
  const UNKNOWN_REPO_MESSAGE =
42
42
  'Could not read this repository from `git remote`, so the filing has no repository to compare against — check `gh auth status`.'
43
43
  const THIRD_PARTY_MESSAGE =
@@ -74,10 +74,13 @@ function is_third_party(target: string): boolean {
74
74
  // the filing may go on to the scout.
75
75
  function refusals_of(filing: Filing): ReadonlyArray<string> {
76
76
  if (is_third_party(filing.target)) return [THIRD_PARTY_MESSAGE]
77
- const problems = issue_lint_cli.problems(filing.body).map((problem) => `✖ ${problem}`)
78
- const origin = issue_file.origin_problem(filing.body, filing.target, filing.current)
77
+ const problems = [
78
+ ...issue_lint_cli.problems(filing.body),
79
+ issue_auto_ok.declaration_problem(filing.body),
80
+ issue_file.origin_problem(filing.body, filing.target, filing.current),
81
+ ]
79
82
 
80
- return origin === undefined ? problems : [...problems, `✖ ${origin}`]
83
+ return problems.filter((problem) => problem !== undefined).map((problem) => `✖ ${problem}`)
81
84
  }
82
85
 
83
86
  function unacknowledged_message(numbers: ReadonlyArray<number>): string {
@@ -143,18 +146,15 @@ async function is_scout_clear(filing: Filing): Promise<boolean> {
143
146
  }
144
147
 
145
148
  // Every label the create call carries, or `undefined` when an `auto-ok` filing names neither run label.
146
- // Decided before the WIP count and the scout, so a refusal costs no listing.
147
- async function labels_of(filing: Filing): Promise<ReadonlyArray<string> | undefined> {
148
- const declared = {
149
+ // Decided before the WIP count and the scout, so a refusal costs no listing. The decision reads the
150
+ // labels the body's declarations add, so a declared breaking change is excluded like its label.
151
+ function labels_of(filing: Filing): ReadonlyArray<string> | undefined {
152
+ const auto_ok = issue_auto_ok.decide({
149
153
  is_opted_out: filing.args.is_auto_ok_opted_out,
150
- is_requested: filing.args.is_requested,
151
- }
152
- const auto_ok = await issue_auto_ok.resolve(
153
- declared,
154
- filing.target,
155
- filing.current,
156
- filing.args.labels,
157
- )
154
+ is_cross_repository: !issue_file.is_same_repository(filing.target, filing.current),
155
+ labels: issue_file.labels_of(filing.args, filing.body),
156
+ body: filing.body,
157
+ })
158
158
 
159
159
  console.info(issue_auto_ok.line_of(auto_ok))
160
160
  const labels = issue_file.labels_of(filing.args, filing.body, auto_ok.is_applied)
@@ -271,7 +271,7 @@ async function is_clear(filing: Filing): Promise<boolean> {
271
271
 
272
272
  async function file(filing: Filing): Promise<number> {
273
273
  if (!is_admitted(filing)) return FAILURE_EXIT_CODE
274
- const labels = await labels_of(filing)
274
+ const labels = labels_of(filing)
275
275
 
276
276
  if (labels === undefined || !(await is_clear(filing))) return FAILURE_EXIT_CODE
277
277
 
@@ -41,9 +41,6 @@ interface FileArguments {
41
41
  is_over_cap: boolean
42
42
  // `--no-auto-ok`: the Issue needs a person's judgement, so `auto-ok` is not applied (`issue-auto-ok.ts`).
43
43
  is_auto_ok_opted_out: boolean
44
- // `--requested`: a person asked for this filing, so `auto-ok` is applied only when a `--label` names
45
- // it (`issue-auto-ok.ts`).
46
- is_requested: boolean
47
44
  // `--release`: the change reaches a consumer only once published, so the Issue is linked to the
48
45
  // repository's release Issue as a blocker (`issue-release-cli.ts`).
49
46
  is_release: boolean
@@ -58,7 +55,6 @@ const OPTIONS = {
58
55
  distinct: { type: 'string', multiple: true },
59
56
  'over-cap': { type: 'boolean' },
60
57
  'no-auto-ok': { type: 'boolean' },
61
- requested: { type: 'boolean' },
62
58
  release: { type: 'boolean' },
63
59
  } as const
64
60
 
@@ -129,7 +125,6 @@ function arguments_of(values: ParsedValues, title: string | undefined): FileArgu
129
125
  repo: values.repo,
130
126
  is_over_cap: values['over-cap'] === true,
131
127
  is_auto_ok_opted_out: values['no-auto-ok'] === true,
132
- is_requested: values.requested === true,
133
128
  is_release: values.release === true,
134
129
  }
135
130
  }
@@ -151,7 +146,7 @@ function is_undeclared_classification(label: string, body: string): boolean {
151
146
 
152
147
  // Every label the filing carries: the depth, the route when there is one, the classification labels
153
148
  // the body declares (`issue_classification.labels_for`, which merges case-insensitively), then any
154
- // extra `--label`, then `auto-ok` when the filing opted in (`issue-auto-ok.ts`) and no `--label`
149
+ // extra `--label`, then `auto-ok` when the filing is judged in (`issue-auto-ok.ts`) and no `--label`
155
150
  // already named it. One request applies them all, so no label is a separate step to forget.
156
151
  function labels_of(args: FileArguments, body: string, is_auto_ok = false): ReadonlyArray<string> {
157
152
  const extra = args.labels.filter((label) => !is_undeclared_classification(label, body))
@@ -67,9 +67,12 @@ const ALREADY_DONE_LABEL = 'already-done'
67
67
  // Marks the Issue that tracks a repository's next release. An Issue whose
68
68
  // change reaches a consumer only once published is linked to it as a `blocked_by` blocker
69
69
  // (`josh issue:release`), so "merged but not yet released" is visible in the backlog. A run applies
70
- // it; **`auto-ok` never rides on it** — `issue-auto-ok.ts` refuses the default for it on every path,
71
- // since a release is Tier C — and only a person opts it into unattended execution.
70
+ // it; **`auto-ok` never rides on it** — `issue-auto-ok.ts` excludes it, since a release is Tier C —
71
+ // and only a person opts it into unattended execution.
72
72
  const RELEASE_LABEL = 'release'
73
+ // A person's mark for work set aside for now; `issue-auto-ok.ts` reads it as an exclusion. Applied by
74
+ // hand and never provisioned, so it is not in `REPOSITORY_LABELS`.
75
+ const DEFERRED_LABEL = 'deferred'
73
76
  // Applied to an Issue whose second review round was skipped, so the condition stays auditable
74
77
  // (`prompts/review.md` → "When round 2 is skipped entirely, and when it is not").
75
78
  const REVIEW_ROUND2_SKIPPED_LABEL = 'review-round2-skipped'
@@ -402,6 +405,7 @@ export {
402
405
  BUG_LABEL,
403
406
  BUGFIX_LABEL,
404
407
  BREAKING_CHANGE_LABEL,
408
+ DEFERRED_LABEL,
405
409
  depth_label_of,
406
410
  DEPTH_0_LABEL,
407
411
  DEPTH_1_LABEL,