@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.
- package/.claude/skills/workflow-commands/backlogrun-child.md +4 -5
- package/.claude/skills/workflow-commands/backlogrun-steps.md +1 -1
- package/.github/workflows/sonar-qube.yml +12 -2
- package/CLAUDE.md +2 -2
- package/README.md +30 -21
- package/dist/CLAUDE.md +2 -2
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-ZT65LR3U.js → chunk-4GDR2YTV.js} +4 -4
- package/dist/hooks/{chunk-ASUZGZFT.js → chunk-56LGMJJV.js} +2 -2
- package/dist/hooks/{chunk-LBXQHXMC.js → chunk-6PSTVOLH.js} +263 -74
- package/dist/hooks/{chunk-P3UX7SFN.js → chunk-A2H5I2VD.js} +2 -2
- package/dist/hooks/{chunk-ERXMJJOV.js → chunk-AWTFKKFW.js} +1 -1
- package/dist/hooks/{chunk-IM3KM25Y.js → chunk-EZLHXKJ6.js} +10 -7
- package/dist/hooks/{chunk-APVOACVT.js → chunk-GBJUERYK.js} +1 -1
- package/dist/hooks/{chunk-5JX6HXDX.js → chunk-KPZN42RT.js} +3 -3
- package/dist/hooks/{chunk-47SPMGOD.js → chunk-PAXMLGSS.js} +26 -7
- package/dist/hooks/{chunk-RFPHKU7C.js → chunk-WMIR5SNX.js} +3 -3
- package/dist/hooks/codex-hook-adapter.js +10 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +98 -26
- package/dist/josh.js +5 -5
- package/package.json +5 -1
- package/prettier/basic.d.ts +3 -0
- package/prettier/basic.js +10 -0
- package/prettier/index.js +2 -2
- package/prettier/static.js +3 -10
- package/prompts/sonar-hotspot-handling.md +6 -0
- package/prompts/testing-guide.md +1 -1
- package/scripts/agent/agent-diagnostics.ts +1 -0
- package/scripts/agent/agent-role-profile.ts +1 -1
- package/scripts/behavior/behavior-rules.ts +8 -1
- package/scripts/build/build-claude-md.ts +13 -4
- package/scripts/ci/pr-classification.ts +11 -2
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/gate/gate-steps.ts +1 -1
- package/scripts/gate/project-checks.ts +12 -11
- package/scripts/gate/type-check-command.ts +1 -1
- package/scripts/git/git-branch.ts +14 -3
- package/scripts/git/git-command.ts +5 -2
- package/scripts/git/git-issue.ts +13 -5
- package/scripts/git/git-pr.ts +16 -10
- package/scripts/git/git-preflight.ts +138 -0
- package/scripts/git/telegram-notify.ts +21 -0
- package/scripts/hooks/stop-guard.ts +2 -0
- package/scripts/init/basic-path-migration.ts +24 -0
- package/scripts/init/distributed-paths.ts +22 -0
- package/scripts/init/init-actions.ts +27 -19
- package/scripts/init/init-ai-copy.ts +86 -28
- package/scripts/init/{init-static.ts → init-basic.ts} +6 -6
- package/scripts/init/init-bootstrap.ts +4 -3
- package/scripts/init/init-install.ts +1 -1
- package/scripts/init/init-logic-templates.ts +1 -1
- package/scripts/init/init-logic.ts +1 -1
- package/scripts/init/init.ts +41 -19
- package/scripts/init/kit-setup-state.ts +30 -0
- package/scripts/init/kit-written-paths.ts +33 -0
- package/scripts/init/project-profile-cli.ts +15 -1
- package/scripts/init/project-profile.ts +41 -12
- package/scripts/init/session-language-environment.ts +83 -0
- package/scripts/init/start-exec.ts +28 -0
- package/scripts/init/start-plan.ts +35 -13
- package/scripts/init/start-prompt.ts +22 -8
- package/scripts/init/start-setup-pr.ts +138 -0
- package/scripts/init/start-steps.ts +30 -30
- package/scripts/init/start.ts +7 -3
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +3 -3
- package/scripts/josh/josh-commands-development.ts +3 -3
- package/scripts/josh/josh-commands-project.ts +2 -2
- package/scripts/lane/lane-leftover.ts +143 -0
- package/scripts/lane/lane-open.ts +14 -1
- package/scripts/lint/cspell-cached.ts +3 -3
- package/scripts/lint/format.ts +1 -1
- package/scripts/lint/lint-parallel.ts +2 -2
- package/scripts/propagate/propagate-steps.ts +14 -1
- package/scripts/rules/filing-cap.ts +1 -0
- package/scripts/rules/issue-citation.ts +30 -1
- package/scripts/rules/last-prompt.ts +79 -0
- package/scripts/rules/stop-rules.ts +5 -3
- package/scripts/run/run-hold.ts +3 -3
- package/scripts/run/run-preflight.ts +75 -5
- package/scripts/run/run-progress-cli.ts +14 -2
- package/scripts/safe-chain/ci-installer-pin.ts +19 -12
- package/scripts/safe-chain/project-config.ts +1 -1
- package/scripts/sync/sync-ai-files.ts +204 -0
- package/scripts/sync/sync.ts +48 -184
- package/scripts/test/josh-harness-environment.ts +1 -1
- package/scripts/test/pilot-files.ts +0 -1
- package/scripts/test/test-declared-changed.ts +1 -1
- package/scripts/test/test-declared-logic.ts +55 -14
- package/scripts/test/test-declared.ts +23 -11
- package/scripts/test/test-unit-guard.ts +6 -6
- package/scripts/time-runtime/time-shell.ts +36 -2
- package/scripts-ai/git-workflow.ts +10 -0
- package/templates/CLAUDE.basic.md +8 -0
- package/templates/workflows/ci.yml +27 -0
- /package/{templates/CLAUDE.static.md → dist/CLAUDE.basic.md} +0 -0
- /package/templates/{cursorrules.static → cursorrules.basic} +0 -0
- /package/templates/{pnpm-workspace.static.yaml → pnpm-workspace.basic.yaml} +0 -0
- /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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
package/scripts/init/start.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: '
|
|
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
|
-
|
|
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
|
|
27
|
-
if (!project_checks.
|
|
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 =
|
|
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))
|
package/scripts/lint/format.ts
CHANGED
|
@@ -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
|
|
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
|
|
68
|
-
// run would have skipped (joshuafolkken/kit#2709). The skip reasons answer nothing for a
|
|
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 [
|
|
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 {
|
|
@@ -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 = {
|
|
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(
|
|
246
|
-
const references = issue_citation.
|
|
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
|
|
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.
|
package/scripts/run/run-hold.ts
CHANGED
|
@@ -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 {
|