@joshuafolkken/kit 1.983.0 → 1.1003.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 (102) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-child.md +4 -5
  2. package/.claude/skills/workflow-commands/backlogrun-steps.md +1 -1
  3. package/.github/workflows/sonar-qube.yml +12 -2
  4. package/CLAUDE.md +2 -2
  5. package/README.md +30 -21
  6. package/dist/CLAUDE.md +2 -2
  7. package/dist/hooks/batch-guard.js +4 -4
  8. package/dist/hooks/{chunk-ZT65LR3U.js → chunk-4GDR2YTV.js} +4 -4
  9. package/dist/hooks/{chunk-ASUZGZFT.js → chunk-56LGMJJV.js} +2 -2
  10. package/dist/hooks/{chunk-LBXQHXMC.js → chunk-6PSTVOLH.js} +263 -74
  11. package/dist/hooks/{chunk-P3UX7SFN.js → chunk-A2H5I2VD.js} +2 -2
  12. package/dist/hooks/{chunk-ERXMJJOV.js → chunk-AWTFKKFW.js} +1 -1
  13. package/dist/hooks/{chunk-IM3KM25Y.js → chunk-EZLHXKJ6.js} +10 -7
  14. package/dist/hooks/{chunk-APVOACVT.js → chunk-GBJUERYK.js} +1 -1
  15. package/dist/hooks/{chunk-5JX6HXDX.js → chunk-KPZN42RT.js} +3 -3
  16. package/dist/hooks/{chunk-47SPMGOD.js → chunk-PAXMLGSS.js} +26 -7
  17. package/dist/hooks/{chunk-RFPHKU7C.js → chunk-WMIR5SNX.js} +3 -3
  18. package/dist/hooks/codex-hook-adapter.js +10 -10
  19. package/dist/hooks/duplicate-read-guard.js +5 -5
  20. package/dist/hooks/format-edited.js +2 -2
  21. package/dist/hooks/investigation-guard.js +5 -5
  22. package/dist/hooks/pretool-guard.js +9 -9
  23. package/dist/hooks/stop-guard.js +98 -26
  24. package/dist/josh.js +5 -5
  25. package/package.json +5 -1
  26. package/prettier/basic.d.ts +3 -0
  27. package/prettier/basic.js +10 -0
  28. package/prettier/index.js +2 -2
  29. package/prettier/static.js +3 -10
  30. package/prompts/sonar-hotspot-handling.md +6 -0
  31. package/prompts/testing-guide.md +1 -1
  32. package/scripts/agent/agent-diagnostics.ts +1 -0
  33. package/scripts/agent/agent-role-profile.ts +1 -1
  34. package/scripts/behavior/behavior-rules.ts +8 -1
  35. package/scripts/build/build-claude-md.ts +13 -4
  36. package/scripts/ci/pr-classification.ts +11 -2
  37. package/scripts/document/document-byte-budget.ts +1 -1
  38. package/scripts/gate/gate-steps.ts +1 -1
  39. package/scripts/gate/project-checks.ts +12 -11
  40. package/scripts/gate/type-check-command.ts +1 -1
  41. package/scripts/git/git-branch.ts +14 -3
  42. package/scripts/git/git-command.ts +5 -2
  43. package/scripts/git/git-issue.ts +13 -5
  44. package/scripts/git/git-pr.ts +16 -10
  45. package/scripts/git/git-preflight.ts +138 -0
  46. package/scripts/git/telegram-notify.ts +21 -0
  47. package/scripts/hooks/stop-guard.ts +2 -0
  48. package/scripts/init/basic-path-migration.ts +24 -0
  49. package/scripts/init/distributed-paths.ts +22 -0
  50. package/scripts/init/init-actions.ts +27 -19
  51. package/scripts/init/init-ai-copy.ts +86 -28
  52. package/scripts/init/{init-static.ts → init-basic.ts} +6 -6
  53. package/scripts/init/init-bootstrap.ts +4 -3
  54. package/scripts/init/init-install.ts +1 -1
  55. package/scripts/init/init-logic-templates.ts +1 -1
  56. package/scripts/init/init-logic.ts +1 -1
  57. package/scripts/init/init.ts +41 -19
  58. package/scripts/init/kit-setup-state.ts +30 -0
  59. package/scripts/init/kit-written-paths.ts +33 -0
  60. package/scripts/init/project-profile-cli.ts +15 -1
  61. package/scripts/init/project-profile.ts +41 -12
  62. package/scripts/init/session-language-environment.ts +83 -0
  63. package/scripts/init/start-exec.ts +28 -0
  64. package/scripts/init/start-plan.ts +35 -13
  65. package/scripts/init/start-prompt.ts +22 -8
  66. package/scripts/init/start-setup-pr.ts +138 -0
  67. package/scripts/init/start-steps.ts +30 -30
  68. package/scripts/init/start.ts +7 -3
  69. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +3 -3
  70. package/scripts/josh/josh-commands-development.ts +3 -3
  71. package/scripts/josh/josh-commands-project.ts +2 -2
  72. package/scripts/lane/lane-leftover.ts +143 -0
  73. package/scripts/lane/lane-open.ts +14 -1
  74. package/scripts/lint/cspell-cached.ts +3 -3
  75. package/scripts/lint/format.ts +1 -1
  76. package/scripts/lint/lint-parallel.ts +2 -2
  77. package/scripts/propagate/propagate-steps.ts +14 -1
  78. package/scripts/rules/filing-cap.ts +1 -0
  79. package/scripts/rules/issue-citation.ts +30 -1
  80. package/scripts/rules/last-prompt.ts +79 -0
  81. package/scripts/rules/stop-rules.ts +5 -3
  82. package/scripts/run/run-hold.ts +3 -3
  83. package/scripts/run/run-preflight.ts +75 -5
  84. package/scripts/run/run-progress-cli.ts +14 -2
  85. package/scripts/safe-chain/ci-installer-pin.ts +19 -12
  86. package/scripts/safe-chain/project-config.ts +1 -1
  87. package/scripts/sync/sync-ai-files.ts +204 -0
  88. package/scripts/sync/sync.ts +48 -184
  89. package/scripts/test/josh-harness-environment.ts +1 -1
  90. package/scripts/test/pilot-files.ts +0 -1
  91. package/scripts/test/test-declared-changed.ts +1 -1
  92. package/scripts/test/test-declared-logic.ts +55 -14
  93. package/scripts/test/test-declared.ts +23 -11
  94. package/scripts/test/test-unit-guard.ts +6 -6
  95. package/scripts/time-runtime/time-shell.ts +36 -2
  96. package/scripts-ai/git-workflow.ts +10 -0
  97. package/templates/CLAUDE.basic.md +8 -0
  98. package/templates/workflows/ci.yml +27 -0
  99. /package/{templates/CLAUDE.static.md → dist/CLAUDE.basic.md} +0 -0
  100. /package/templates/{cursorrules.static → cursorrules.basic} +0 -0
  101. /package/templates/{pnpm-workspace.static.yaml → pnpm-workspace.basic.yaml} +0 -0
  102. /package/templates/{prettierignore.static → prettierignore.basic} +0 -0
