@joshuafolkken/kit 1.929.0 → 1.953.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/background-commands.md +6 -0
- package/.claude/skills/workflow-commands/backlogrun-child.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-lanes.md +4 -3
- package/.claude/skills/workflow-commands/observation-filing.md +3 -3
- package/.claude/skills/workflow-commands/prerequisite.md +1 -1
- package/.github/workflows/ci.yml +26 -8
- package/.github/workflows/pr-classification.yml +76 -0
- package/README.md +44 -22
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-GQ3EVWHY.js → chunk-452OKRQX.js} +421 -195
- package/dist/hooks/{chunk-2PGRQ62K.js → chunk-EYFJ74MJ.js} +11 -6
- package/dist/hooks/{chunk-LCJZE42I.js → chunk-IA7WC4NS.js} +1 -1
- package/dist/hooks/{chunk-BNFG2IFK.js → chunk-IBFC25IX.js} +4 -4
- package/dist/hooks/{chunk-I3JP3D4A.js → chunk-M4U4YNRE.js} +3 -3
- package/dist/hooks/{chunk-NJZSO7BK.js → chunk-NKVLBB65.js} +3 -3
- package/dist/hooks/{chunk-SXPTRFPG.js → chunk-O3PEDF3M.js} +1 -1
- package/dist/hooks/{chunk-7XQ6TK3R.js → chunk-OM5VDM6F.js} +2 -2
- package/dist/hooks/{chunk-ROULPSQC.js → chunk-V7RH56HU.js} +2 -2
- package/dist/hooks/{chunk-YO43HEPY.js → chunk-Z5TVZ4AP.js} +237 -235
- 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 +9 -3
- package/dist/josh.js +11 -6
- package/dist/version/index.js +7 -4
- package/package.json +13 -1
- package/prompts/collaboration-workflow/issue-template.md +1 -1
- package/scripts/ci/pr-classification.ts +27 -6
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/gate/hook-gate-reuse.ts +1 -1
- package/scripts/gate/project-checks.ts +45 -15
- package/scripts/gate/type-check-command.ts +43 -0
- package/scripts/git/constants.ts +4 -0
- package/scripts/git/git-command.ts +11 -5
- package/scripts/git/git-gh-pr-rollup.ts +56 -16
- package/scripts/git/git-staging.ts +6 -4
- package/scripts/git/git-stash.ts +73 -1
- package/scripts/git/git-worktree.ts +35 -4
- package/scripts/git/repository-lock.ts +138 -0
- package/scripts/git/stash-orphans.ts +24 -2
- package/scripts/git/stash-sweep-lock.ts +30 -0
- package/scripts/hooks/stop-guard.ts +2 -0
- package/scripts/init/init-actions.ts +3 -2
- package/scripts/init/init-logic-migrate.ts +10 -1
- package/scripts/init/init-logic-templates.ts +27 -7
- package/scripts/init/init-logic-workspace.ts +52 -1
- package/scripts/init/init-logic.ts +6 -1
- package/scripts/init/init.ts +60 -19
- package/scripts/issue/issue-merged.ts +75 -0
- package/scripts/issue/measure-rerun-cli.ts +2 -2
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +10 -0
- package/scripts/josh/josh-command-map.ts +1 -0
- package/scripts/josh/josh-command-types.ts +0 -3
- package/scripts/josh/josh-commands-ai.ts +6 -0
- package/scripts/josh/josh-commands-development.ts +5 -5
- package/scripts/lint/lint-parallel.ts +19 -18
- package/scripts/observations/observation-ledger-home.ts +22 -2
- package/scripts/observations/observation-ledger-line.ts +11 -0
- package/scripts/observations/observation-ledger-migrate.ts +146 -0
- package/scripts/observations/observation-ledger.ts +51 -3
- package/scripts/observations/observations-flush.ts +38 -7
- package/scripts/review/review-finding-ledger.ts +1 -1
- package/scripts/review/review-record-cli.ts +1 -7
- package/scripts/rules/delivered-rules.ts +10 -0
- package/scripts/rules/lane-background.ts +111 -0
- package/scripts/rules/stop-rules.ts +15 -2
- package/scripts/run/run-carry-flush.ts +2 -7
- package/scripts/run/run-hold-cli.ts +10 -2
- package/scripts/run/run-tidy-cli.ts +50 -0
- package/scripts/run/run-tidy-lanes.ts +110 -0
- package/scripts/run/run-tidy-stashes.ts +160 -0
- package/scripts/run/run-tidy.ts +101 -0
- package/scripts/safe-chain/ci-installer-pin.ts +90 -0
- package/scripts/safe-chain/preinstall-command.ts +39 -0
- package/scripts/safe-chain/project-config.ts +55 -10
- package/scripts/sync/sync.ts +8 -3
- package/scripts/test/pilot-files.ts +0 -1
- package/scripts/version/preinstall-version-update.ts +2 -0
- package/scripts/version/release-age.ts +11 -6
- package/templates/workflows/ci.yml +21 -7
- package/tsconfig/base.json +4 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
LEGACY_OBSERVATION_LEDGER_PATH,
|
|
5
|
+
MIGRATION_CLAIM_SUFFIX,
|
|
6
|
+
OBSERVATION_LEDGER_PATH,
|
|
7
|
+
} from './observation-ledger'
|
|
8
|
+
import { observation_ledger_line } from './observation-ledger-line'
|
|
9
|
+
|
|
10
|
+
// Moves the ledger's lines from the path it had before joshuafolkken/kit#2724 to the one it has now
|
|
11
|
+
// (`observation-ledger.ts` carries why both are recognized). Changing the constant alone split the
|
|
12
|
+
// appends across two files: a run still on the old code recreated `docs/observations.md`, and a
|
|
13
|
+
// consumer repository's existing ledger stayed where nothing read it. So resolving the ledger's path
|
|
14
|
+
// migrates first (`observation-ledger-home.ts`), and whatever reached the old path lands at the tail
|
|
15
|
+
// of the new one before anything reads or appends.
|
|
16
|
+
//
|
|
17
|
+
// **The old file is claimed with a rename before it is read.** Two sessions resolving the ledger at
|
|
18
|
+
// once would otherwise both read it and both copy its lines — and a repeated line under one key is
|
|
19
|
+
// what promotes an observation to an issue. A rename succeeds for exactly one of them; the other finds
|
|
20
|
+
// nothing.
|
|
21
|
+
//
|
|
22
|
+
// **A claim whose process is gone is absorbed like the old file itself.** A process killed between the
|
|
23
|
+
// rename and the append leaves the ledger's lines only in the claim file; the next migration finds it
|
|
24
|
+
// by its name and moves it, so no line is stranded where nothing looks.
|
|
25
|
+
//
|
|
26
|
+
// **Synchronous on purpose**: the path resolver every reader and writer calls is synchronous, and a
|
|
27
|
+
// migration with no await point cannot be interleaved with another write in the same process.
|
|
28
|
+
|
|
29
|
+
const NEWLINE = '\n'
|
|
30
|
+
const NO_SIGNAL = 0
|
|
31
|
+
const NO_SUCH_PROCESS = 'ESRCH'
|
|
32
|
+
|
|
33
|
+
function claim_path(legacy: string): string {
|
|
34
|
+
return `${legacy}.${String(process.pid)}${MIGRATION_CLAIM_SUFFIX}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function claim(from: string, to: string): boolean {
|
|
38
|
+
try {
|
|
39
|
+
renameSync(from, to)
|
|
40
|
+
|
|
41
|
+
return true
|
|
42
|
+
} catch {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Only `ESRCH` means the process is gone; `EPERM` means it runs under another user, and its claim is
|
|
48
|
+
// still its own.
|
|
49
|
+
function is_alive(pid: number): boolean {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(pid, NO_SIGNAL)
|
|
52
|
+
|
|
53
|
+
return true
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return (error as NodeJS.ErrnoException).code !== NO_SUCH_PROCESS
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A claim is named `<old ledger>.<pid>.migrating`, so the pid is what sits between the two.
|
|
60
|
+
function is_stale_claim(name: string, legacy_name: string): boolean {
|
|
61
|
+
const prefix = `${legacy_name}.`
|
|
62
|
+
|
|
63
|
+
if (!name.startsWith(prefix) || !name.endsWith(MIGRATION_CLAIM_SUFFIX)) return false
|
|
64
|
+
|
|
65
|
+
const pid = Number(name.slice(prefix.length, -MIGRATION_CLAIM_SUFFIX.length))
|
|
66
|
+
|
|
67
|
+
return Number.isSafeInteger(pid) && !is_alive(pid)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function stale_claims(legacy: string): ReadonlyArray<string> {
|
|
71
|
+
const directory = path.dirname(legacy)
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
return readdirSync(directory)
|
|
75
|
+
.filter((name) => is_stale_claim(name, path.basename(legacy)))
|
|
76
|
+
.map((name) => path.join(directory, name))
|
|
77
|
+
} catch {
|
|
78
|
+
return []
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function terminated(content: string): string {
|
|
83
|
+
return content.length === 0 || content.endsWith(NEWLINE) ? content : `${content}${NEWLINE}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function read_or_undefined(file_path: string): string | undefined {
|
|
87
|
+
try {
|
|
88
|
+
return readFileSync(file_path, 'utf8')
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// What of the claimed file goes to the ledger's tail: all of it, verbatim, when there is no ledger yet,
|
|
95
|
+
// and otherwise only the lines the ledger does not already hold. **The old file can come back after
|
|
96
|
+
// its lines were moved** — `git restore`, `git reset --hard` or a stash without `-u` restores a
|
|
97
|
+
// tracked old ledger beside the untracked new one — and copying it whole again would repeat every line
|
|
98
|
+
// in the history, which the digest reads as every observation recurring.
|
|
99
|
+
function carried(content: string, ledger: string | undefined): string {
|
|
100
|
+
if (ledger === undefined) return terminated(content)
|
|
101
|
+
|
|
102
|
+
const lines = content.split(NEWLINE).filter((line) => line.length > 0)
|
|
103
|
+
const missing = observation_ledger_line.missing_lines(lines, ledger)
|
|
104
|
+
|
|
105
|
+
return missing.length === 0 ? '' : `${missing.join(NEWLINE)}${NEWLINE}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Moves one claimed file's lines to the tail of the ledger, then removes the claim.
|
|
109
|
+
function absorb(claimed: string, target: string): void {
|
|
110
|
+
mkdirSync(path.dirname(target), { recursive: true })
|
|
111
|
+
appendFileSync(target, carried(readFileSync(claimed, 'utf8'), read_or_undefined(target)), 'utf8')
|
|
112
|
+
rmSync(claimed)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function absorb_stale(legacy: string, target: string): number {
|
|
116
|
+
let count = 0
|
|
117
|
+
|
|
118
|
+
for (const stale of stale_claims(legacy)) {
|
|
119
|
+
const claimed = claim_path(legacy)
|
|
120
|
+
|
|
121
|
+
if (!claim(stale, claimed)) continue
|
|
122
|
+
|
|
123
|
+
absorb(claimed, target)
|
|
124
|
+
count += 1
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return count
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Returns whether anything was moved. `root` is the checkout the ledger lives in.
|
|
131
|
+
function migrate(root: string): boolean {
|
|
132
|
+
const legacy = path.join(root, LEGACY_OBSERVATION_LEDGER_PATH)
|
|
133
|
+
const target = path.join(root, OBSERVATION_LEDGER_PATH)
|
|
134
|
+
const recovered = absorb_stale(legacy, target)
|
|
135
|
+
const claimed = claim_path(legacy)
|
|
136
|
+
|
|
137
|
+
if (!claim(legacy, claimed)) return recovered > 0
|
|
138
|
+
|
|
139
|
+
absorb(claimed, target)
|
|
140
|
+
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const observation_ledger_migrate = { claim_path, migrate }
|
|
145
|
+
|
|
146
|
+
export { observation_ledger_migrate }
|
|
@@ -4,7 +4,21 @@
|
|
|
4
4
|
// `observations-flush.ts` is the only thing that stages it — so a literal in each would be three
|
|
5
5
|
// answers to one question, and a rename reaching only some of them would silently restore the
|
|
6
6
|
// contamination the exclusion exists to prevent.
|
|
7
|
-
|
|
7
|
+
//
|
|
8
|
+
// **It sits under `docs/maintainers/`, apart from the reader-facing documents** (joshuafolkken/kit#2724).
|
|
9
|
+
const OBSERVATION_LEDGER_PATH = 'docs/maintainers/observations.md'
|
|
10
|
+
|
|
11
|
+
// Where the ledger lived before joshuafolkken/kit#2724, kept as a migration source rather than
|
|
12
|
+
// forgotten: a run still on the old code, a stash cut before the move and a consumer repository's
|
|
13
|
+
// existing ledger all write here, and `observation-ledger-migrate.ts` moves what it finds to the path
|
|
14
|
+
// above. Every place that recognizes the ledger recognizes both, so a line on either path is never
|
|
15
|
+
// staged into an ordinary commit. Remove it once every repository holding a ledger has migrated.
|
|
16
|
+
const LEGACY_OBSERVATION_LEDGER_PATH = 'docs/observations.md'
|
|
17
|
+
|
|
18
|
+
const OBSERVATION_LEDGER_PATHS: ReadonlyArray<string> = [
|
|
19
|
+
OBSERVATION_LEDGER_PATH,
|
|
20
|
+
LEGACY_OBSERVATION_LEDGER_PATH,
|
|
21
|
+
]
|
|
8
22
|
|
|
9
23
|
// `git status --porcelain` prints `XY PATH`, so the path starts at the fourth character.
|
|
10
24
|
const STATUS_PATH_INDEX = 3
|
|
@@ -13,8 +27,34 @@ function status_path(line: string): string {
|
|
|
13
27
|
return line.slice(STATUS_PATH_INDEX).trim()
|
|
14
28
|
}
|
|
15
29
|
|
|
30
|
+
// The suffix of the file a migration renames the old ledger to while it copies it
|
|
31
|
+
// (`observation-ledger-migrate.ts`): `docs/observations.md.<pid>.migrating`.
|
|
32
|
+
const MIGRATION_CLAIM_SUFFIX = '.migrating'
|
|
33
|
+
|
|
34
|
+
// A claim left by a process killed mid-migration holds ledger lines, so it is the ledger too: never
|
|
35
|
+
// staged by an ordinary commit, and absorbed by the next migration.
|
|
36
|
+
function is_migration_claim(file_path: string): boolean {
|
|
37
|
+
return (
|
|
38
|
+
file_path.startsWith(`${LEGACY_OBSERVATION_LEDGER_PATH}.`) &&
|
|
39
|
+
file_path.endsWith(MIGRATION_CLAIM_SUFFIX)
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function is_ledger_path(file_path: string): boolean {
|
|
44
|
+
return OBSERVATION_LEDGER_PATHS.includes(file_path) || is_migration_claim(file_path)
|
|
45
|
+
}
|
|
46
|
+
|
|
16
47
|
function is_ledger_line(line: string): boolean {
|
|
17
|
-
return status_path(line)
|
|
48
|
+
return is_ledger_path(status_path(line))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The ledger paths a porcelain status names — what a flush stages after a migration, which may be the
|
|
52
|
+
// new file together with the old one's deletion.
|
|
53
|
+
function ledger_paths(status_output: string): ReadonlyArray<string> {
|
|
54
|
+
return status_output
|
|
55
|
+
.split('\n')
|
|
56
|
+
.filter((line) => is_ledger_line(line))
|
|
57
|
+
.map((line) => status_path(line))
|
|
18
58
|
}
|
|
19
59
|
|
|
20
60
|
// **The one place that answers "does this working tree hold a pending observation append?"**
|
|
@@ -29,7 +69,15 @@ function has_pending_append(status_output: string): boolean {
|
|
|
29
69
|
const observation_ledger = {
|
|
30
70
|
has_pending_append,
|
|
31
71
|
is_ledger_line,
|
|
72
|
+
is_ledger_path,
|
|
73
|
+
ledger_paths,
|
|
32
74
|
status_path,
|
|
33
75
|
}
|
|
34
76
|
|
|
35
|
-
export {
|
|
77
|
+
export {
|
|
78
|
+
LEGACY_OBSERVATION_LEDGER_PATH,
|
|
79
|
+
MIGRATION_CLAIM_SUFFIX,
|
|
80
|
+
OBSERVATION_LEDGER_PATH,
|
|
81
|
+
OBSERVATION_LEDGER_PATHS,
|
|
82
|
+
observation_ledger,
|
|
83
|
+
}
|
|
@@ -4,6 +4,7 @@ import { git_gh_command } from '#scripts/git/git-gh-command'
|
|
|
4
4
|
import { main_sync } from '#scripts/git/main-sync'
|
|
5
5
|
import { observation_ledger, OBSERVATION_LEDGER_PATH } from './observation-ledger'
|
|
6
6
|
import { observation_ledger_line, type BrokenLine } from './observation-ledger-line'
|
|
7
|
+
import { observation_ledger_migrate } from './observation-ledger-migrate'
|
|
7
8
|
import { observations_flush_landing } from './observations-flush-landing'
|
|
8
9
|
|
|
9
10
|
// The commit path the observation ledger did not have (joshuafolkken/kit#1756). The parent session
|
|
@@ -78,7 +79,9 @@ function has_ledger_change(status_output: string): boolean {
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
function other_changed_paths(status_output: string): ReadonlyArray<string> {
|
|
81
|
-
return status_paths(status_output).filter(
|
|
82
|
+
return status_paths(status_output).filter(
|
|
83
|
+
(file_path) => !observation_ledger.is_ledger_path(file_path),
|
|
84
|
+
)
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
function message_of(error: unknown): string {
|
|
@@ -295,9 +298,21 @@ function rejection_message(
|
|
|
295
298
|
return roll_back_failure_message(branch_name, reason, roll_back_reason)
|
|
296
299
|
}
|
|
297
300
|
|
|
301
|
+
// **Every ledger path the status names is staged, not the one constant** (joshuafolkken/kit#2724).
|
|
302
|
+
// After a migration the change is the new file together with the old one's deletion, and a commit
|
|
303
|
+
// holding only the first would leave the deletion behind as a change no later flush could tell apart
|
|
304
|
+
// from a fresh append.
|
|
305
|
+
async function stage_ledger(): Promise<void> {
|
|
306
|
+
const ledger_paths = observation_ledger.ledger_paths(await git_command.status())
|
|
307
|
+
|
|
308
|
+
for (const file_path of ledger_paths) {
|
|
309
|
+
await git_command.add_path(file_path)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
298
313
|
async function commit_ledger(branch_name: string, default_branch: string): Promise<void> {
|
|
299
314
|
try {
|
|
300
|
-
await
|
|
315
|
+
await stage_ledger()
|
|
301
316
|
await git_command.commit(COMMIT_MESSAGE)
|
|
302
317
|
} catch (error) {
|
|
303
318
|
const roll_back_reason = await roll_back(branch_name, default_branch)
|
|
@@ -316,7 +331,7 @@ async function open_pull_request(branch_name: string, default_branch: string): P
|
|
|
316
331
|
}
|
|
317
332
|
|
|
318
333
|
// **The checkout goes back to the default branch only on the way that merged.** A `finally` here
|
|
319
|
-
// would check the default branch out after a red check too — and
|
|
334
|
+
// would check the default branch out after a red check too — and the ledger would then
|
|
320
335
|
// revert to main's content, leaving the appended lines on a branch nothing in this checkout points
|
|
321
336
|
// at any more. The next flush would read `has_ledger_change` as false and report `clean`, which is
|
|
322
337
|
// the silent loss the refusals above exist to prevent. Left on the branch, the refusal fires.
|
|
@@ -359,14 +374,30 @@ function broken_lines_message(broken: ReadonlyArray<BrokenLine>): string {
|
|
|
359
374
|
// **A malformed line is refused before a branch is cut, not after.** The ledger path was collected
|
|
360
375
|
// without a line ever being parsed (joshuafolkken/kit#2123), so a broken append could ride a flush
|
|
361
376
|
// through; validating here keeps the committed ledger to its grammar.
|
|
377
|
+
//
|
|
378
|
+
// **A ledger that is not there has no line to break** — the change the status saw is then its
|
|
379
|
+
// deletion, and a flush commits that like any other change to it.
|
|
380
|
+
async function read_ledger(): Promise<string> {
|
|
381
|
+
try {
|
|
382
|
+
return await readFile(OBSERVATION_LEDGER_PATH, 'utf8')
|
|
383
|
+
} catch {
|
|
384
|
+
return ''
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
362
388
|
async function refuse_broken_ledger(): Promise<void> {
|
|
363
|
-
const broken = observation_ledger_line.broken_ledger_lines(
|
|
364
|
-
await readFile(OBSERVATION_LEDGER_PATH, 'utf8'),
|
|
365
|
-
)
|
|
389
|
+
const broken = observation_ledger_line.broken_ledger_lines(await read_ledger())
|
|
366
390
|
|
|
367
391
|
if (broken.length > 0) throw new Error(broken_lines_message(broken))
|
|
368
392
|
}
|
|
369
393
|
|
|
394
|
+
// Lines still on the ledger's old path are moved before anything is validated or staged
|
|
395
|
+
// (joshuafolkken/kit#2724), so the flush commits the move and the lines together.
|
|
396
|
+
async function prepare_ledger(): Promise<void> {
|
|
397
|
+
observation_ledger_migrate.migrate(process.cwd())
|
|
398
|
+
await refuse_broken_ledger()
|
|
399
|
+
}
|
|
400
|
+
|
|
370
401
|
// **Nothing pulls in front of the branch, and that is deliberate.** The ledger is dirty by
|
|
371
402
|
// definition at this point, so `git pull --ff-only` aborts on it in exactly the case a pull would
|
|
372
403
|
// have been for — an upstream flush that already advanced the ledger — and reports a failure about
|
|
@@ -382,7 +413,7 @@ async function flush(now: Date): Promise<string> {
|
|
|
382
413
|
|
|
383
414
|
if (!has_ledger_change(status_output)) return CLEAN_MESSAGE
|
|
384
415
|
|
|
385
|
-
await
|
|
416
|
+
await prepare_ledger()
|
|
386
417
|
|
|
387
418
|
const deferred = await observations_flush_landing.refuse_open_flush(BRANCH_PREFIX)
|
|
388
419
|
if (deferred !== undefined) return deferred
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The review-finding line's grammar, its category vocabulary and the aggregation over it — the single
|
|
2
2
|
// place all three are defined (joshuafolkken/kit#2325). A `/code-review` round's findings evaporated
|
|
3
3
|
// the moment they were fixed, so no one could say which category recurred. They now land in the same
|
|
4
|
-
// append-only ledger the observation lines use (`docs/observations.md`), under a distinct `- rf:`
|
|
4
|
+
// append-only ledger the observation lines use (`docs/maintainers/observations.md`), under a distinct `- rf:`
|
|
5
5
|
// prefix so `observation-ledger-line.ts`'s `- k:` grammar never treats them as its own — one ledger
|
|
6
6
|
// file, one flush path, a second line *type* rather than a second ledger.
|
|
7
7
|
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
|
-
import { appendFile, mkdir } from 'node:fs/promises'
|
|
3
|
-
import path from 'node:path'
|
|
4
2
|
import { fileURLToPath } from 'node:url'
|
|
5
3
|
import { parseArgs } from 'node:util'
|
|
6
4
|
import { observation_ledger_home } from '#scripts/observations/observation-ledger-home'
|
|
@@ -154,11 +152,7 @@ async function run_record(parsed: Parsed, now: Date, ledger_path: string): Promi
|
|
|
154
152
|
|
|
155
153
|
const lines = build_lines(request, today(now))
|
|
156
154
|
|
|
157
|
-
|
|
158
|
-
// below would fail with ENOENT and leave the merge gate unsatisfiable (joshuafolkken/kit#2402).
|
|
159
|
-
// Creating the parent first is the one write path that can bring the ledger into existence there.
|
|
160
|
-
await mkdir(path.dirname(ledger_path), { recursive: true })
|
|
161
|
-
await appendFile(ledger_path, `${lines.join('\n')}\n`, 'utf8')
|
|
155
|
+
await observation_ledger_home.append(ledger_path, lines)
|
|
162
156
|
console.info(confirmation(lines.length, ledger_path))
|
|
163
157
|
|
|
164
158
|
return 0
|
|
@@ -13,6 +13,7 @@ import { issue_bug_label_rule } from './issue-bug-label'
|
|
|
13
13
|
import { issue_fold } from './issue-fold'
|
|
14
14
|
import { issue_scout } from './issue-scout'
|
|
15
15
|
import { josh_git_bare } from './josh-git-bare'
|
|
16
|
+
import { lane_background } from './lane-background'
|
|
16
17
|
import { lane_carry_conflict } from './lane-carry-conflict'
|
|
17
18
|
import { lane_interactive_ask } from './lane-interactive-ask'
|
|
18
19
|
import { lane_park } from './lane-park'
|
|
@@ -360,6 +361,15 @@ const DELIVERED_RULES: ReadonlyArray<DeliveredRule> = [
|
|
|
360
361
|
// verdict is `required`; in the hermetic non-repo suite the git read fails to `exempt`, so
|
|
361
362
|
// `test-declared` claims nothing and the "exactly one rule" invariant over `run-tail`'s fixture holds.
|
|
362
363
|
test_declared_commit.ROW,
|
|
364
|
+
// **The long-running josh command a lane child backgrounds and loses** (joshuafolkken/kit#2704). A
|
|
365
|
+
// headless child's background tasks die with its turn, so a backgrounded `josh gate` / `git` /
|
|
366
|
+
// `followup` is killed before it reports; this refuses the launch and hands back `josh ship
|
|
367
|
+
// --detach`, whose supervisor outlives the turn. **Listed before `run-tail`, whose push it claims in
|
|
368
|
+
// a child**: that row answers a foreground push with "reissue it backgrounded", the reissue this row
|
|
369
|
+
// refuses, so a child is sent to the detached ship on its first refusal instead of through both.
|
|
370
|
+
// Outside a child it claims nothing. A backgrounded bare `josh git` also trips `josh-git-bare`
|
|
371
|
+
// below; this row wins, and `ship --detach` satisfies both. It fires on every occurrence.
|
|
372
|
+
lane_background.ROW,
|
|
363
373
|
// **The one row whose trigger reads a field of the input beside the command**, so it supplies its
|
|
364
374
|
// own tool-name check rather than going through `on_bash_command`: a push step already issued with
|
|
365
375
|
// `run_in_background` is the rule obeyed, and refusing it would charge a run for doing the right
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { cost_blocks } from '#scripts/cost-runtime/cost-blocks'
|
|
2
|
+
import { lane_child_marker } from '#scripts/lane/lane-child-marker'
|
|
3
|
+
import { json_value } from '#scripts/lib/json-value'
|
|
4
|
+
import type { GuardedCall } from '#scripts/time-runtime/time-batch-guard'
|
|
5
|
+
import { time_shell } from '#scripts/time-runtime/time-shell'
|
|
6
|
+
import { time_transcript_line } from '#scripts/time-runtime/time-transcript-line'
|
|
7
|
+
import { run_tail } from './run-tail'
|
|
8
|
+
|
|
9
|
+
// A dispatched lane child is stopped from backgrounding a long-running josh command (joshuafolkken/kit#2704).
|
|
10
|
+
//
|
|
11
|
+
// **A headless child's turn-end is its process's end.** A lane child runs as `claude -p`, and there the
|
|
12
|
+
// background Bash tasks die with the turn — so "a background command's exit re-invokes the session"
|
|
13
|
+
// (`background-commands.md`) does not hold. joshuafolkken/kit#2457 refused a non-detached `josh ship`
|
|
14
|
+
// for exactly that reason, but only `ship`: the #2606 child backgrounded `pnpm josh gate && pnpm josh git
|
|
15
|
+
// -y …` directly, ended its turn, and the gate died at `exit code 143` with the implementation, the
|
|
16
|
+
// scoped checks, a review round and the live verification all done — parked instead of merged.
|
|
17
|
+
//
|
|
18
|
+
// **So the refusal is put at the launch, and it names the detached route.** The region from the gate to
|
|
19
|
+
// the merge belongs to `pnpm josh ship --detach` in a child (`chain-rule.md` step 0), whose supervisor
|
|
20
|
+
// outlives the turn. `ship` itself is not in the set: #2457 already detaches it implicitly in a child.
|
|
21
|
+
//
|
|
22
|
+
// **Its stop-time half reads the same fact from the other side.** A backgrounded task the transcript
|
|
23
|
+
// launched but never saw finish is still running, and in a child the stop about to happen kills it —
|
|
24
|
+
// so the `Stop` hook sends the child back to wait or detach rather than asking for a notify
|
|
25
|
+
// (`stop-rules.ts`). The launch and finish ids are `time_transcript_line`'s, never re-parsed here.
|
|
26
|
+
|
|
27
|
+
// The josh subcommands that run long enough to be backgrounded, as `time_shell` names them — canonical,
|
|
28
|
+
// so the `ga` / `g` / `fu` aliases are covered by the one spelling.
|
|
29
|
+
const LONG_RUNNING_COMMANDS: ReadonlySet<string> = new Set(
|
|
30
|
+
['gate', 'git', 'followup'].map((name) => `${time_shell.JOSH_PREFIX}${name}`),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const BACKGROUND_KEY = 'run_in_background'
|
|
34
|
+
|
|
35
|
+
function is_backgrounded(input: unknown): boolean {
|
|
36
|
+
return json_value.is_record(input) && input[BACKGROUND_KEY] === true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function runs_long_command(command: string): boolean {
|
|
40
|
+
return time_shell.josh_commands_of(command).some((name) => LONG_RUNNING_COMMANDS.has(name))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The call's own fields alone: a backgrounded Bash call that runs a long josh command, or the push step
|
|
44
|
+
// in either spelling. A child's foreground push is claimed too, because `run-tail` would otherwise answer
|
|
45
|
+
// it with "reissue it backgrounded" — the one reissue this row then refuses — so the child is handed the
|
|
46
|
+
// detached route on its first refusal rather than two contradictory ones.
|
|
47
|
+
function is_candidate(call: GuardedCall): boolean {
|
|
48
|
+
if (run_tail.is_push_step_call(call)) return true
|
|
49
|
+
if (call.name !== cost_blocks.BASH_TOOL || !is_backgrounded(call.input)) return false
|
|
50
|
+
|
|
51
|
+
return runs_long_command(time_shell.bash_command(call.input))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// True when this session is a dispatched lane child backgrounding a long-running josh command. The call's
|
|
55
|
+
// own fields are tested first, so the dispatch mark is read only on the handful of calls that match.
|
|
56
|
+
function is_background_long_run(
|
|
57
|
+
call: GuardedCall,
|
|
58
|
+
is_lane_child: () => boolean = () => lane_child_marker.is_child_of(process.cwd()),
|
|
59
|
+
): boolean {
|
|
60
|
+
return is_candidate(call) && is_lane_child()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The background tasks the transcript tail launched and never saw finish. A launch is read off its own
|
|
64
|
+
// tool result and a finish off the harness notice, both by `time_transcript_line`.
|
|
65
|
+
function pending_background_ids(tail: string): ReadonlyArray<string> {
|
|
66
|
+
const lines = tail.split('\n').map((line) => time_transcript_line.parse_line(line))
|
|
67
|
+
const finished = new Set(lines.map((line) => line?.finished_background ?? ''))
|
|
68
|
+
const launched = lines.flatMap((line) => line?.blocks.map((block) => block.background_id) ?? [])
|
|
69
|
+
|
|
70
|
+
return launched.filter((id) => id !== '' && !finished.has(id))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const LANE_BACKGROUND_REASON =
|
|
74
|
+
'⛔ lane background: this session is a dispatched lane child (`JOSH_LANE_CHILD`), a headless ' +
|
|
75
|
+
'`claude -p` process whose background Bash tasks are killed when the turn ends — so a backgrounded ' +
|
|
76
|
+
'`josh gate` / `josh git` / `josh followup` never re-invokes you; it dies at `exit code 143` ' +
|
|
77
|
+
'(joshuafolkken/kit#2704, measured on the #2606 child: implementation, review and live verification ' +
|
|
78
|
+
'done, the run parked instead of merged). Hand the gate-to-merge region to the detached supervisor ' +
|
|
79
|
+
'instead: `pnpm josh ship --detach --review "<title> #<N>"` (add `--cite <N>`), then end the turn on ' +
|
|
80
|
+
'`launched` / `busy` — `.claude/skills/workflow-commands/chain-rule.md` step 0 is the single source. A ' +
|
|
81
|
+
'single check you must read before continuing runs in the foreground within the tool timeout; the ' +
|
|
82
|
+
'commit-push step does not — in a child it too belongs to the detached ship, in either spelling. This ' +
|
|
83
|
+
'rule fires on every occurrence, not once per run.'
|
|
84
|
+
|
|
85
|
+
const LANE_BACKGROUND_STOP_REASON =
|
|
86
|
+
'⛔ lane background stop: this is a dispatched lane child (`JOSH_LANE_CHILD`) and a background task ' +
|
|
87
|
+
'it launched has not finished — ending this headless turn kills it, and nothing re-invokes you ' +
|
|
88
|
+
'afterwards (joshuafolkken/kit#2704). Do not send a notification and do not stop: wait for the task ' +
|
|
89
|
+
'in the foreground and act on its result, or re-issue the work through `pnpm josh ship --detach`, ' +
|
|
90
|
+
'whose supervisor outlives the turn (`.claude/skills/workflow-commands/chain-rule.md` step 0). Do ' +
|
|
91
|
+
'not repeat your previous reply.'
|
|
92
|
+
|
|
93
|
+
// The row itself, so `delivered-rules.ts` spreads one entry. It fires on every occurrence (`decide`
|
|
94
|
+
// returns true): a child must never background these, so the route is always the detached ship, never
|
|
95
|
+
// a reissue. It declares no `keeps`: the detached ship is a different command, not this call made right.
|
|
96
|
+
const ROW = {
|
|
97
|
+
id: 'lane-background',
|
|
98
|
+
is_trigger: (call: GuardedCall): boolean => is_background_long_run(call),
|
|
99
|
+
reason: LANE_BACKGROUND_REASON,
|
|
100
|
+
decide: (): boolean => true,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const lane_background = {
|
|
104
|
+
LANE_BACKGROUND_REASON,
|
|
105
|
+
LANE_BACKGROUND_STOP_REASON,
|
|
106
|
+
ROW,
|
|
107
|
+
is_background_long_run,
|
|
108
|
+
pending_background_ids,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export { lane_background }
|
|
@@ -2,6 +2,7 @@ import { hook_decision } from '#scripts/josh/hook-decision'
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { filing_offer } from './filing-offer'
|
|
4
4
|
import { issue_citation } from './issue-citation'
|
|
5
|
+
import { lane_background } from './lane-background'
|
|
5
6
|
import { reply_language } from './reply-language'
|
|
6
7
|
|
|
7
8
|
// The stop-time rules, delivered on the `Stop` hook (joshuafolkken/kit#2121, joshuafolkken/kit#2422,
|
|
@@ -66,6 +67,9 @@ interface StopContext {
|
|
|
66
67
|
headless_refusals: number
|
|
67
68
|
// This session is a dispatched lane child for this checkout — `lane_child_marker.is_child_of`.
|
|
68
69
|
lane_child: boolean
|
|
70
|
+
// A background task this run launched has not finished — `lane_background.pending_background_ids`
|
|
71
|
+
// over the transcript tail (joshuafolkken/kit#2704).
|
|
72
|
+
background_pending: boolean
|
|
69
73
|
// The resolved `JOSH_SESSION_LANG` — `session_language.resolve_session_lang` (joshuafolkken/kit#2470).
|
|
70
74
|
session_lang: string
|
|
71
75
|
}
|
|
@@ -256,10 +260,19 @@ function count_headless_refusals(tail: string): number {
|
|
|
256
260
|
return tail.slice(Math.max(last_wait, 0)).split(HEADLESS_WAIT_MARKER).length - 1
|
|
257
261
|
}
|
|
258
262
|
|
|
259
|
-
//
|
|
260
|
-
//
|
|
263
|
+
// **A lane child with a task still running is sent back to wait, not to notify** (joshuafolkken/kit#2704).
|
|
264
|
+
// Its turn-end kills the task, so the notify the hold rules would ask for announces a stop that loses
|
|
265
|
+
// the work; the #2606 child obeyed exactly that and its gate died at `exit code 143`.
|
|
266
|
+
function needs_background_wait(context: StopContext): boolean {
|
|
267
|
+
return context.lane_child && context.background_pending
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The two hold rules, notify ahead of release — behind the lane index route (joshuafolkken/kit#2445)
|
|
271
|
+
// and the lane background wait (joshuafolkken/kit#2704), since the notify they would ask for announces
|
|
272
|
+
// a pause the run has no reason to take.
|
|
261
273
|
function hold_reason(context: StopContext): string | undefined {
|
|
262
274
|
if (needs_index_route(context)) return LANE_INDEX_REASON
|
|
275
|
+
if (needs_background_wait(context)) return lane_background.LANE_BACKGROUND_STOP_REASON
|
|
263
276
|
if (needs_notify(context)) return STOP_NOTIFY_REASON
|
|
264
277
|
if (needs_release(context)) return HOLD_RELEASE_REASON
|
|
265
278
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PORCELAIN_FLAG, UNTRACKED_FILES_FLAG } from '#scripts/git/constants'
|
|
1
2
|
import { git_spawn } from '#scripts/git/git-spawn'
|
|
2
3
|
import { josh_command } from '#scripts/josh/josh-run'
|
|
3
4
|
import { observation_ledger } from '#scripts/observations/observation-ledger'
|
|
@@ -31,13 +32,7 @@ const FAILURE_NOTE =
|
|
|
31
32
|
|
|
32
33
|
async function has_pending_append(): Promise<boolean> {
|
|
33
34
|
const root = observation_ledger_home.ledger_root()
|
|
34
|
-
const status = await git_spawn.read([
|
|
35
|
-
'-C',
|
|
36
|
-
root,
|
|
37
|
-
'status',
|
|
38
|
-
'--porcelain',
|
|
39
|
-
'--untracked-files=normal',
|
|
40
|
-
])
|
|
35
|
+
const status = await git_spawn.read(['-C', root, 'status', PORCELAIN_FLAG, UNTRACKED_FILES_FLAG])
|
|
41
36
|
|
|
42
37
|
return observation_ledger.has_pending_append(status)
|
|
43
38
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { run_hold, type HoldRead, type RunHold } from './run-hold'
|
|
4
4
|
import { run_preflight, type PreflightDecision } from './run-preflight'
|
|
5
|
+
import { run_tidy_cli } from './run-tidy-cli'
|
|
5
6
|
|
|
6
7
|
// `josh run:hold [<N>]` and `josh run:release [<N> | --force]` — the working-tree guard the typed
|
|
7
8
|
// entry points ask before they start (joshuafolkken/kit#1091).
|
|
@@ -28,6 +29,10 @@ import { run_preflight, type PreflightDecision } from './run-preflight'
|
|
|
28
29
|
// The contract is `epic:next`'s: **standard output carries exactly one token** — `hold`, `busy` or
|
|
29
30
|
// `unknown` — and every explanation goes to standard error, so `answer=$(pnpm josh run:hold 1091)`
|
|
30
31
|
// captures something a loop can branch on.
|
|
32
|
+
//
|
|
33
|
+
// **A successful claim then sweeps merged residue** — `run:tidy` (joshuafolkken/kit#2701). After the
|
|
34
|
+
// claim rather than before it: the sweep may append to the primary checkout's observation ledger, and
|
|
35
|
+
// doing that ahead of the preflight would turn a clean primary checkout into a `reclaim`.
|
|
31
36
|
|
|
32
37
|
const SUCCESS_EXIT_CODE = 0
|
|
33
38
|
const FAILURE_EXIT_CODE = 1
|
|
@@ -181,9 +186,12 @@ async function claim(target: string, issue: string, is_linked: boolean): Promise
|
|
|
181
186
|
|
|
182
187
|
if (blocked !== undefined) return report_busy(blocked)
|
|
183
188
|
|
|
184
|
-
|
|
189
|
+
const code =
|
|
190
|
+
read.kind === 'stale' ? replace_stale(target, issue, read.hold) : take_free_tree(target, issue)
|
|
185
191
|
|
|
186
|
-
|
|
192
|
+
if (code === SUCCESS_EXIT_CODE) await run_tidy_cli.sweep()
|
|
193
|
+
|
|
194
|
+
return code
|
|
187
195
|
}
|
|
188
196
|
|
|
189
197
|
function remove_record(target: string, was_present: boolean): number {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { issue_merged } from '#scripts/issue/issue-merged'
|
|
4
|
+
import { run_tidy } from './run-tidy'
|
|
5
|
+
import { run_tidy_lanes, type IsMerged } from './run-tidy-lanes'
|
|
6
|
+
import { run_tidy_stashes } from './run-tidy-stashes'
|
|
7
|
+
|
|
8
|
+
// `josh run:tidy` — sweep what merged work left behind at a run's start (joshuafolkken/kit#2701): the
|
|
9
|
+
// lanes still holding a seat after their issue merged, and the stashes whose issues are all merged.
|
|
10
|
+
// Both used to wait for a person — `lane:list` flagged a merged lane, `run:carry --end` printed the
|
|
11
|
+
// closed-issue stashes — and a batch ran with two of six seats held by finished work until someone did.
|
|
12
|
+
//
|
|
13
|
+
// `run:hold` calls `sweep` once a claim succeeds, so every `fullrun` / `halfrun` start runs it; a
|
|
14
|
+
// `backlogrun` runs the command itself in its once-per-repository preparation, beside `lane:prune`.
|
|
15
|
+
// **The sweep never fails the run that called it**: the report goes to standard error, where
|
|
16
|
+
// `run:hold`'s one-token standard output is not disturbed, and a failed read is a note, not an exit.
|
|
17
|
+
|
|
18
|
+
const FAILURE_NOTE = 'run:tidy could not finish; merged lanes and stashes may remain:'
|
|
19
|
+
|
|
20
|
+
// One read per issue across both halves — a lane and its stash usually name the same one.
|
|
21
|
+
function memoized(read: IsMerged): IsMerged {
|
|
22
|
+
const answers = new Map<string, Promise<boolean>>()
|
|
23
|
+
|
|
24
|
+
return async function read_once(issue: string): Promise<boolean> {
|
|
25
|
+
const cached = answers.get(issue) ?? read(issue)
|
|
26
|
+
|
|
27
|
+
answers.set(issue, cached)
|
|
28
|
+
|
|
29
|
+
return await cached
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function sweep(): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
const read_merged = memoized(issue_merged.read_merged)
|
|
36
|
+
const lanes = await run_tidy_lanes.tidy_lanes(read_merged)
|
|
37
|
+
const stashes = await run_tidy_stashes.tidy_stashes(read_merged)
|
|
38
|
+
const report = run_tidy.format_report([...lanes, ...stashes])
|
|
39
|
+
|
|
40
|
+
if (report !== undefined) console.error(report)
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error(FAILURE_NOTE, error)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const run_tidy_cli = { sweep }
|
|
47
|
+
|
|
48
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) await sweep()
|
|
49
|
+
|
|
50
|
+
export { run_tidy_cli }
|