@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.
- package/.claude/skills/workflow-commands/backlogrun-progress.md +13 -12
- package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -1
- package/README.md +2 -1
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-UULARQ5Q.js → chunk-2PGRQ62K.js} +2 -9
- package/dist/hooks/{chunk-3HZ5MJIW.js → chunk-7XQ6TK3R.js} +2 -2
- package/dist/hooks/{chunk-UANRLZV7.js → chunk-BNFG2IFK.js} +4 -4
- package/dist/hooks/{chunk-LC765SUA.js → chunk-GQ3EVWHY.js} +8 -3
- package/dist/hooks/{chunk-XMR3BS35.js → chunk-I3JP3D4A.js} +3 -3
- package/dist/hooks/{chunk-OLTAGGHH.js → chunk-LCJZE42I.js} +1 -1
- package/dist/hooks/{chunk-NWD47MW3.js → chunk-NJZSO7BK.js} +3 -3
- package/dist/hooks/{chunk-SSX23SJ2.js → chunk-RKUAD3I6.js} +1 -0
- package/dist/hooks/{chunk-77RLKZ5V.js → chunk-ROULPSQC.js} +2 -2
- package/dist/hooks/{chunk-OYU6KPRA.js → chunk-SXPTRFPG.js} +2 -2
- package/dist/hooks/{chunk-FXAKJKTB.js → chunk-WPALEIRX.js} +1 -1
- package/dist/hooks/{chunk-O2FBZPSW.js → chunk-YO43HEPY.js} +23 -8
- package/dist/hooks/codex-hook-adapter.js +12 -12
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +3 -3
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +11 -11
- package/dist/hooks/session-lang.js +2 -2
- package/dist/hooks/stop-guard.js +5 -5
- package/dist/josh.js +1 -8
- package/eslint/base.js +4 -1
- package/package.json +9 -6
- package/playwright.config.ts +9 -17
- package/ports/index.d.ts +1 -0
- package/ports/index.js +1 -0
- package/ports/web-server.d.ts +5 -0
- package/ports/web-server.js +73 -0
- package/scripts/agent/agent-role-profile.ts +11 -0
- package/scripts/gate/project-checks.ts +24 -0
- package/scripts/init/init-ai-copy.ts +8 -5
- package/scripts/init/init-logic-workspace.ts +38 -5
- package/scripts/init/init-logic.ts +1 -0
- package/scripts/init/init-static.ts +4 -1
- package/scripts/init/project-profile.ts +14 -5
- package/scripts/josh/josh-commands-development.ts +2 -15
- package/scripts/lint/format.ts +68 -0
- package/scripts/lint/lint-parallel.ts +4 -18
- package/scripts/run/run-step-cli.ts +11 -1
- package/scripts/run/run-step.ts +36 -8
- package/templates/pnpm-workspace.static.yaml +4 -0
package/playwright.config.ts
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
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: '
|
|
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: '
|
|
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
|
@@ -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
|
|
116
|
-
|
|
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
|
|
49
|
-
if (!
|
|
50
|
-
const existing_keys = new Set(extract_yaml_top_level_keys(
|
|
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
|
|
85
|
+
if (new_keys.length === 0) return cleaned
|
|
53
86
|
|
|
54
|
-
return append_user_blocks(
|
|
87
|
+
return append_user_blocks(cleaned, new_keys, template)
|
|
55
88
|
}
|
|
56
89
|
|
|
57
90
|
const init_logic_workspace = { merge_workspace_yaml }
|
|
@@ -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, {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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 {
|
|
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
|
|
package/scripts/run/run-step.ts
CHANGED
|
@@ -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
|
|
138
|
-
//
|
|
139
|
-
//
|
|
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
|
|
201
|
-
//
|
|
202
|
-
//
|
|
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,
|