@@ -1,12 +1,13 @@
1
1
  import path from 'node:path'
2
- import { git_gh_exec } from '#scripts/git/git-gh-exec'
3
2
  import { repository_labels } from '#scripts/repo/repository-labels'
4
3
  import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
5
- import { execaSync } from 'execa'
6
4
  import { main as init_main } from './init'
7
5
  import { PACKAGE_DIR } from './init-paths'
6
+ import { kit_setup_state } from './kit-setup-state'
8
7
  import type { ProjectProfile, ProjectShape } from './project-profile'
8
+ import { start_exec } from './start-exec'
9
9
  import { start_plan, type GitState, type StartStep, type Visibility } from './start-plan'
10
+ import { start_setup_pr } from './start-setup-pr'
10
11
 
11
12
  interface StepContext {
12
13
  root: string
@@ -20,38 +21,28 @@ const GH_INSTALL_HINT =
20
21
  const GH_AUTH_HINT =
21
22
  'GitHub CLI is not signed in. Run gh auth login, then run josh start again. Nothing was changed.'
22
23
 
23
- function succeeds(command: string, args: ReadonlyArray<string>, root: string): boolean {
24
- return execaSync(command, args, { cwd: root, reject: false }).exitCode === 0
25
- }
26
-
27
- function read_output(
28
- command: string,
29
- args: ReadonlyArray<string>,
30
- root: string,
31
- ): string | undefined {
32
- const result = execaSync(command, args, { cwd: root, reject: false })
33
-
34
- return result.exitCode === 0 ? result.stdout.trim() : undefined
35
- }
36
-
37
- // Every step spawned here dials GitHub directly, past a scanner's loopback proxy, the same as every
38
- // other `gh` spawn under scripts/ (joshuafolkken/kit#2436); a local `git` step is unaffected by it.
39
- function run(command: string, args: ReadonlyArray<string>, root: string): void {
40
- execaSync(command, args, { ...git_gh_exec.direct_environment(), cwd: root, stdio: 'inherit' })
41
- }
24
+ const { succeeds, read_output, run } = start_exec
42
25
 
43
26
  function read_git_state(root: string, shape: ProjectShape): GitState {
44
27
  const { has_git, has_github } = shape
45
28
 
46
29
  if (!has_git) {
47
- return { has_git, has_github, has_origin: false, branch: undefined, has_commits: false }
30
+ return {
31
+ has_git,
32
+ has_github,
33
+ has_origin: false,
34
+ branch: undefined,
35
+ has_commits: false,
36
+ has_kit_committed: false,
37
+ }
48
38
  }
49
39
 
50
40
  const has_origin = succeeds('git', ['remote', 'get-url', 'origin'], root)
51
41
  const branch = read_output('git', ['symbolic-ref', '--short', 'HEAD'], root)
52
42
  const has_commits = succeeds('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], root)
43
+ const has_kit_committed = kit_setup_state.is_kit_committed(root)
53
44
 
54
- return { has_git, has_github, has_origin, branch, has_commits }
45
+ return { has_git, has_github, has_origin, branch, has_commits, has_kit_committed }
55
46
  }
56
47
 
57
48
  // Every step talks to GitHub, so the CLI is checked before the first write rather than at the step
@@ -82,7 +73,7 @@ function initialize(context: StepContext): void {
82
73
  function commit_all(context: StepContext): void {
83
74
  run('git', ['add', '--all'], context.root)
84
75
  // The first commit of a new repository has no branch to come from, so the hook that keeps commits
85
- // off main — installed by the initialize step in a node project — would refuse the only commit
76
+ // off main — installed by the initialize step in a full project — would refuse the only commit
86
77
  // that has to land there.
87
78
  run('git', ['commit', '--no-verify', '--message', 'Initial commit'], context.root)
88
79
  // A repository created by an older `git init` may name its unborn branch `master`.
@@ -103,21 +94,30 @@ function add_missing_labels(): void {
103
94
  repository_labels.ensure_labels('{owner}/{repo}')
104
95
  }
105
96
 
106
- const STEP_ACTIONS: Readonly<Record<StartStep, (context: StepContext) => void>> = {
97
+ // Main already has a history here, so the setup reaches it the way every other change does: through
98
+ // an Issue and a pull request a person merges (joshuafolkken/kit#2816).
99
+ async function open_setup_pull_request(context: StepContext): Promise<void> {
100
+ await start_setup_pr.open(context.root)
101
+ }
102
+
103
+ type StepAction = (context: StepContext) => void | Promise<void>
104
+
105
+ const STEP_ACTIONS: Readonly<Record<StartStep, StepAction>> = {
107
106
  git_init: create_git_repository,
108
107
  initialize,
109
108
  commit: commit_all,
110
109
  repository: create_github_repository,
111
110
  labels: add_missing_labels,
111
+ setup_pr: open_setup_pull_request,
112
112
  }
113
113
 
114
- function run_step(
114
+ async function run_step(
115
115
  step: StartStep,
116
116
  context: StepContext,
117
117
  completed: ReadonlyArray<StartStep>,
118
- ): void {
118
+ ): Promise<void> {
119
119
  try {
120
- STEP_ACTIONS[step](context)
120
+ await STEP_ACTIONS[step](context)
121
121
  } catch (error) {
122
122
  const cause = error instanceof Error ? error.message : String(error)
123
123
 
@@ -125,12 +125,12 @@ function run_step(
125
125
  }
126
126
  }
127
127
 
128
- function run_steps(steps: ReadonlyArray<StartStep>, context: StepContext): void {
128
+ async function run_steps(steps: ReadonlyArray<StartStep>, context: StepContext): Promise<void> {
129
129
  for (const [index, step] of steps.entries()) {
130
130
  const position = `${String(index + 1)}/${String(steps.length)}`
131
131
 
132
132
  console.info(`\n[${position}] ${start_plan.STEP_LABELS[step]}`)
133
- run_step(step, context, steps.slice(0, index))
133
+ await run_step(step, context, steps.slice(0, index))
134
134
  }
135
135
  }
136
136
 
@@ -13,7 +13,7 @@ import { start_steps } from './start-steps'
13
13
 
14
14
  const ARGUMENT_START_INDEX = 2
15
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]'
16
+ const START_USAGE = 'josh start [--profile basic|full] [--yes] [--github] [--public]'
17
17
  const YES_FLAG = '--yes'
18
18
  const GITHUB_FLAG = '--github'
19
19
  const PUBLIC_FLAG = '--public'
@@ -72,7 +72,11 @@ function prepare(args: ReadonlyArray<string>, is_tty: boolean, root: string): Pr
72
72
 
73
73
  stop_on(start_steps.github_cli_refusal(root))
74
74
 
75
- return { steps: plan.steps, interaction, choices: { options, shape, consent, root } }
75
+ return {
76
+ steps: plan.steps,
77
+ interaction,
78
+ choices: { options, shape, consent, root, steps: plan.steps },
79
+ }
76
80
  }
77
81
 
78
82
  async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void> {
@@ -82,7 +86,7 @@ async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void>
82
86
  const profile = await resolve_profile(choices, interaction)
83
87
  const { root, options } = choices
84
88
 
85
- start_steps.run_steps(steps, { root, profile, visibility: options.visibility })
89
+ await start_steps.run_steps(steps, { root, profile, visibility: options.visibility })
86
90
  console.info(`\n${READY_MESSAGE}`)
87
91
  }
88
92
 
@@ -450,7 +450,7 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
450
450
  ],
451
451
  ],
452
452
  "init": [
453
- "[--profile static|node] [--no-install]",
453
+ "[--profile basic|full] [--no-install]",
454
454
  "developer",
455
455
  [
456
456
  "files",
@@ -766,7 +766,7 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
766
766
  ],
767
767
  ],
768
768
  "profile": [
769
- "[--profile static|node]",
769
+ "[--profile basic|full]",
770
770
  "developer",
771
771
  [
772
772
  "files",
@@ -1112,7 +1112,7 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
1112
1112
  ],
1113
1113
  ],
1114
1114
  "start": [
1115
- "[--profile static|node] [--yes] [--github] [--public]",
1115
+ "[--profile basic|full] [--yes] [--github] [--public]",
1116
1116
  "developer",
1117
1117
  [
1118
1118
  "files",
@@ -22,7 +22,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
22
22
  },
23
23
  lint: {
24
24
  script: 'scripts/lint/lint-parallel.ts',
25
- description: 'Check code with prettier and eslint (skips a tool a static project lacks)',
25
+ description: 'Check code with prettier and eslint (skips a tool a basic project lacks)',
26
26
  category: 'Development',
27
27
  reference: ['', 'developer', ['processes']],
28
28
  },
@@ -61,7 +61,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
61
61
  },
62
62
  format: {
63
63
  script: 'scripts/lint/format.ts',
64
- description: 'Format code with prettier and eslint (skips a tool a static project lacks)',
64
+ description: 'Format code with prettier and eslint (skips a tool a basic project lacks)',
65
65
  category: 'Development',
66
66
  reference: ['', 'developer', ['files', 'processes']],
67
67
  },
@@ -183,7 +183,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
183
183
  },
184
184
  check: {
185
185
  script: 'scripts/gate/type-check-command.ts',
186
- description: 'Type-check with tsc (skips a static project with no TypeScript to check)',
186
+ description: 'Type-check with tsc (skips a basic project with no TypeScript to check)',
187
187
  category: 'Development',
188
188
  reference: ['[arguments...]', 'developer', ['processes']],
189
189
  },
@@ -4,7 +4,7 @@ import type { CommandEntry } from './josh-command-types'
4
4
  // no identifier format allows. The same disable sits at the top of `josh-commands-maintenance.ts`
5
5
  // for the same reason.
6
6
  /* eslint-disable @typescript-eslint/naming-convention */
7
- const PROFILE_ARGUMENTS = '[--profile static|node]'
7
+ const PROFILE_ARGUMENTS = '[--profile basic|full]'
8
8
  const PROJECT_COMMANDS: Record<string, CommandEntry> = {
9
9
  profile: {
10
10
  script: 'scripts/init/project-profile-cli.ts',
@@ -14,7 +14,7 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
14
14
  },
15
15
  start: {
16
16
  script: 'scripts/init/start.ts',
17
- description: 'Create a new project for the GitHub Issue workflow, from git init to labels',
17
+ description: 'Set a project up for the GitHub Issue workflow, from git init to the setup PR',
18
18
  category: 'Project',
19
19
  reference: [
20
20
  `${PROFILE_ARGUMENTS} [--yes] [--github] [--public]`,
@@ -0,0 +1,143 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, rmSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { git_spawn } from '#scripts/git/git-spawn'
5
+
6
+ // A lane directory git no longer registers as a work tree (joshuafolkken/kit#2857).
7
+ //
8
+ // **Nothing else can see it.** `lane:list` and `lane:prune` read `git worktree list`, so a directory
9
+ // whose registration is gone is invisible to both, and `git worktree add` refuses its path for good —
10
+ // the lane for that issue cannot be opened again until someone deletes it by hand. `lane:open` asks
11
+ // here before it adds the tree.
12
+ //
13
+ // **The test for removing it is that removing it loses nothing**, not a list of names. Every file in
14
+ // it has to be one the repository already holds with the same content, so it is a checkout git would
15
+ // write again; the one exception is the `.git` file, a pointer to the registration that is gone. A
16
+ // list of generated names would break the next time a file is generated, and would delete whatever
17
+ // shares one of those names.
18
+
19
+ // `git ls-tree -r -z`: `<mode> <type> <object>\t<path>`, entries separated by NUL — the `-z` form
20
+ // leaves a path with an unusual character unquoted, so it can be compared to the path on disk.
21
+ const TREE_ENTRY_PATTERN = /^\d+ blob (?<object>[\da-f]+)\t(?<path>.+)$/su
22
+ const GIT_POINTER = '.git'
23
+ const GITDIR_PREFIX = 'gitdir: '
24
+ // The foreign paths a refusal names before it summarizes the rest as a count.
25
+ const NAMED_PATH_LIMIT = 10
26
+
27
+ // One blob entry as `[path, object id]`; a tree, a commit (a submodule) or the trailing empty entry is
28
+ // none.
29
+ function tree_entry(entry: string): Array<[string, string]> {
30
+ const { path: entry_path, object } = TREE_ENTRY_PATTERN.exec(entry)?.groups ?? {}
31
+
32
+ if (entry_path === undefined || object === undefined) return []
33
+
34
+ return [[entry_path, object]]
35
+ }
36
+
37
+ function parse_tree(listing: string): Map<string, string> {
38
+ return new Map(listing.split('\0').flatMap((entry) => tree_entry(entry)))
39
+ }
40
+
41
+ // The object id git gives this file — a symbolic link's is that of its target string, as git stores it.
42
+ function blob_id(file: string): string {
43
+ const content = lstatSync(file).isSymbolicLink()
44
+ ? Buffer.from(readlinkSync(file))
45
+ : readFileSync(file)
46
+
47
+ // SHA-1 because it is git's object id, compared rather than trusted — a repository on the SHA-256
48
+ // object format matches nothing here, so its leftovers are refused rather than removed.
49
+ // eslint-disable-next-line sonarjs/hashing -- git's object id, not a security digest
50
+ return createHash('sha1')
51
+ .update(`blob ${String(content.length)}\0`)
52
+ .update(content)
53
+ .digest('hex')
54
+ }
55
+
56
+ // Walked one level at a time because `readdirSync`'s `recursive` option descends into a symbolic link
57
+ // to a directory, which would list the repository's own files under the link's path as foreign.
58
+ function walk_files(directory: string, relative: string): Array<string> {
59
+ return readdirSync(path.join(directory, relative), { withFileTypes: true }).flatMap((entry) => {
60
+ const child = relative === '' ? entry.name : `${relative}/${entry.name}`
61
+
62
+ return entry.isDirectory() ? walk_files(directory, child) : [child]
63
+ })
64
+ }
65
+
66
+ // Every file under `directory` as a repository path, symbolic links included and never followed.
67
+ function list_files(directory: string): Array<string> {
68
+ return walk_files(directory, '')
69
+ }
70
+
71
+ // The `.git` file is disposable only while the registration it points to is gone: one that still
72
+ // resolves is a live work tree — a `lane:open` of the same issue may be checking it out right now.
73
+ function is_dangling_pointer(directory: string): boolean {
74
+ const file = path.join(directory, GIT_POINTER)
75
+
76
+ if (!lstatSync(file).isFile()) return false
77
+
78
+ const pointer = readFileSync(file, 'utf8').trim()
79
+
80
+ if (!pointer.startsWith(GITDIR_PREFIX)) return false
81
+
82
+ return !existsSync(path.resolve(directory, pointer.slice(GITDIR_PREFIX.length)))
83
+ }
84
+
85
+ function is_reclaimable_file(
86
+ directory: string,
87
+ relative: string,
88
+ tracked: ReadonlyMap<string, string>,
89
+ ): boolean {
90
+ if (relative === GIT_POINTER) return is_dangling_pointer(directory)
91
+
92
+ return tracked.get(relative) === blob_id(path.join(directory, relative))
93
+ }
94
+
95
+ /**
96
+ * The paths under `directory` whose removal would lose something — empty when it holds nothing but
97
+ * the repository's own files and the `.git` pointer. A `.git` directory is a repository of its own,
98
+ * so it is foreign as a whole rather than walked.
99
+ */
100
+ function foreign_paths(directory: string, tracked: ReadonlyMap<string, string>): Array<string> {
101
+ const pointer = lstatSync(path.join(directory, GIT_POINTER), { throwIfNoEntry: false })
102
+
103
+ if (pointer?.isDirectory() === true) return [GIT_POINTER]
104
+
105
+ return list_files(directory).filter(
106
+ (relative) => !is_reclaimable_file(directory, relative, tracked),
107
+ )
108
+ }
109
+
110
+ function refusal(directory: string, foreign: ReadonlyArray<string>): Error {
111
+ const named = foreign.slice(0, NAMED_PATH_LIMIT).join(', ')
112
+ const rest = foreign.length - NAMED_PATH_LIMIT
113
+ const more = rest > 0 ? ` and ${String(rest)} more` : ''
114
+
115
+ return new Error(
116
+ `${directory} already exists but git does not register it as a work tree, and it holds files the repository does not: ${named}${more}. It was left in place so nothing is lost — move those files out, delete the directory, and open the lane again.`,
117
+ )
118
+ }
119
+
120
+ /**
121
+ * Clear the way for `git worktree add` at `directory`, which the caller has found unregistered.
122
+ * Absent is nothing to do; holding only the repository's own files, it is removed; anything else is
123
+ * refused with the paths that would have been lost.
124
+ */
125
+ async function reclaim(directory: string): Promise<void> {
126
+ if (!existsSync(directory)) return
127
+
128
+ const tracked = parse_tree(await git_spawn.read(['ls-tree', '-r', '-z', '--full-tree', 'HEAD']))
129
+ const foreign = foreign_paths(directory, tracked)
130
+
131
+ if (foreign.length > 0) throw refusal(directory, foreign)
132
+
133
+ rmSync(directory, { force: true, recursive: true })
134
+ }
135
+
136
+ const lane_leftover = {
137
+ blob_id,
138
+ foreign_paths,
139
+ parse_tree,
140
+ reclaim,
141
+ }
142
+
143
+ export { lane_leftover }
@@ -5,6 +5,7 @@ import { git_worktree } from '#scripts/git/git-worktree'
5
5
  import { lane_cache } from './lane-cache'
6
6
  import { lane_environment } from './lane-environment'
7
7
  import { lane_install, type InstallResult } from './lane-install'
8
+ import { lane_leftover } from './lane-leftover'
8
9
  import { lane_paths } from './lane-paths'
9
10
  import { lane_registry, type LaneInfo } from './lane-registry'
10
11
  import { lane_seed_policy } from './lane-seed'
@@ -234,6 +235,18 @@ async function materialize(plan: LanePlan, source_root: string): Promise<void> {
234
235
  }
235
236
  }
236
237
 
238
+ // Both refusals run before the seat is claimed, so neither leaves a lock behind. A directory at the
239
+ // lane's path that git does not register is `lane_leftover`'s to remove or refuse
240
+ // (joshuafolkken/kit#2857).
241
+ async function clear_the_way(
242
+ root: string,
243
+ issue: string,
244
+ lanes: ReadonlyArray<LaneInfo>,
245
+ ): Promise<void> {
246
+ guard_unreadable(root, lanes)
247
+ await lane_leftover.reclaim(lane_paths.lane_directory(root, issue))
248
+ }
249
+
237
250
  /**
238
251
  * Open the lane for `issue`, or say why it was not opened.
239
252
  *
@@ -253,7 +266,7 @@ async function open_lane(issue: string): Promise<OpenOutcome> {
253
266
 
254
267
  const root = lane_paths.lane_root(repository_root)
255
268
 
256
- guard_unreadable(root, lanes)
269
+ await clear_the_way(root, issue, lanes)
257
270
 
258
271
  const plan = build_plan(repository_root, root, issue, lanes)
259
272
 
@@ -23,8 +23,8 @@ const CSPELL_ARGS = [
23
23
 
24
24
  // The wrapper puts cache transfer directly around cspell rather than around the whole gate (#2060),
25
25
  // so a later failing check cannot prevent a completed cache from reaching the primary checkout.
26
- function static_skip_reason(directory: string): string | undefined {
27
- if (!project_checks.is_static(directory)) return undefined
26
+ function basic_skip_reason(directory: string): string | undefined {
27
+ if (!project_checks.is_basic(directory)) return undefined
28
28
 
29
29
  if (!project_checks.has_config(directory, project_checks.CSPELL_CONFIGS)) {
30
30
  return 'no cspell configuration was found'
@@ -36,7 +36,7 @@ function static_skip_reason(directory: string): string | undefined {
36
36
  }
37
37
 
38
38
  async function run(extra_arguments: ReadonlyArray<string> = []): Promise<number> {
39
- const reason = static_skip_reason(process.cwd())
39
+ const reason = basic_skip_reason(process.cwd())
40
40
 
41
41
  if (reason !== undefined) {
42
42
  console.info(project_checks.skip_notice('cspell', reason))
@@ -19,7 +19,7 @@ async function run_step(args: ReadonlyArray<string>, directory: string): Promise
19
19
 
20
20
  // prettier first, deliberately: `eslint --fix` exits 1 whenever a non-autofixable error remains, so
21
21
  // putting it first would mean one unused variable anywhere in the tree stops prettier from running.
22
- // A static project without Prettier or ESLint is skipped with the same reason `josh lint` prints
22
+ // A basic project without Prettier or ESLint is skipped with the same reason `josh lint` prints
23
23
  // (joshuafolkken/kit#2693, joshuafolkken/kit#2606).
24
24
  async function run_unless_skipped(
25
25
  check: string,
@@ -64,8 +64,8 @@ function lint_exit_code(prettier: BufferedProcessResult, eslint: BufferedProcess
64
64
  // The two checks are run from here whether they were pointed at the whole tree or at one change's
65
65
  // files, so `josh lint:related` reaches prettier and eslint through the same buffering, the same
66
66
  // "one failure does not abort the other" reading, and the same exit code (joshuafolkken/kit#1298) —
67
- // and through the same static-project skip, so a narrowed run never reaches a tool the whole-tree
68
- // run would have skipped (joshuafolkken/kit#2709). The skip reasons answer nothing for a node
67
+ // and through the same basic-project skip, so a narrowed run never reaches a tool the whole-tree
68
+ // run would have skipped (joshuafolkken/kit#2709). The skip reasons answer nothing for a full
69
69
  // project, so the one path serves both profiles.
70
70
  async function run_lint_checks(
71
71
  prettier_args: ReadonlyArray<string>,
@@ -1,5 +1,7 @@
1
+ import { pr_classification } from '#scripts/ci/pr-classification'
1
2
  import { git_gh_exec } from '#scripts/git/git-gh-exec'
2
3
  import { git_gh_issue_write } from '#scripts/git/git-gh-issue-write'
4
+ import { OTHER_CHANGE_LABEL } from '#scripts/git/issue-labels'
3
5
  import { GATE_COMMAND } from '#scripts/josh/josh-command-types'
4
6
  import { build_upgrade_shell_command } from '#scripts/version/upgrade-shell-command'
5
7
  import { create_version_command_config } from '#scripts/version/version-command-config'
@@ -62,6 +64,10 @@ const PROPAGATE_ORIGIN =
62
64
  'Opened by `josh propagate` from the supplier repository after the release was published.'
63
65
  const RUN_NOTE =
64
66
  'The upgrade, the managed-file sync and the verification gate have already run here.'
67
+ // `josh git` refuses to open a pull request for an issue that declares no release classification
68
+ // (joshuafolkken/kit#2666), so the upgrade issue declares one. A dependency upgrade changes no code of
69
+ // the consumer's own, which is what `other-change` covers (joshuafolkken/kit#2858).
70
+ const UPGRADE_CLASSIFICATION = OTHER_CHANGE_LABEL
65
71
  const EMPTY_PLAN_TITLE = 'Upgrade the installed toolkits'
66
72
  const LAST_SEPARATOR = ' and '
67
73
  const NAME_SEPARATOR = ', '
@@ -95,7 +101,14 @@ function plan_body(plan: ReleasePlan): string {
95
101
  .map((release) => `\`${release.package_name}@${release.version}\``)
96
102
  .join(NAME_SEPARATOR)
97
103
 
98
- return [`Carry ${specs} into this repository.`, '', plan.origin, RUN_NOTE].join('\n')
104
+ return [
105
+ `Carry ${specs} into this repository.`,
106
+ '',
107
+ plan.origin,
108
+ RUN_NOTE,
109
+ '',
110
+ pr_classification.declaration(UPGRADE_CLASSIFICATION),
111
+ ].join('\n')
99
112
  }
100
113
 
101
114
  function issue_body(package_name: string, version: string): string {
@@ -129,6 +129,7 @@ const filing_cap = {
129
129
  ROW,
130
130
  current_turn,
131
131
  decide,
132
+ is_prompt_line,
132
133
  prior_filing_count,
133
134
  turn_filing_count,
134
135
  }
@@ -171,6 +171,28 @@ function bare_references(message: string): ReadonlyArray<string> {
171
171
  return [...new Set(found)]
172
172
  }
173
173
 
174
+ // Every reference the prompt carries, each as written — `#N` or `owner/repo#N` — with none of the
175
+ // citation exclusions applied: a number pasted inside a fence or a quote is still the person's.
176
+ function prompt_references(prompt: string): ReadonlySet<string> {
177
+ const found = new Set<string>()
178
+
179
+ for (const match of prompt.matchAll(ISSUE_NUMBER)) {
180
+ found.add(repo_prefix(prompt, match.index) + match[0])
181
+ }
182
+
183
+ return found
184
+ }
185
+
186
+ // **A number the person's prompt already carried is quoted, not cited** (joshuafolkken/kit#2819). A
187
+ // pasted log from another repository holds its own `#1`…`#7`; `issue:cite` would link them to this
188
+ // repository's unrelated Issues, so following the refusal would make the reply wrong. Only the exact
189
+ // reference the prompt wrote is exempt, so a bare `#N` the run brought in itself is still refused.
190
+ function unquoted_references(message: string, prompt: string): ReadonlyArray<string> {
191
+ const quoted = prompt_references(prompt)
192
+
193
+ return bare_references(message).filter((reference) => !quoted.has(reference))
194
+ }
195
+
174
196
  function has_bare_reference(message: string): boolean {
175
197
  return bare_references(message).length > 0
176
198
  }
@@ -237,6 +259,13 @@ function linkify(message: string, slug: string): string {
237
259
  return out.join('\n')
238
260
  }
239
261
 
240
- const issue_citation = { bare_references, cite_arguments, has_bare_reference, linkify, prose_lines }
262
+ const issue_citation = {
263
+ bare_references,
264
+ cite_arguments,
265
+ has_bare_reference,
266
+ linkify,
267
+ prose_lines,
268
+ unquoted_references,
269
+ }
241
270
 
242
271
  export { issue_citation }
@@ -0,0 +1,79 @@
1
+ import { json_value } from '#scripts/lib/json-value'
2
+ import { time_reported_failure } from '#scripts/time-runtime/time-reported-failure'
3
+ import { z } from 'zod'
4
+ import { filing_cap } from './filing-cap'
5
+
6
+ // The text of the person's last prompt on a transcript tail — what the stop guard's citation row
7
+ // reads to tell a `#N` the person quoted from one the run brought in itself (joshuafolkken/kit#2819).
8
+ //
9
+ // **A prompt is `filing_cap`'s prompt line, minus what the harness wrote there.** A skill's body is a
10
+ // `user` line with no tool result too, but it carries `isMeta`; a background task's completion notice
11
+ // and a subagent's hand-back carry an `origin` whose `kind` is not `human`. Each is the harness
12
+ // speaking, not the person — and a notice can name the very Issue the run filed — so only a line with
13
+ // no `origin` (an SDK prompt) or a `human` one is read.
14
+ //
15
+ // **A workflow invocation quotes nothing.** `fullrun #2819` names this repository's own Issue, so a
16
+ // bare `#2819` in that run's report is a citation slip like any other, and the prompt yields no text —
17
+ // a resumed lane child's prompt included, since it ends on the same invocation.
18
+
19
+ const WORKFLOW_KEYWORDS: ReadonlySet<string> = new Set([
20
+ 'kickoff',
21
+ 'fullrun',
22
+ 'halfrun',
23
+ 'backlogrun',
24
+ ])
25
+ const NO_PROMPT = ''
26
+ const HUMAN_ORIGIN = 'human'
27
+ // The word before the prompt's last one — where a trailing `fullrun #N` puts its keyword.
28
+ const KEYWORD_BEFORE_ARGUMENT = -2
29
+
30
+ const PROMPT_LINE_SCHEMA = z.object({
31
+ isMeta: z.boolean().nullish(),
32
+ origin: z.object({ kind: z.string().nullish() }).nullish(),
33
+ message: z.object({ content: z.unknown() }),
34
+ })
35
+
36
+ type PromptLine = z.infer<typeof PROMPT_LINE_SCHEMA>
37
+
38
+ function is_person(line: PromptLine): boolean {
39
+ if (line.isMeta === true) return false
40
+
41
+ return line.origin === null || line.origin === undefined || line.origin.kind === HUMAN_ORIGIN
42
+ }
43
+
44
+ // The prompt line's text, or `undefined` when the harness wrote it or it does not read as one.
45
+ function person_text(line: string): string | undefined {
46
+ const parsed = PROMPT_LINE_SCHEMA.safeParse(json_value.parse_or_undefined(line))
47
+
48
+ if (!parsed.success || !is_person(parsed.data)) return undefined
49
+
50
+ return time_reported_failure.result_text(parsed.data.message.content)
51
+ }
52
+
53
+ // A keyword opening the prompt is what a person types; a keyword before its one trailing argument is a
54
+ // lane child's resume prompt, whose preamble ends on `fullrun #N` (`lane-child-invocation.ts`).
55
+ function is_invocation(prompt: string): boolean {
56
+ const words = prompt.trim().split(/\s+/u)
57
+
58
+ return [words.at(0), words.at(KEYWORD_BEFORE_ARGUMENT)].some((word) =>
59
+ WORKFLOW_KEYWORDS.has(word ?? NO_PROMPT),
60
+ )
61
+ }
62
+
63
+ function prompt_line_text(line: string): string | undefined {
64
+ return filing_cap.is_prompt_line(line) ? person_text(line) : undefined
65
+ }
66
+
67
+ // The last person prompt on the tail; '' when none is on it or it is an invocation.
68
+ function prompt_text(tail: string): string {
69
+ const text = tail
70
+ .split('\n')
71
+ .map((line) => prompt_line_text(line))
72
+ .findLast((one) => one !== undefined)
73
+
74
+ return text === undefined || is_invocation(text) ? NO_PROMPT : text
75
+ }
76
+
77
+ const last_prompt = { prompt_text }
78
+
79
+ export { last_prompt }
@@ -49,6 +49,8 @@ interface StopContext {
49
49
  notified: boolean
50
50
  // The turn's session-facing reply text.
51
51
  message: string
52
+ // The person's last prompt — a `#N` it already carried is quoted, not cited (joshuafolkken/kit#2819).
53
+ prompt: string
52
54
  // This stop already forced a continuation earlier in the prompt — the loop-breaker Claude Code
53
55
  // documents, so a run that will not comply is not blocked forever.
54
56
  stop_hook_active: boolean
@@ -242,8 +244,8 @@ function needs_release(context: StopContext): boolean {
242
244
  return is_held_idle(context) && context.tree_clean
243
245
  }
244
246
 
245
- function citation_reason(message: string): string | undefined {
246
- const references = issue_citation.bare_references(message)
247
+ function citation_reason(context: StopContext): string | undefined {
248
+ const references = issue_citation.unquoted_references(context.message, context.prompt)
247
249
  if (references.length === 0) return undefined
248
250
 
249
251
  return build_citation_reason(references)
@@ -255,7 +257,7 @@ function citation_reason(message: string): string | undefined {
255
257
  function reply_reason(context: StopContext): string | undefined {
256
258
  if (needs_filing(context)) return FILING_OFFER_REASON
257
259
 
258
- return citation_reason(context.message) ?? language_reason(context)
260
+ return citation_reason(context) ?? language_reason(context)
259
261
  }
260
262
 
261
263
  // The headless wait holds past the loop-breaker until the spin bound is reached.
@@ -170,10 +170,10 @@ function create_halfrun_stop_hold(target: string, issue: string, now: Date = new
170
170
  // be chosen that covers a `halfrun` stop or a `needs-human-review` stop: those are held across a
171
171
  // person's latency rather than a run's, and the thing that must not be trampled is sitting in the
172
172
  // tree where anyone can see it. An unreadable status is dirty, for the reason every other unreadable
173
- // state here blocks.
174
- async function is_tree_dirty(): Promise<boolean> {
173
+ // state here blocks. `directory` asks the same of another work tree — a lane — instead of this one.
174
+ async function is_tree_dirty(directory?: string): Promise<boolean> {
175
175
  try {
176
- const status = await git_command.status()
176
+ const status = await git_command.status(directory)
177
177
 
178
178
  return status.trim() !== ''
179
179
  } catch {