@joshuafolkken/kit 1.921.0 → 1.929.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 (44) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-progress.md +13 -12
  2. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -1
  3. package/README.md +2 -1
  4. package/dist/hooks/batch-guard.js +6 -6
  5. package/dist/hooks/{chunk-UULARQ5Q.js → chunk-2PGRQ62K.js} +2 -9
  6. package/dist/hooks/{chunk-3HZ5MJIW.js → chunk-7XQ6TK3R.js} +2 -2
  7. package/dist/hooks/{chunk-UANRLZV7.js → chunk-BNFG2IFK.js} +4 -4
  8. package/dist/hooks/{chunk-LC765SUA.js → chunk-GQ3EVWHY.js} +8 -3
  9. package/dist/hooks/{chunk-XMR3BS35.js → chunk-I3JP3D4A.js} +3 -3
  10. package/dist/hooks/{chunk-OLTAGGHH.js → chunk-LCJZE42I.js} +1 -1
  11. package/dist/hooks/{chunk-NWD47MW3.js → chunk-NJZSO7BK.js} +3 -3
  12. package/dist/hooks/{chunk-SSX23SJ2.js → chunk-RKUAD3I6.js} +1 -0
  13. package/dist/hooks/{chunk-77RLKZ5V.js → chunk-ROULPSQC.js} +2 -2
  14. package/dist/hooks/{chunk-OYU6KPRA.js → chunk-SXPTRFPG.js} +2 -2
  15. package/dist/hooks/{chunk-FXAKJKTB.js → chunk-WPALEIRX.js} +1 -1
  16. package/dist/hooks/{chunk-O2FBZPSW.js → chunk-YO43HEPY.js} +23 -8
  17. package/dist/hooks/codex-hook-adapter.js +12 -12
  18. package/dist/hooks/duplicate-read-guard.js +7 -7
  19. package/dist/hooks/format-edited.js +3 -3
  20. package/dist/hooks/investigation-guard.js +7 -7
  21. package/dist/hooks/pretool-guard.js +11 -11
  22. package/dist/hooks/session-lang.js +2 -2
  23. package/dist/hooks/stop-guard.js +5 -5
  24. package/dist/josh.js +1 -8
  25. package/eslint/base.js +4 -1
  26. package/package.json +9 -6
  27. package/playwright.config.ts +9 -17
  28. package/ports/index.d.ts +1 -0
  29. package/ports/index.js +1 -0
  30. package/ports/web-server.d.ts +5 -0
  31. package/ports/web-server.js +73 -0
  32. package/scripts/agent/agent-role-profile.ts +11 -0
  33. package/scripts/gate/project-checks.ts +24 -0
  34. package/scripts/init/init-ai-copy.ts +8 -5
  35. package/scripts/init/init-logic-workspace.ts +38 -5
  36. package/scripts/init/init-logic.ts +1 -0
  37. package/scripts/init/init-static.ts +4 -1
  38. package/scripts/init/project-profile.ts +14 -5
  39. package/scripts/josh/josh-commands-development.ts +2 -15
  40. package/scripts/lint/format.ts +68 -0
  41. package/scripts/lint/lint-parallel.ts +4 -18
  42. package/scripts/run/run-step-cli.ts +11 -1
  43. package/scripts/run/run-step.ts +36 -8
  44. package/templates/pnpm-workspace.static.yaml +4 -0
@@ -1,5 +1,6 @@
1
1
  import { environment_flags } from '@joshuafolkken/kit/env'
2
2
  import { ports } from '@joshuafolkken/kit/ports'
3
+ import { web_server } from '@joshuafolkken/kit/web-server'
3
4
  import { defineConfig, devices, type ReporterDescription } from '@playwright/test'
4
5
 
5
6
  // Both ports come from kit's single definition (`@joshuafolkken/kit/ports`), offset by the personal
@@ -17,7 +18,7 @@ import { defineConfig, devices, type ReporterDescription } from '@playwright/tes
17
18
  // the file, and a project with no `.env` is untouched.
18
19
  //
19
20
  // The working directory is the anchor, and the loader ascends from it to the project root — the
20
- // same root `pnpm run` hands the `webServer` command, so both sides name one file from anywhere
21
+ // same root `node --run` hands the `webServer` command, so both sides name one file from anywhere
21
22
  // inside the project. The one layout that escapes that is a run whose working directory sits in a
22
23
  // *different* package from this config, as `playwright test --config ../../playwright.config.ts`
23
24
  // does in a workspace: Playwright would default `webServer.cwd` to this file's directory while the
@@ -31,13 +32,6 @@ const PREVIEW_PORT = ports.resolve_preview_port()
31
32
 
32
33
  const CI_TIMEOUT = 120_000
33
34
  const LOCAL_TIMEOUT = 30_000
34
- // pnpm 11.27.1+ places a script it runs without a controlling terminal in its own process group, so
35
- // Playwright's default teardown — SIGKILL to the webServer's group — no longer reaches the `vite dev`
36
- // / `preview` child: it survives holding Playwright's stdio pipes open, and Playwright blocks forever
37
- // on the `close` event (#2386). SIGTERM, unlike SIGKILL, is relayed by that pnpm to the child, so a
38
- // graceful shutdown lets the server exit and the pipes close. The timeout must stay well above the
39
- // observed ~40ms shutdown, or Playwright falls back to killProcess() (SIGKILL) and the hang returns.
40
- const SHUTDOWN_TIMEOUT = 10_000
41
35
  const CI_TEST_TIMEOUT = 30_000
42
36
  const ACTION_TIMEOUT = 10_000
43
37
  const NAV_TIMEOUT = 30_000
@@ -74,7 +68,7 @@ const IS_CI = environment_flags.is_ci_enabled(process.env['CI'])
74
68
  // run whose report is unreachable unless the developer already knows `playwright show-report`.
75
69
  // (`CI/1` in the Playwright user agent and the MCP headless force share the same root cause.)
76
70
  // Deleting the variable once this config has judged the run local is what makes every downstream
77
- // reader agree with that verdict, the `pnpm run dev` child process included: leaving `CI=0` set for
71
+ // reader agree with that verdict, the `dev` script's child process included: leaving `CI=0` set for
78
72
  // the child would only re-introduce the same bare-truthiness bug one process down. The guard is
79
73
  // what keeps this safe — a real provider value such as `CI=woodpecker` is never touched, and the
80
74
  // deletion cannot fire on the CI branch.
@@ -91,24 +85,22 @@ if (!IS_CI) delete process.env['CI']
91
85
  // application; a seed reduces how often a foreign server is on the port at all.
92
86
  const IS_REUSE_ENABLED = environment_flags.is_flag_enabled(process.env['PLAYWRIGHT_REUSE_SERVER'])
93
87
 
94
- // Both branches share one shutdown policy — see SHUTDOWN_TIMEOUT for why SIGTERM rather than the
95
- // SIGKILL default. `as const` keeps the signal a literal so it satisfies Playwright's signal union.
96
- const GRACEFUL_SHUTDOWN = { signal: 'SIGTERM', timeout: SHUTDOWN_TIMEOUT } as const
97
-
88
+ // The scripts run through `node --run` rather than `pnpm run`, so the server stays in the process
89
+ // group Playwright signals on teardown and its default SIGKILL reaches it directly — no package
90
+ // manager relays the signal, and no graceful-shutdown timeout has to be tuned around one (#2392).
91
+ // Each script's `pre` hook is chained in explicitly, since `node --run` does not run it.
98
92
  const web_server_config = IS_CI
99
93
  ? {
100
- command: 'pnpm run build && pnpm run preview',
94
+ command: web_server.script_command(['build', 'preview']),
101
95
  port: PREVIEW_PORT,
102
96
  timeout: CI_TIMEOUT,
103
97
  reuseExistingServer: IS_REUSE_ENABLED,
104
- gracefulShutdown: GRACEFUL_SHUTDOWN,
105
98
  }
106
99
  : {
107
- command: 'pnpm run dev',
100
+ command: web_server.script_command(['dev']),
108
101
  port: DEV_PORT,
109
102
  timeout: LOCAL_TIMEOUT,
110
103
  reuseExistingServer: IS_REUSE_ENABLED,
111
- gracefulShutdown: GRACEFUL_SHUTDOWN,
112
104
  }
113
105
 
114
106
  const env_config: EnvConfig = IS_CI
package/ports/index.d.ts CHANGED
@@ -7,6 +7,7 @@ declare const PROJECT_ENVIRONMENT_KEYS: ReadonlySet<string>
7
7
 
8
8
  declare const ports: {
9
9
  load_environment_file: (directory?: string) => boolean
10
+ resolve_project_directory: (directory: string) => string
10
11
  resolve_seed: (environment?: PortEnvironment) => number
11
12
  resolve_lane: (environment?: PortEnvironment) => number
12
13
  resolve_development_port: (environment?: PortEnvironment) => number
package/ports/index.js CHANGED
@@ -247,6 +247,7 @@ function load_environment_file(directory = process.cwd()) {
247
247
 
248
248
  const ports = {
249
249
  load_environment_file,
250
+ resolve_project_directory,
250
251
  resolve_seed,
251
252
  resolve_lane,
252
253
  resolve_development_port,
@@ -0,0 +1,5 @@
1
+ declare const web_server: {
2
+ script_command: (names: Array<string>, directory?: string) => string
3
+ }
4
+
5
+ export { web_server }
@@ -0,0 +1,73 @@
1
+ // The command Playwright's `webServer` runs, built so no package manager stands between Playwright
2
+ // and the server it starts.
3
+ //
4
+ // Playwright spawns the command detached and tears it down by signalling that process group. pnpm
5
+ // 11.27.1+ moves a script it runs without a controlling terminal into a group of its own, so the
6
+ // teardown reached pnpm alone and the dev / preview server survived holding Playwright's stdio pipes
7
+ // open — a hang #2386 patched by relying on pnpm to relay SIGTERM. `node --run` executes the same
8
+ // `package.json` script in the caller's process group, so the teardown signal reaches the server
9
+ // itself and there is no relay left to depend on (#2392).
10
+ //
11
+ // The script names stay the interface rather than a hard-coded `vite` invocation: a consumer's
12
+ // `preview` is not necessarily `vite preview` (wrangler serves the Cloudflare build), and each script
13
+ // resolves its own port through `josh port`. What `node --run` does not do is run `pre<name>` hooks,
14
+ // which a consumer can rely on — a `prepreview` applying local database migrations, say — so the hook
15
+ // is chained explicitly here instead of being dropped without a word. `post<name>` is not: a server
16
+ // script never ends on its own, so its post hook would never have run under pnpm either.
17
+ import { existsSync, readFileSync } from 'node:fs'
18
+ import path from 'node:path'
19
+ import { ports } from './index.js'
20
+
21
+ const PACKAGE_FILE_NAME = 'package.json'
22
+ const PRE_HOOK_PREFIX = 'pre'
23
+ const STEP_SEPARATOR = ' && '
24
+
25
+ /**
26
+ * The `scripts` of the project root's `package.json` — the same file `node --run` resolves from the
27
+ * working directory — or an empty record when there is none.
28
+ *
29
+ * @param {string} directory
30
+ * @returns {Record<string, unknown>}
31
+ */
32
+ function read_scripts(directory) {
33
+ const file = path.join(ports.resolve_project_directory(directory), PACKAGE_FILE_NAME)
34
+ if (!existsSync(file)) return {}
35
+
36
+ const { scripts } = JSON.parse(readFileSync(file, 'utf8'))
37
+
38
+ return typeof scripts === 'object' && scripts !== null ? scripts : {}
39
+ }
40
+
41
+ /**
42
+ * @param {string} name
43
+ * @param {Record<string, unknown>} scripts
44
+ * @returns {string[]}
45
+ */
46
+ function steps_for(name, scripts) {
47
+ const hook = `${PRE_HOOK_PREFIX}${name}`
48
+
49
+ return Object.hasOwn(scripts, hook) ? [hook, name] : [name]
50
+ }
51
+
52
+ /**
53
+ * Chain the named `package.json` scripts, each preceded by its `pre` hook when the project defines
54
+ * one, into a single shell command run through `node --run`.
55
+ *
56
+ * @param {string[]} names
57
+ * @param {string} [directory]
58
+ * @returns {string}
59
+ */
60
+ function script_command(names, directory = process.cwd()) {
61
+ const scripts = read_scripts(directory)
62
+
63
+ return names
64
+ .flatMap((name) => steps_for(name, scripts))
65
+ .map((step) => `node --run ${step}`)
66
+ .join(STEP_SEPARATOR)
67
+ }
68
+
69
+ const web_server = {
70
+ script_command,
71
+ }
72
+
73
+ export { web_server }
@@ -201,6 +201,16 @@ function resolve_provider(environment: AgentEnvironment = process.env): Provider
201
201
  return detected_provider(environment) ?? handed_provider(environment)
202
202
  }
203
203
 
204
+ // Whether this session is woken when a background command it started completes
205
+ // (joshuafolkken/kit#2653). Claude Code re-invokes the session at the completion; a Codex session is not
206
+ // re-invoked, so a parent there can only wait by polling, each poll a model call over its whole context.
207
+ // Only a resolved Codex session answers no — an unresolved one keeps the behavior it had before.
208
+ function has_completion_callback(environment: AgentEnvironment = process.env): boolean {
209
+ const selected = resolve_provider(environment)
210
+
211
+ return selected.kind !== 'provider' || selected.provider !== OPENAI_PROVIDER
212
+ }
213
+
204
214
  // The mark a detached launch sets on its child: the provider this session resolved, or nothing when it
205
215
  // resolved none — the child then fails the same way this session would have.
206
216
  function handoff_environment(
@@ -309,6 +319,7 @@ const agent_role_profile = {
309
319
  WORKER,
310
320
  describe,
311
321
  handoff_environment,
322
+ has_completion_callback,
312
323
  is_safe_value,
313
324
  parse,
314
325
  phase_effort,
@@ -102,6 +102,28 @@ function type_check_skip_reason(directory: string): string | undefined {
102
102
  return undefined
103
103
  }
104
104
 
105
+ // Shared by `josh lint` and `josh format`, so a static project without Web files — a Python or Rust
106
+ // project, which `josh init` gives no Prettier — is skipped for the same reason by both
107
+ // (joshuafolkken/kit#2606).
108
+ function prettier_skip_reason(directory: string): string | undefined {
109
+ if (!is_static(directory)) return undefined
110
+ if (!has_files(directory, WEB_FILES)) return 'no HTML, CSS or JavaScript files were found'
111
+ if (!has_bin(directory, 'prettier')) return 'prettier is not installed'
112
+
113
+ return undefined
114
+ }
115
+
116
+ // Shared by `josh lint` and `josh format`, so a static project without ESLint is skipped for the
117
+ // same reason by both (joshuafolkken/kit#2693).
118
+ function eslint_skip_reason(directory: string): string | undefined {
119
+ if (!is_static(directory)) return undefined
120
+ if (!has_files(directory, SCRIPT_FILES)) return 'no JavaScript or TypeScript files were found'
121
+ if (!has_config(directory, ESLINT_CONFIGS)) return 'no ESLint configuration was found'
122
+ if (!has_bin(directory, 'eslint')) return 'eslint is not installed'
123
+
124
+ return undefined
125
+ }
126
+
105
127
  const project_checks = {
106
128
  CSPELL_CONFIGS,
107
129
  ESLINT_CONFIGS,
@@ -109,10 +131,12 @@ const project_checks = {
109
131
  TYPE_CONFIGS,
110
132
  TYPE_FILES,
111
133
  WEB_FILES,
134
+ eslint_skip_reason,
112
135
  has_bin,
113
136
  has_config,
114
137
  has_files,
115
138
  is_static,
139
+ prettier_skip_reason,
116
140
  project_root,
117
141
  skip_notice,
118
142
  type_check_skip_reason,
@@ -28,7 +28,13 @@ const STATIC_AI_FILES = [
28
28
  GIT_ATTRIBUTES,
29
29
  CODE_OF_CONDUCT,
30
30
  SECURITY_MD,
31
+ WORKSPACE_YAML,
31
32
  ]
33
+ // A static project gets its own template for these, not kit's development copy (joshuafolkken/kit#2693).
34
+ const STATIC_SOURCES: Readonly<Record<string, string>> = {
35
+ [PRETTIER_IGNORE]: 'templates/prettierignore.static',
36
+ [WORKSPACE_YAML]: 'templates/pnpm-workspace.static.yaml',
37
+ }
32
38
  const GITHUB_FILES = new Set([CODE_OF_CONDUCT, SECURITY_MD])
33
39
  const STATIC_POINTER_MAPPING = { src: 'templates/cursorrules.static', dest: '.cursorrules' }
34
40
  const PULL_REQUEST_TEMPLATE = '.github/pull_request_template.md'
@@ -112,11 +118,8 @@ function did_skip_workspace_yaml_copy(source_path: string, destination_path: str
112
118
  }
113
119
 
114
120
  function did_skip_ai_file_copy(filename: string, shape?: ProjectShape): boolean {
115
- const source_path = package_path(
116
- filename === PRETTIER_IGNORE && shape?.profile === 'static'
117
- ? 'templates/prettierignore.static'
118
- : filename,
119
- )
121
+ const static_source = shape?.profile === 'static' ? STATIC_SOURCES[filename] : undefined
122
+ const source_path = package_path(static_source ?? filename)
120
123
  const destination_path = path.join(PROJECT_ROOT, filename)
121
124
 
122
125
  if (filename === WORKSPACE_YAML) {
@@ -42,16 +42,49 @@ function remove_deprecated_yaml_keys(content: string): string {
42
42
  return `${result.trimEnd()}\n`
43
43
  }
44
44
 
45
+ // pnpm 11+ writes this placeholder into `allowBuilds` for every build script it ignored, and fails
46
+ // every later install until it is answered. `pnpm add -D @joshuafolkken/kit` before `josh init`
47
+ // leaves one for esbuild, and the key-level merge below keeps an existing `allowBuilds` whole, so a
48
+ // placeholder the template decides is answered here (joshuafolkken/kit#2693).
49
+ const BUILD_PLACEHOLDER_PATTERN = /^([ \t]+)([^\s:#][^:#]*): set this to true or false$/gmu
50
+ const TEMPLATE_BUILD_PATTERN = /^[ \t]+(?<key>[^\s:#][^:#]*):[ \t]*(?<value>true|false)\b/u
51
+
52
+ function unquote(key: string): string {
53
+ return key.trim().replaceAll(/^['"]|['"]$/gu, '')
54
+ }
55
+
56
+ function template_build_values(template: string): Map<string, string> {
57
+ const lines = extract_yaml_block(template, 'allowBuilds').split('\n')
58
+
59
+ return new Map(
60
+ lines.flatMap((line) => {
61
+ const { key, value } = TEMPLATE_BUILD_PATTERN.exec(line)?.groups ?? {}
62
+
63
+ return key && value ? [[unquote(key), value] as const] : []
64
+ }),
65
+ )
66
+ }
67
+
68
+ function answer_build_placeholders(existing: string, template: string): string {
69
+ const values = template_build_values(template)
70
+
71
+ return existing.replaceAll(BUILD_PLACEHOLDER_PATTERN, (line, indent: string, key: string) => {
72
+ const value = values.get(unquote(key))
73
+
74
+ return value === undefined ? line : `${indent}${key}: ${value}`
75
+ })
76
+ }
77
+
45
78
  function merge_workspace_yaml(existing: string, template: string): string {
46
79
  if (!existing.trim()) return template
47
80
  const normalized = existing.endsWith('\n') ? existing : `${existing}\n`
48
- const without_deprecated = remove_deprecated_yaml_keys(normalized)
49
- if (!without_deprecated.trim()) return template
50
- const existing_keys = new Set(extract_yaml_top_level_keys(without_deprecated))
81
+ const cleaned = answer_build_placeholders(remove_deprecated_yaml_keys(normalized), template)
82
+ if (!cleaned.trim()) return template
83
+ const existing_keys = new Set(extract_yaml_top_level_keys(cleaned))
51
84
  const new_keys = extract_yaml_top_level_keys(template).filter((k) => !existing_keys.has(k))
52
- if (new_keys.length === 0) return without_deprecated
85
+ if (new_keys.length === 0) return cleaned
53
86
 
54
- return append_user_blocks(without_deprecated, new_keys, template)
87
+ return append_user_blocks(cleaned, new_keys, template)
55
88
  }
56
89
 
57
90
  const init_logic_workspace = { merge_workspace_yaml }
@@ -451,6 +451,7 @@ const init_logic = {
451
451
  strip_kit_only_vscode_settings,
452
452
  strip_kit_only_vscode_settings_content,
453
453
  VSCODE_EXTENSIONS_FILENAME,
454
+ SAFE_CHAIN_CMD,
454
455
  GUARDED_LEFTHOOK_CMD,
455
456
  upgrade_prepare_lefthook_warning,
456
457
  get_npmrc_lines,
@@ -36,7 +36,10 @@ function merge_static_manifest(
36
36
  versions: StaticVersions,
37
37
  ): string {
38
38
  const with_profile = with_recorded_profile(content, shape.profile)
39
- const with_script = init_logic.merge_package_scripts(with_profile, { josh: 'josh' })
39
+ const with_script = init_logic.merge_package_scripts(with_profile, {
40
+ preinstall: init_logic.SAFE_CHAIN_CMD,
41
+ josh: 'josh',
42
+ })
40
43
  const with_kit = init_logic.merge_development_dependencies(with_script, {
41
44
  [KIT_PACKAGE_NAME]: versions.kit,
42
45
  })
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync, type Dirent } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { repo_origin } from '#scripts/discovery/repo-origin'
4
+ import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
4
5
  import { execaSync } from 'execa'
5
6
 
6
7
  type ProjectProfile = 'static' | 'node'
@@ -52,10 +53,6 @@ function read_manifest(root: string): Record<string, unknown> | undefined {
52
53
  return parsed as Record<string, unknown>
53
54
  }
54
55
 
55
- function has_entries(value: unknown): boolean {
56
- return typeof value === 'object' && value !== null && Object.keys(value).length > 0
57
- }
58
-
59
56
  function is_record(value: unknown): value is Record<string, unknown> {
60
57
  return typeof value === 'object' && value !== null && !Array.isArray(value)
61
58
  }
@@ -70,8 +67,20 @@ function has_build_script(value: unknown): boolean {
70
67
  return is_record(value) && ('build' in value || 'dev' in value)
71
68
  }
72
69
 
70
+ // kit itself is not evidence of a Node toolchain: `pnpm add -D @joshuafolkken/kit` before
71
+ // `josh init` is the documented order, and it must still leave an `index.html` site static
72
+ // (joshuafolkken/kit#2693).
73
+ function has_project_dependencies(value: unknown): boolean {
74
+ if (!is_record(value)) return false
75
+
76
+ return Object.keys(value).some((name) => name !== KIT_PACKAGE_NAME)
77
+ }
78
+
73
79
  function inferred_profile(manifest: Record<string, unknown>): ProfileResult {
74
- if (has_entries(manifest['dependencies']) || has_entries(manifest['devDependencies'])) {
80
+ if (
81
+ has_project_dependencies(manifest['dependencies']) ||
82
+ has_project_dependencies(manifest['devDependencies'])
83
+ ) {
75
84
  return { profile: 'node', reason: 'package dependencies' }
76
85
  }
77
86
 
@@ -1,10 +1,4 @@
1
- import {
2
- ESLINT_CACHE_FLAGS,
3
- GATE_COMMAND,
4
- PE,
5
- TS_CACHE_FLAGS,
6
- type CommandEntry,
7
- } from './josh-command-types'
1
+ import { GATE_COMMAND, PE, TS_CACHE_FLAGS, type CommandEntry } from './josh-command-types'
8
2
 
9
3
  const FILE_ARGUMENTS = '[files...]'
10
4
  const REQUIRED_FILE_ARGUMENTS = '<files...>'
@@ -66,14 +60,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
66
60
  reference: ['', 'developer', ['none']],
67
61
  },
68
62
  format: {
69
- // prettier first here, unlike `format:edited`, and deliberately: `eslint --fix` exits 1
70
- // whenever a non-autofixable error remains, so putting it first behind `&&` would mean one
71
- // unused variable anywhere in the tree stops prettier from running at all.
72
- shell: [
73
- 'sh',
74
- '-c',
75
- `pnpm exec prettier --write . && pnpm exec eslint . --fix ${ESLINT_CACHE_FLAGS.join(' ')}`,
76
- ],
63
+ script: 'scripts/lint/format.ts',
77
64
  description: 'Format code with prettier and eslint',
78
65
  category: 'Development',
79
66
  reference: ['', 'developer', ['files', 'processes']],
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env tsx
2
+ import { fileURLToPath } from 'node:url'
3
+ import { project_checks } from '#scripts/gate/project-checks'
4
+ import { ESLINT_CACHE_FLAGS } from '#scripts/josh/josh-command-types'
5
+ import { composite_arguments, USAGE_ERROR_EXIT_CODE } from '#scripts/josh/josh-composite-arguments'
6
+ import { execa } from 'execa'
7
+
8
+ const COMMAND_NAME = 'format'
9
+ const ARGV_OFFSET = 2
10
+ const FAIL_EXIT_CODE = 1
11
+ const PRETTIER_ARGS = ['exec', 'prettier', '--write', '.'] as const
12
+ const ESLINT_FIX_ARGS = ['exec', 'eslint', '.', '--fix', ...ESLINT_CACHE_FLAGS] as const
13
+
14
+ async function run_step(args: ReadonlyArray<string>, directory: string): Promise<number> {
15
+ const result = await execa('pnpm', args, { cwd: directory, stdio: 'inherit', reject: false })
16
+
17
+ return result.exitCode ?? FAIL_EXIT_CODE
18
+ }
19
+
20
+ // prettier first, deliberately: `eslint --fix` exits 1 whenever a non-autofixable error remains, so
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
23
+ // (joshuafolkken/kit#2693, joshuafolkken/kit#2606).
24
+ async function run_unless_skipped(
25
+ check: string,
26
+ reason: string | undefined,
27
+ args: ReadonlyArray<string>,
28
+ directory: string,
29
+ ): Promise<number> {
30
+ if (reason === undefined) return await run_step(args, directory)
31
+ console.info(project_checks.skip_notice(check, reason))
32
+
33
+ return 0
34
+ }
35
+
36
+ async function run_format(directory: string): Promise<number> {
37
+ const prettier_reason = project_checks.prettier_skip_reason(directory)
38
+ const prettier_exit_code = await run_unless_skipped(
39
+ 'prettier',
40
+ prettier_reason,
41
+ PRETTIER_ARGS,
42
+ directory,
43
+ )
44
+ if (prettier_exit_code !== 0) return prettier_exit_code
45
+ const eslint_reason = project_checks.eslint_skip_reason(directory)
46
+
47
+ return await run_unless_skipped('eslint', eslint_reason, ESLINT_FIX_ARGS, directory)
48
+ }
49
+
50
+ // The argument refusal survives leaving the `sh -c` composite shape: both tools run over the whole
51
+ // tree, so a path passed here would be silently discarded and every file rewritten instead.
52
+ async function run(argv: ReadonlyArray<string>, directory: string): Promise<number> {
53
+ if (argv.length > 0) {
54
+ console.error(composite_arguments.format_rejection(COMMAND_NAME, []))
55
+
56
+ return USAGE_ERROR_EXIT_CODE
57
+ }
58
+
59
+ return await run_format(directory)
60
+ }
61
+
62
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
63
+ process.exitCode = await run(process.argv.slice(ARGV_OFFSET), process.cwd())
64
+ }
65
+
66
+ const format = { run, run_format }
67
+
68
+ export { ESLINT_FIX_ARGS, format }
@@ -35,29 +35,15 @@ function skipped(check: string, reason: string): BufferedProcessResult {
35
35
  }
36
36
 
37
37
  async function run_static_prettier(directory: string): Promise<BufferedProcessResult> {
38
- if (!project_checks.has_files(directory, project_checks.WEB_FILES)) {
39
- return skipped('prettier', 'no HTML, CSS or JavaScript files were found')
40
- }
41
-
42
- if (!project_checks.has_bin(directory, 'prettier')) {
43
- return skipped('prettier', 'prettier is not installed')
44
- }
38
+ const reason = project_checks.prettier_skip_reason(directory)
39
+ if (reason !== undefined) return skipped('prettier', reason)
45
40
 
46
41
  return await buffered_process.run_buffered_process(PRETTIER_ARGS)
47
42
  }
48
43
 
49
44
  async function run_static_eslint(directory: string): Promise<BufferedProcessResult> {
50
- if (!project_checks.has_files(directory, project_checks.SCRIPT_FILES)) {
51
- return skipped('eslint', 'no JavaScript or TypeScript files were found')
52
- }
53
-
54
- if (!project_checks.has_config(directory, project_checks.ESLINT_CONFIGS)) {
55
- return skipped('eslint', 'no ESLint configuration was found')
56
- }
57
-
58
- if (!project_checks.has_bin(directory, 'eslint')) {
59
- return skipped('eslint', 'eslint is not installed')
60
- }
45
+ const reason = project_checks.eslint_skip_reason(directory)
46
+ if (reason !== undefined) return skipped('eslint', reason)
61
47
 
62
48
  return await run_eslint(ESLINT_ARGS, ESLINT_CACHE_FILE)
63
49
  }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { agent_role_profile } from '#scripts/agent/agent-role-profile'
3
4
  import { doctor_consumer } from '#scripts/doctor/doctor-consumer'
4
5
  import { hook_decision } from '#scripts/josh/hook-decision'
5
6
  import { find_package_directory } from '#scripts/josh/josh-logic'
@@ -29,6 +30,7 @@ const RETROSPECTIVE_ENV_KEY = 'JOSH_RETROSPECTIVE'
29
30
  interface RunReads {
30
31
  carry_kind: CarryRead['kind']
31
32
  is_retrospective_done: boolean
33
+ is_at_cut_cap: boolean
32
34
  last_event: string | undefined
33
35
  }
34
36
 
@@ -36,7 +38,12 @@ interface RunReads {
36
38
  // directory that cannot be read leaves the position unknowable, which `next_action` answers `unknown`.
37
39
  function read_run(directory: string | undefined, issue_number: string): RunReads {
38
40
  if (directory === undefined) {
39
- return { carry_kind: 'unreadable', is_retrospective_done: false, last_event: undefined }
41
+ return {
42
+ carry_kind: 'unreadable',
43
+ is_retrospective_done: false,
44
+ is_at_cut_cap: false,
45
+ last_event: undefined,
46
+ }
40
47
  }
41
48
 
42
49
  const carry = run_carry.read_carry(run_carry.carry_path(directory))
@@ -55,6 +62,7 @@ function read_run(directory: string | undefined, issue_number: string): RunReads
55
62
  return {
56
63
  carry_kind: carry.kind,
57
64
  is_retrospective_done: run_carry.retrospective_done_of(carry),
65
+ is_at_cut_cap: carry.kind === 'carried' && run_carry.is_at_cut_cap(carry.carry),
58
66
  last_event: last?.kind,
59
67
  }
60
68
  }
@@ -72,10 +80,12 @@ async function gather(issue_number: string): Promise<StepInput> {
72
80
  last_event: run_reads.last_event,
73
81
  carry_kind: run_reads.carry_kind,
74
82
  is_retrospective_done: run_reads.is_retrospective_done,
83
+ is_at_cut_cap: run_reads.is_at_cut_cap,
75
84
  is_lane_child: lane_child_marker.is_child_of(process.cwd()),
76
85
  is_consumer: doctor_consumer.is_kit_consumer(find_package_directory(process.cwd())),
77
86
  is_retrospective_enabled: hook_decision.is_switch_opt_in(RETROSPECTIVE_ENV_KEY),
78
87
  has_changes: parts.has_changes,
88
+ has_completion_callback: agent_role_profile.has_completion_callback(),
79
89
  }
80
90
  }
81
91
 
@@ -118,6 +118,13 @@ interface StepInput extends PreInput {
118
118
  // are opt-in — so a run that leaves the variable unset never prints the retrospective step, while the
119
119
  // retrospective's own logic is untouched and `pnpm josh retrospective` still runs by hand.
120
120
  is_retrospective_enabled: boolean
121
+ // Whether this session is woken when a background command it started completes, read by the CLI from
122
+ // the session's own environment (joshuafolkken/kit#2653). A parent without the callback cannot wait
123
+ // on its lanes except by polling, so it hands the run to the supervisor instead of waiting.
124
+ has_completion_callback: boolean
125
+ // Whether the carried invocation has already taken its maximum cuts, read off the carry record by the
126
+ // CLI (joshuafolkken/kit#2346). At the cap `run:carry --cut` refuses, so the hand-off is not offered.
127
+ is_at_cut_cap: boolean
121
128
  }
122
129
 
123
130
  interface StepAction {
@@ -134,10 +141,9 @@ function command(line: string): StepAction {
134
141
  }
135
142
 
136
143
  // The action for each event a run can be positioned at, keyed on the newest one. Each dispatches to the
137
- // command that owns that phase; `child-launch` is the one position with no command to run — the parent
138
- // waits for its child. `stop` and `drain` are handled apart in `stop_action` / `drain_action`, because
139
- // whether either still owes a retrospective needs more than the issue number. A merge and an outage share a next step: `run:merge`
140
- // classifies both, including the outage's stop condition.
144
+ // command that owns that phase. `stop`, `drain` and `child-launch` are handled apart in
145
+ // `FULL_INPUT_ACTIONS`, because each needs more than the issue number. A merge and an outage share a
146
+ // next step: `run:merge` classifies both, including the outage's stop condition.
141
147
  const KIND = run_event_stream.EVENT_KIND
142
148
  // A park and a stall share this next step: both mean runnable work is waiting to be offered
143
149
  // (joshuafolkken/kit#2359), so both are pointed at the backlog.
@@ -149,7 +155,6 @@ const EVENT_ACTIONS: Record<string, (issue_number: string) => StepAction> = {
149
155
  [KIND.OUTAGE]: (issue_number) => command(`pnpm josh run:merge ${issue_number}`),
150
156
  [KIND.PARK]: () => command(OFFER_BACKLOG),
151
157
  [KIND.CUT]: (issue_number) => command(`pnpm josh run:cut --resume ${issue_number}`),
152
- [KIND.CHILD_LAUNCH]: () => verdict(WAIT),
153
158
  // A stall is undispatched ready work with a free lane, so its next step is exactly a park's: offer
154
159
  // the backlog. The detector only reports the stall; reading it here as a dispatch is what turns the
155
160
  // report into the action that resolves it.
@@ -197,12 +202,34 @@ function drain_action(input: StepInput): StepAction {
197
202
  return is_retrospective_owed(input) ? command(RETROSPECTIVE_COMMAND) : verdict(WAIT)
198
203
  }
199
204
 
200
- // The two positions whose action needs the whole input rather than the issue number — a stop and a
201
- // drain both turn on whether the retrospective is still owed. Kept in a map so `next_action` dispatches
202
- // them in one branch rather than one `if` apiece (`is_retrospective_owed` is the shared gate).
205
+ // The hand-off a `backlogrun` parent without a completion callback takes right after dispatching
206
+ // (joshuafolkken/kit#2653). It is the existing merge-time cut, so the ownership check and the cut cap
207
+ // apply unchanged, and the supervisor's driver adopts the in-flight lanes from their `child-launch`
208
+ // events.
209
+ const HAND_OFF_COMMAND = 'pnpm josh run:carry --cut --owner "$PPID"'
210
+
211
+ // After a dispatch the parent waits for its child — where waiting is free. A session the completion
212
+ // does not re-invoke could only wait by polling, each poll a model call over its whole context, so a
213
+ // `backlogrun` parent there hands the carry record to the supervisor instead. A lane child and a run
214
+ // with no carry record have nothing to hand off, so both still wait; so does a run at the cut cap, whose
215
+ // cut would be refused on every ask and leave the carry record with nobody.
216
+ function can_hand_off(input: StepInput): boolean {
217
+ if (input.is_lane_child || input.is_at_cut_cap) return false
218
+
219
+ return input.carry_kind === 'carried' && !input.has_completion_callback
220
+ }
221
+
222
+ function child_launch_action(input: StepInput): StepAction {
223
+ return can_hand_off(input) ? command(HAND_OFF_COMMAND) : verdict(WAIT)
224
+ }
225
+
226
+ // The positions whose action needs the whole input rather than the issue number — a stop and a drain
227
+ // both turn on whether the retrospective is still owed, a child launch on whether waiting is free. Kept
228
+ // in a map so `next_action` dispatches them in one branch rather than one `if` apiece.
203
229
  const FULL_INPUT_ACTIONS: Record<string, (input: StepInput) => StepAction> = {
204
230
  [KIND.STOP]: stop_action,
205
231
  [KIND.DRAIN]: drain_action,
232
+ [KIND.CHILD_LAUNCH]: child_launch_action,
206
233
  }
207
234
 
208
235
  // An event the table does not name leaves the position unknown rather than guessing a next step. A
@@ -275,6 +302,7 @@ function next_action(input: StepInput): StepAction {
275
302
  const run_step = {
276
303
  ALREADY_DONE,
277
304
  EXPIRED_DECISION,
305
+ HAND_OFF_COMMAND,
278
306
  HUMAN_REVIEW,
279
307
  IMPLEMENT,
280
308
  KEEP_WORK,
@@ -0,0 +1,4 @@
1
+ # Written by `josh init` for a static project (joshuafolkken/kit#2693). kit's CLI runs on tsx, which
2
+ # depends on esbuild, and pnpm fails an install whose dependencies carry an unapproved build script.
3
+ allowBuilds:
4
+ esbuild: true