@joshuafolkken/kit 1.957.0 → 1.972.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.claude/skills/workflow-commands/background-commands.md +3 -0
  2. package/.claude/skills/workflow-commands/backlogrun-child.md +75 -120
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +78 -115
  4. package/.claude/skills/workflow-commands/backlogrun-progress.md +82 -146
  5. package/.claude/skills/workflow-commands/backlogrun-steps.md +173 -229
  6. package/.claude/skills/workflow-commands/backlogrun.md +3 -1
  7. package/.claude/skills/workflow-commands/followup.md +3 -0
  8. package/.claude/skills/workflow-commands/fullrun.md +3 -4
  9. package/.claude/skills/workflow-commands/observation-filing.md +31 -25
  10. package/.claude/skills/workflow-commands/pre-gate-cut.md +96 -161
  11. package/.github/workflows/pr-classification.yml +12 -1
  12. package/CLAUDE.md +3 -3
  13. package/README.md +3 -3
  14. package/dist/CLAUDE.md +3 -3
  15. package/dist/hooks/batch-guard.js +4 -4
  16. package/dist/hooks/{chunk-XSL4SRMA.js → chunk-2K52BRWV.js} +2 -2
  17. package/dist/hooks/{chunk-M6K7EQDO.js → chunk-BNWIOJFU.js} +29 -16
  18. package/dist/hooks/{chunk-KOCK5FXQ.js → chunk-BVMSPG3M.js} +3 -3
  19. package/dist/hooks/{chunk-3ON7HOYB.js → chunk-CRGJTD6E.js} +1 -1
  20. package/dist/hooks/{chunk-HJXQZ7V4.js → chunk-F6OM6SIG.js} +3 -3
  21. package/dist/hooks/{chunk-LDQDZYHX.js → chunk-KQG4776P.js} +1 -1
  22. package/dist/hooks/{chunk-244WYOEO.js → chunk-NXL3SBXY.js} +2 -2
  23. package/dist/hooks/{chunk-IJPBTDTB.js → chunk-WWTMHHZU.js} +59 -18
  24. package/dist/hooks/{chunk-BELI5XSS.js → chunk-ZPLXLJ7O.js} +15 -5
  25. package/dist/hooks/{chunk-GNOITLRR.js → chunk-ZYYSBNR6.js} +4 -4
  26. package/dist/hooks/codex-hook-adapter.js +10 -10
  27. package/dist/hooks/duplicate-read-guard.js +5 -5
  28. package/dist/hooks/format-edited.js +2 -2
  29. package/dist/hooks/investigation-guard.js +5 -5
  30. package/dist/hooks/pretool-guard.js +9 -9
  31. package/dist/hooks/stop-guard.js +206 -176
  32. package/dist/josh.js +8 -4
  33. package/package.json +3 -3
  34. package/prompts/collaboration-workflow/wip-cap.md +1 -1
  35. package/scripts/backlog/backlog-arrival.ts +11 -1
  36. package/scripts/backlog/backlog-budget.ts +25 -8
  37. package/scripts/backlog/backlog-next.ts +45 -2
  38. package/scripts/backlog/backlog-offer.ts +1 -0
  39. package/scripts/backlog/backlog-plan-cli.ts +66 -27
  40. package/scripts/backlog/backlog-plan.ts +30 -2
  41. package/scripts/backlog/backlog-stalled.ts +9 -0
  42. package/scripts/backlog/backlog-waves.ts +169 -0
  43. package/scripts/document/document-byte-budget.ts +1 -1
  44. package/scripts/document/entry-read-budget.ts +7 -5
  45. package/scripts/epic/epic-lane-offer.ts +69 -4
  46. package/scripts/epic/epic-next.ts +2 -2
  47. package/scripts/epic/epic-solo.ts +147 -0
  48. package/scripts/epic/epic-triage.ts +47 -0
  49. package/scripts/gate/hook-gate-reuse.ts +7 -8
  50. package/scripts/git/git-closes-keyword.ts +15 -0
  51. package/scripts/git/git-followup-flush.ts +5 -6
  52. package/scripts/git/git-followup-issue-close.ts +75 -0
  53. package/scripts/git/git-pr-followup-wrapup.ts +36 -6
  54. package/scripts/git/git-pr-followup.ts +9 -16
  55. package/scripts/git/git-staging.ts +33 -20
  56. package/scripts/git/issue-labels.ts +23 -0
  57. package/scripts/hooks/stop-guard.ts +1 -0
  58. package/scripts/init/init-install.ts +64 -0
  59. package/scripts/init/init.ts +38 -11
  60. package/scripts/issue/issue-closing-pr.ts +93 -0
  61. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
  62. package/scripts/josh/josh-commands-project.ts +5 -1
  63. package/scripts/josh/josh-commands-workflow.ts +3 -3
  64. package/scripts/observations/observation-ledger-prepare.ts +31 -0
  65. package/scripts/observations/observation-ledger.ts +5 -5
  66. package/scripts/observations/observations-flush.ts +11 -30
  67. package/scripts/rules/implementation-cut.ts +49 -16
  68. package/scripts/rules/lane-background.ts +25 -5
  69. package/scripts/rules/stop-rules.ts +16 -4
  70. package/scripts/run/run-carry-cli.ts +21 -0
  71. package/scripts/run/run-cut-cli.ts +92 -110
  72. package/scripts/run/run-cut-report.ts +167 -0
  73. package/scripts/run/run-cut.ts +23 -7
  74. package/scripts/run/run-entry-cli.ts +36 -2
  75. package/scripts/run/run-headless.ts +1 -0
  76. package/scripts/run/run-hold-cli.ts +36 -15
  77. package/scripts/run/run-hold.ts +15 -4
  78. package/scripts/run/run-merge-cli.ts +28 -14
  79. package/scripts/run/run-merge-steps.ts +38 -2
  80. package/scripts/run/run-merge.ts +41 -3
  81. package/scripts/run/run-prep-cli.ts +8 -2
  82. package/scripts/run/run-prep-locate.ts +165 -0
  83. package/scripts/run/run-prep.ts +12 -1
  84. package/scripts/run/run-step-cli.ts +16 -0
  85. package/scripts/run/run-step.ts +28 -4
  86. package/scripts/run/run-tail-cli.ts +4 -2
  87. package/scripts/run/run-wake-work.ts +4 -0
  88. package/scripts/safe-chain/ci-installer-pin.ts +8 -4
  89. package/scripts/time-runtime/time-background.ts +11 -0
  90. package/scripts/time-runtime/time-transcript-line.ts +4 -0
  91. package/templates/pnpm-workspace.static.yaml +2 -0
@@ -78,6 +78,7 @@ async function build_context(
78
78
  headless_refusals: stop_rules.count_headless_refusals(tail),
79
79
  lane_child: lane_child_marker.is_child_of(process.cwd()),
80
80
  background_pending: lane_background.pending_background_ids(tail).length > 0,
81
+ agent_pending: lane_background.pending_agent_ids(tail).length > 0,
81
82
  session_lang: session_lang(),
82
83
  }
83
84
  }
@@ -0,0 +1,64 @@
1
+ import { execaSync } from 'execa'
2
+
3
+ interface InstallStep {
4
+ label: string
5
+ command: string
6
+ args: ReadonlyArray<string>
7
+ }
8
+
9
+ interface InstallRequest {
10
+ is_install: boolean
11
+ rest: ReadonlyArray<string>
12
+ }
13
+
14
+ const NO_INSTALL_FLAG = '--no-install'
15
+ const INSTALL_STEP: InstallStep = { label: 'pnpm install', command: 'pnpm', args: ['install'] }
16
+ const FORMAT_STEP: InstallStep = {
17
+ label: 'pnpm josh format',
18
+ command: 'pnpm',
19
+ args: ['exec', 'josh', 'format'],
20
+ }
21
+ const INSTALL_FAILURE = [
22
+ `${INSTALL_STEP.label} failed — fix the cause above,`,
23
+ `then run it manually: ${INSTALL_STEP.label} && ${FORMAT_STEP.label}`,
24
+ ].join(' ')
25
+ const FORMAT_WARNING = [
26
+ ` ⚠ ${FORMAT_STEP.label} left problems it could not fix in the project's own files —`,
27
+ `the setup itself succeeded; fix them, then run: ${FORMAT_STEP.label}`,
28
+ ].join(' ')
29
+
30
+ // The flag is peeled off before the `--profile` pair is parsed, so the profile parser stays the one
31
+ // `josh start` shares.
32
+ function split_install_flag(args: ReadonlyArray<string>): InstallRequest {
33
+ return {
34
+ is_install: !args.includes(NO_INSTALL_FLAG),
35
+ rest: args.filter((argument) => argument !== NO_INSTALL_FLAG),
36
+ }
37
+ }
38
+
39
+ function did_step_succeed(step: InstallStep, project_root: string): boolean {
40
+ console.info(`\n$ ${step.label}`)
41
+ const result = execaSync(step.command, step.args, {
42
+ cwd: project_root,
43
+ stdio: 'inherit',
44
+ reject: false,
45
+ })
46
+
47
+ return result.exitCode === 0
48
+ }
49
+
50
+ // Returns the failure to report, or undefined once the install passed. A failed install stops
51
+ // there: `josh format` runs prettier and the kit itself, both of which the install provides. A failed
52
+ // format does not fail `init` — in a node project it also runs `eslint --fix`, which exits non-zero on
53
+ // the project's own unfixable errors, and those are the project's result, not the setup's (the
54
+ // reason `josh gate` is left out of `init` too).
55
+ function run_post_init_steps(project_root: string): string | undefined {
56
+ console.info('\nDependencies:')
57
+ if (!did_step_succeed(INSTALL_STEP, project_root)) return INSTALL_FAILURE
58
+ if (!did_step_succeed(FORMAT_STEP, project_root)) console.warn(FORMAT_WARNING)
59
+
60
+ return undefined
61
+ }
62
+
63
+ const init_install = { NO_INSTALL_FLAG, split_install_flag, run_post_init_steps }
64
+ export { init_install }
@@ -15,6 +15,7 @@ import { execaSync } from 'execa'
15
15
  import { z } from 'zod'
16
16
  import { init_actions, PRETTIER_CONFIG_JS, type FileAction } from './init-actions'
17
17
  import { init_ai_copy } from './init-ai-copy'
18
+ import { init_install } from './init-install'
18
19
  import { init_logic } from './init-logic'
19
20
  import { PACKAGE_DIR, PROJECT_ROOT } from './init-paths'
20
21
  import { init_static } from './init-static'
@@ -28,6 +29,7 @@ const SAMPLE_INDENT_WIDTH = 4
28
29
  const ARGUMENT_START_INDEX = 2
29
30
  const SAMPLE_INDENT = ' '.repeat(SAMPLE_INDENT_WIDTH)
30
31
  const INSTALL_HINT = '→ run `pnpm install` to install what package.json lists'
32
+ const INIT_USAGE = 'josh init [--profile static|node] [--no-install]'
31
33
 
32
34
  function write_new_file(action: FileAction, destination_path: string): void {
33
35
  mkdirSync(path.dirname(destination_path), { recursive: true })
@@ -218,14 +220,10 @@ function merge_project_package_json(shape: ProjectShape): void {
218
220
 
219
221
  writeFileSync(package_json_path, merged)
220
222
  console.info(` ✔ ${is_existing ? 'updated' : 'created'} package.json`)
221
- // `init` writes the manifest but installs nothing, so a tool it just listed is absent until the
222
- // user installs it — and `josh lint` then fails on it rather than skipping it
223
- // (joshuafolkken/kit#2709). Saying so here is what tells the user the next step.
224
- console.info(` ${INSTALL_HINT}`)
225
223
  }
226
224
 
227
- // A fresh project runs `init` before its first `pnpm install`, so lefthook — added to devDependencies
228
- // above — is not there yet; the `prepare` script installs the hooks once it is.
225
+ // Under `--no-install` a fresh project has no lefthook yet — added to devDependencies above — so the
226
+ // `prepare` script of the user's own `pnpm install` installs the hooks once it is.
229
227
  function install_lefthook(project_root: string = PROJECT_ROOT): void {
230
228
  console.info('\nLefthook:')
231
229
  const bin = resolve_local_bin(project_root, LEFTHOOK_BIN)
@@ -285,8 +283,6 @@ function run_ai_file_actions(shape: ProjectShape): void {
285
283
  // resolved, so `gh repo view` runs once; the position is the pre-existing one.
286
284
  const name_with_owner = init_ai_copy.run_ai_copies(shape)
287
285
 
288
- if (shape.has_git && shape.profile === 'node') install_lefthook()
289
-
290
286
  if (shape.has_github) report_repository_settings(name_with_owner)
291
287
 
292
288
  if (shape.profile === 'node') plugin_install_hint_module.report_plugin_install_hint()
@@ -302,19 +298,50 @@ function initialize_project(shape: ProjectShape): void {
302
298
 
303
299
  run_ai_file_actions(shape)
304
300
  if (shape.profile === 'node') project_config.sync_project_config(PROJECT_ROOT)
305
- console.info('\n✅ Done.\n')
301
+ }
302
+
303
+ // A tool `init` just listed is absent until the user installs it — and `josh lint` then fails on it
304
+ // rather than skipping it (joshuafolkken/kit#2709). Saying so is what tells the user the next step.
305
+ function report_manual_install(shape: ProjectShape): void {
306
+ console.info(`\nDependencies:\n ${INSTALL_HINT}`)
307
+ if (shape.has_git && shape.profile === 'node') install_lefthook()
308
+ }
309
+
310
+ // The install runs the `prepare` script, which installs the git hooks. A failure is thrown rather
311
+ // than printed, so `josh start` — which calls this entry point — stops before its first commit.
312
+ function finish_dependencies(shape: ProjectShape, is_install: boolean): void {
313
+ if (!is_install) {
314
+ report_manual_install(shape)
315
+
316
+ return
317
+ }
318
+
319
+ const failure = init_install.run_post_init_steps(PROJECT_ROOT)
320
+ if (failure !== undefined) throw new Error(failure)
306
321
  }
307
322
 
308
323
  function main(args: ReadonlyArray<string> = []): void {
309
324
  if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return
310
- const requested = project_profile.requested_profile(args)
325
+ const { is_install, rest } = init_install.split_install_flag(args)
326
+ const requested = project_profile.requested_profile(rest, INIT_USAGE)
311
327
  const shape = project_profile.inspect_project(PROJECT_ROOT, requested)
312
328
 
313
329
  initialize_project(shape)
330
+ finish_dependencies(shape, is_install)
331
+ console.info('\n✅ Done.\n')
332
+ }
333
+
334
+ function run_cli(args: ReadonlyArray<string>): void {
335
+ try {
336
+ main(args)
337
+ } catch (error) {
338
+ console.error(`\n✖ ${error instanceof Error ? error.message : String(error)}\n`)
339
+ process.exitCode = 1
340
+ }
314
341
  }
315
342
 
316
343
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
317
- main(process.argv.slice(ARGUMENT_START_INDEX))
344
+ run_cli(process.argv.slice(ARGUMENT_START_INDEX))
318
345
  }
319
346
 
320
347
  const init = {
@@ -0,0 +1,93 @@
1
+ import { git_closes_keyword } from '#scripts/git/git-closes-keyword'
2
+ import { git_gh_api_path } from '#scripts/git/git-gh-api-path'
3
+ import { git_gh_exec } from '#scripts/git/git-gh-exec'
4
+ import { git_gh_repo } from '#scripts/git/git-gh-repo'
5
+ import { parse_json_object_safe } from '#scripts/git/parse-json-array'
6
+ import { z } from 'zod'
7
+
8
+ // **The merged pull request that closes an issue GitHub left open** (joshuafolkken/kit#2769). A
9
+ // `backlogrun` child whose pull request merged while GitHub did not apply its `closes #N`
10
+ // (joshuafolkken/kit#2770) came back OPEN, and `run:merge` counted it a failure and parked it — the
11
+ // work had landed, only the issue's close had not.
12
+ //
13
+ // **A merged reference is not enough; the body must close this issue.** A merged pull request that
14
+ // merely mentions the issue — a follow-up, a sibling in the same epic — cross-references it too, and
15
+ // closing on that would close unfinished work. So the pull request counts only when its body's
16
+ // `closes #N` names this issue, read through the one parser every `closes #N` reader shares.
17
+ //
18
+ // **Only references after the last reopen count.** A reopened issue whose earlier attempt merged
19
+ // still carries that pull request's reference, and reading it would close the reopened work — the
20
+ // same reason `issue-merged.ts` reads the last closed/reopened event.
21
+ //
22
+ // **Only a pull request in this repository counts.** A bare `closes #N` names an issue in the pull
23
+ // request's own repository, so a merged pull request elsewhere whose `closes #N` carries the same
24
+ // number closes a different issue — its cross-reference here is a mention, not a close.
25
+
26
+ const LINE_SEPARATOR = '\n'
27
+ // One compact JSON object per merged pull request that cross-references the issue, and a
28
+ // `{"reopened":true}` marker per reopen, in timeline order.
29
+ const TIMELINE_FILTER = [
30
+ '.[]',
31
+ '| if .event == "cross-referenced" and .source.issue.pull_request.merged_at != null',
32
+ 'then {url: .source.issue.html_url, repo: .source.issue.repository.full_name, body: (.source.issue.body // "")}',
33
+ 'elif .event == "reopened" then {reopened: true}',
34
+ 'else empty end',
35
+ ].join(' ')
36
+
37
+ const reference_schema = z.object({ url: z.string(), repo: z.string(), body: z.string() })
38
+ const reopen_schema = z.object({ reopened: z.literal(true) })
39
+ const row_schema = z.union([reference_schema, reopen_schema])
40
+
41
+ type MergedReference = z.infer<typeof reference_schema>
42
+ type TimelineRow = z.infer<typeof row_schema>
43
+
44
+ function is_reference(row: TimelineRow): row is MergedReference {
45
+ return 'url' in row
46
+ }
47
+
48
+ function parse_references(raw: string): Array<MergedReference> {
49
+ const rows = raw
50
+ .split(LINE_SEPARATOR)
51
+ .map((line) => parse_json_object_safe(line, row_schema))
52
+ .filter((row) => row !== undefined)
53
+ const last_reopen = rows.findLastIndex((row) => !is_reference(row))
54
+
55
+ return rows.slice(last_reopen + 1).filter((row) => is_reference(row))
56
+ }
57
+
58
+ function pick_closing_pr(
59
+ references: ReadonlyArray<MergedReference>,
60
+ issue: string,
61
+ repo: string,
62
+ ): string | undefined {
63
+ return references.find(
64
+ (reference) =>
65
+ reference.repo === repo &&
66
+ git_closes_keyword.parse_closes_issue_number(reference.body) === issue,
67
+ )?.url
68
+ }
69
+
70
+ // Every doubt falls to `undefined` — an unreadable timeline or repository name leaves the child
71
+ // judged as it was, never closed on a guess.
72
+ async function read_closing_pr(issue: string): Promise<string | undefined> {
73
+ try {
74
+ const [raw, repo] = await Promise.all([
75
+ git_gh_exec.exec_gh_api({
76
+ path: `${git_gh_api_path.issue_api_path(issue)}/timeline?per_page=100`,
77
+ should_paginate: true,
78
+ jq_filter: TIMELINE_FILTER,
79
+ }),
80
+ git_gh_repo.repo_get_name_with_owner(),
81
+ ])
82
+
83
+ if (repo === undefined) return undefined
84
+
85
+ return pick_closing_pr(parse_references(raw), issue, repo)
86
+ } catch {
87
+ return undefined
88
+ }
89
+ }
90
+
91
+ const issue_closing_pr = { parse_references, pick_closing_pr, read_closing_pr }
92
+
93
+ export { issue_closing_pr }
@@ -449,10 +449,11 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
449
449
  ],
450
450
  ],
451
451
  "init": [
452
- "[--profile static|node]",
452
+ "[--profile static|node] [--no-install]",
453
453
  "developer",
454
454
  [
455
455
  "files",
456
+ "network",
456
457
  "processes",
457
458
  ],
458
459
  ],
@@ -26,7 +26,11 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
26
26
  script: 'scripts/init/init.ts',
27
27
  description: 'Initialize config in a new project',
28
28
  category: 'Project',
29
- reference: [PROFILE_ARGUMENTS, 'developer', ['files', 'processes']],
29
+ reference: [
30
+ `${PROFILE_ARGUMENTS} [--no-install]`,
31
+ 'developer',
32
+ ['files', 'network', 'processes'],
33
+ ],
30
34
  },
31
35
  sync: {
32
36
  script: 'scripts/sync/sync.ts',
@@ -52,9 +52,9 @@ const WORKFLOW_COMMANDS: Record<string, CommandEntry> = {
52
52
  reference: ['--task-type <type> --body <text>', 'automation', ['notifications']],
53
53
  tsx_arguments: OPTIONAL_ENV_FILE_FLAGS,
54
54
  },
55
- // The observation ledger's only commit path (joshuafolkken/kit#1756). `pnpm josh git` excludes the
56
- // ledger from what it stages, so without this command a parent's appended line has no route to the
57
- // default branch at all — and the recurrence count the promotion rule reads is a count of main.
55
+ // The observation ledger's commit path for the lines no run's own commit carried
56
+ // (joshuafolkken/kit#1756, joshuafolkken/kit#2763): a lane's lines sit in the primary checkout, which
57
+ // its commit cannot see, and the recurrence count the promotion rule reads is a count of main.
58
58
  'observations:flush': {
59
59
  script: 'scripts/observations/observations-flush-cli.ts',
60
60
  description: 'Commit the observation ledger as a pull request of its own, and merge it',
@@ -0,0 +1,31 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { OBSERVATION_LEDGER_PATH } from './observation-ledger'
4
+ import { observation_ledger_line, type BrokenLine } from './observation-ledger-line'
5
+ import { observation_ledger_migrate } from './observation-ledger-migrate'
6
+
7
+ // **What has to hold before a ledger line is committed, asked by both of the paths that commit one**
8
+ // (joshuafolkken/kit#2763): `pnpm josh observations:flush` and the run's own commit in
9
+ // `scripts/git/git-staging.ts`. Lines still on the old path are moved first (joshuafolkken/kit#2724),
10
+ // so the commit carries the move and the lines together, and every entry line is then parsed against
11
+ // the grammar (joshuafolkken/kit#2123) — each caller decides what a broken line costs it.
12
+ //
13
+ // **A ledger that is not there has no line to break** — the change a status saw is then its deletion,
14
+ // and a commit carries that like any other change to it.
15
+ async function read_ledger(root: string): Promise<string> {
16
+ try {
17
+ return await readFile(path.join(root, OBSERVATION_LEDGER_PATH), 'utf8')
18
+ } catch {
19
+ return ''
20
+ }
21
+ }
22
+
23
+ async function prepare(root: string): Promise<ReadonlyArray<BrokenLine>> {
24
+ observation_ledger_migrate.migrate(root)
25
+
26
+ return observation_ledger_line.broken_ledger_lines(await read_ledger(root))
27
+ }
28
+
29
+ const observation_ledger_prepare = { prepare }
30
+
31
+ export { observation_ledger_prepare }
@@ -1,9 +1,8 @@
1
1
  // The observation ledger's one path, named here rather than inside any of the places that act on it
2
- // (joshuafolkken/kit#1756). `git-staging.ts` excludes it from every ordinary commit,
3
- // `hook-gate-reuse.ts` excludes it from what a hook calls a carried tree, and
4
- // `observations-flush.ts` is the only thing that stages it — so a literal in each would be three
5
- // answers to one question, and a rename reaching only some of them would silently restore the
6
- // contamination the exclusion exists to prevent.
2
+ // (joshuafolkken/kit#1756). `git-staging.ts` stages it with the run's own commit when its grammar
3
+ // holds (joshuafolkken/kit#2763), `hook-gate-reuse.ts` excludes it from what a hook calls a carried
4
+ // tree, and `observations-flush.ts` commits what no run carried — so a literal in each would be three
5
+ // answers to one question, and a rename reaching only some of them would silently split them.
7
6
  //
8
7
  // **It sits under `docs/maintainers/`, apart from the reader-facing documents** (joshuafolkken/kit#2724).
9
8
  const OBSERVATION_LEDGER_PATH = 'docs/maintainers/observations.md'
@@ -70,6 +69,7 @@ const observation_ledger = {
70
69
  has_pending_append,
71
70
  is_ledger_line,
72
71
  is_ledger_path,
72
+ is_migration_claim,
73
73
  ledger_paths,
74
74
  status_path,
75
75
  }
@@ -1,17 +1,15 @@
1
- import { readFile } from 'node:fs/promises'
2
1
  import { git_command } from '#scripts/git/git-command'
3
2
  import { git_gh_command } from '#scripts/git/git-gh-command'
4
3
  import { main_sync } from '#scripts/git/main-sync'
5
4
  import { observation_ledger, OBSERVATION_LEDGER_PATH } from './observation-ledger'
6
- import { observation_ledger_line, type BrokenLine } from './observation-ledger-line'
7
- import { observation_ledger_migrate } from './observation-ledger-migrate'
5
+ import type { BrokenLine } from './observation-ledger-line'
6
+ import { observation_ledger_prepare } from './observation-ledger-prepare'
8
7
  import { observations_flush_landing } from './observations-flush-landing'
9
8
 
10
- // The commit path the observation ledger did not have (joshuafolkken/kit#1756). The parent session
11
- // that appends a line never runs `pnpm josh git`; a child runs it inside a lane work tree, which
12
- // cannot see the parent's checkout at all; and in the primary checkout `git add -u` swept the line
13
- // into whatever unrelated pull request that run was opening. So the ledger is excluded from ordinary
14
- // staging (`scripts/git/git-staging.ts`) and this is the one thing that stages it.
9
+ // The commit path for the ledger lines no run's own commit carried (joshuafolkken/kit#1756). A run in
10
+ // the primary checkout commits its appended lines with its own pull request (`scripts/git/
11
+ // git-staging.ts`, joshuafolkken/kit#2763), so what is left here is a lane's lines — they sit in the
12
+ // primary checkout, which a lane's commit cannot see — and whatever was appended after a commit.
15
13
  //
16
14
  // **The shape is `scripts/release/release-publish.ts`'s, deliberately.** Both open a pull request of
17
15
  // their own over one path, wait on the same required checks every other pull request waits on, and
@@ -57,7 +55,7 @@ function branch_name_for(stamp: string): string {
57
55
  // single issue, and the ledger's own lines say where each one was seen.
58
56
  function pull_request_body(): string {
59
57
  return [
60
- `Appended observations from \`${OBSERVATION_LEDGER_PATH}\`, which \`pnpm josh git\` never stages.`,
58
+ `Appended observations from \`${OBSERVATION_LEDGER_PATH}\` that no run's own commit carried.`,
61
59
  '',
62
60
  'The ledger is append-only, and a second line under one key is what promotes an observation to an issue — so this pull request adds lines and changes none.',
63
61
  '',
@@ -373,31 +371,14 @@ function broken_lines_message(broken: ReadonlyArray<BrokenLine>): string {
373
371
 
374
372
  // **A malformed line is refused before a branch is cut, not after.** The ledger path was collected
375
373
  // without a line ever being parsed (joshuafolkken/kit#2123), so a broken append could ride a flush
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
-
388
- async function refuse_broken_ledger(): Promise<void> {
389
- const broken = observation_ledger_line.broken_ledger_lines(await read_ledger())
374
+ // through; validating here keeps the committed ledger to its grammar. The migration and the parse are
375
+ // `observation-ledger-prepare.ts`'s, shared with the run's own commit (joshuafolkken/kit#2763).
376
+ async function prepare_ledger(): Promise<void> {
377
+ const broken = await observation_ledger_prepare.prepare(process.cwd())
390
378
 
391
379
  if (broken.length > 0) throw new Error(broken_lines_message(broken))
392
380
  }
393
381
 
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
-
401
382
  // **Nothing pulls in front of the branch, and that is deliberate.** The ledger is dirty by
402
383
  // definition at this point, so `git pull --ff-only` aborts on it in exactly the case a pull would
403
384
  // have been for — an upstream flush that already advanced the ledger — and reports a failure about
@@ -63,6 +63,9 @@ interface LaneCostState {
63
63
  directory: string
64
64
  source: MarkerSource
65
65
  carried: (now?: Date) => RunCut | undefined
66
+ // The issue whose run hold stands over this checkout (joshuafolkken/kit#2760) — how a `fullrun`
67
+ // held outside a lane is told apart from a person's tree.
68
+ held_issue: () => string | undefined
66
69
  verdict: () => CostVerdict
67
70
  }
68
71
 
@@ -71,6 +74,7 @@ function current_state(): LaneCostState {
71
74
  directory: process.cwd(),
72
75
  source: process.env,
73
76
  carried: run_cut.carried_cut_sync,
77
+ held_issue: run_cut.held_issue_sync,
74
78
  // **Read through the short reuse window** (joshuafolkken/kit#2385). The row now fires per
75
79
  // threshold crossing rather than once per run, so this predicate is a candidate on every edit;
76
80
  // the window collapses one turn's burst of edits to a single whole-transcript price.
@@ -79,22 +83,49 @@ function current_state(): LaneCostState {
79
83
  }
80
84
  }
81
85
 
82
- // A dispatched lane child for this checkout with no cut already carried. The dispatch mark is what
83
- // tells a child apart from a person (joshuafolkken/kit#1904), read from the environment against this
84
- // lane's own issue so a leaked mark for another issue reads as a person. **The carried-cut half keeps
85
- // the guard silent between a cut and its resume**, exactly as `pre-gate-cut.ts`'s does: a record naming
86
- // this issue means a cut is already in flight, and `begin_cut`'s exclusive create would refuse a second
87
- // one anyway.
88
- function uncut_lane_child_issue(state: LaneCostState): string | undefined {
86
+ // A dispatched lane child's issue for this checkout. The dispatch mark is what tells a child apart from
87
+ // a person (joshuafolkken/kit#1904), read from the environment against this lane's own issue so a
88
+ // leaked mark for another issue reads as a person.
89
+ function lane_child_issue(state: LaneCostState): string | undefined {
89
90
  if (!lane_child_marker.is_child_of(state.directory, state.source)) return undefined
90
91
 
91
- const issue = lane_paths.lane_issue_of(state.directory, { ...state.source })
92
+ return lane_paths.lane_issue_of(state.directory, { ...state.source })
93
+ }
94
+
95
+ // The issue a run holds this checkout for, read only outside a lane: a lane's hold is its dispatched
96
+ // child's, so a person finishing that lane's work carries no mark and still sees no refusal.
97
+ function held_run_issue(state: LaneCostState): string | undefined {
98
+ if (lane_paths.lane_issue_of(state.directory, { ...state.source }) !== undefined) return undefined
99
+
100
+ return state.held_issue()
101
+ }
102
+
103
+ // The run this checkout's edits belong to, with no cut already carried — a dispatched lane child, or
104
+ // **a `fullrun` held in its own checkout** (joshuafolkken/kit#2760): the cut was lane-only, so a run a
105
+ // person started grew without a bound mid-implementation. The run hold naming an issue is what marks
106
+ // such a run, and a person's tree holds nothing, so it still sees no refusal. **The carried-cut half
107
+ // keeps the guard silent between a cut and its resume**, exactly as `pre-gate-cut.ts`'s does: a record
108
+ // naming this issue means a cut is already in flight, and `begin_cut`'s exclusive create would refuse
109
+ // a second one anyway.
110
+ function uncut_run_issue(state: LaneCostState): string | undefined {
111
+ const issue = lane_child_issue(state) ?? held_run_issue(state)
92
112
 
93
113
  if (issue === undefined) return undefined
94
114
 
95
115
  return state.carried()?.issue === issue ? undefined : issue
96
116
  }
97
117
 
118
+ // **An unmeasurable session is cut in a lane and left alone outside one.** A lane child's safety net is
119
+ // the pre-gate direction below; a held run outside a lane is a person's own session, so only a measured
120
+ // `over` interrupts it — a provider with no transcript would otherwise refuse every edit it makes.
121
+ function warrants_the_cut(state: LaneCostState): boolean {
122
+ const verdict = state.verdict()
123
+
124
+ if (lane_child_issue(state) === undefined) return verdict === cost_verdict.OVER_VERDICT
125
+
126
+ return verdict !== cost_verdict.UNDER_VERDICT
127
+ }
128
+
98
129
  // **The tool-name test comes first and the world is consulted second, the cost measurement last.** This
99
130
  // predicate is asked of every call in the run, so the `Edit` / `Write` string match keeps the lane read
100
131
  // off all but the edits, and the lane read keeps the transcript-priced verdict off every edit but those
@@ -109,9 +140,9 @@ function is_over_threshold_edit(
109
140
  call: GuardedCall,
110
141
  state: LaneCostState = current_state(),
111
142
  ): boolean {
112
- if (!is_edit_tool(call.name) || uncut_lane_child_issue(state) === undefined) return false
143
+ if (!is_edit_tool(call.name) || uncut_run_issue(state) === undefined) return false
113
144
 
114
- return state.verdict() !== cost_verdict.UNDER_VERDICT
145
+ return warrants_the_cut(state)
115
146
  }
116
147
 
117
148
  // **The threshold in the refusal text is assembled from the constant, never spelled** (joshuafolkken/kit#2385).
@@ -125,7 +156,8 @@ const THRESHOLD_TEXT = `${cost_format.format_tokens(CONTEXT_CUT_THRESHOLD)}-toke
125
156
  // because `cut` is the only one that ends the turn and a run told merely to "take the cut" would have to
126
157
  // read which of the others leave it implementing — the same reason `pre-gate-cut.ts` spells them out.
127
158
  const IMPLEMENTATION_CUT_REASON =
128
- '⛔ implementation-phase cut: this checkout is a lane dispatched for this issue and its recent-context ' +
159
+ '⛔ implementation-phase cut: this checkout is a lane dispatched for this issue, or a run holds it for ' +
160
+ 'this issue (joshuafolkken/kit#2760), and its recent-context ' +
129
161
  `cost has crossed the shared ${THRESHOLD_TEXT} threshold mid-implementation, so the thinking accumulated ` +
130
162
  'so far is now re-read on every later request. Take the cut before this edit. ' +
131
163
  'First write a handoff file with the Write tool — the user’s instruction verbatim, what you have ' +
@@ -139,11 +171,12 @@ const IMPLEMENTATION_CUT_REASON =
139
171
  'because the verdict was only ever read at session entry, where the context has not yet grown. The ' +
140
172
  "verdict is `pnpm josh cost --cut`'s exactly — the same per-request billed-input statistic against the " +
141
173
  'same threshold, never a second measurement. Issue `pnpm josh run:cut --impl <N> --handoff <path>` now and read the ' +
142
- 'verdict: on `cut`, **end the turn immediately** — the fresh process owns the run and continues ' +
143
- 'implementing, so it must not be waited for; on `not-a-lane`, `unready`, `busy`, `failed` or ' +
174
+ 'verdict: on `cut`, **end the turn immediately** — in a lane the fresh process owns the run and ' +
175
+ 'continues implementing, so it must not be waited for; outside a lane nothing is relaunched, so first ' +
176
+ 'send the `confirmation` Telegram naming the resume command `fullrun #<N>` and keep the hold; on `not-a-lane`, `unready`, `busy`, `failed` or ' +
144
177
  '`unknown`, this process carries the run on and the edit is simply the next call. Never relaunch a ' +
145
- 'second process after `busy`. This fired because the dispatch mark names this lane; a person working ' +
146
- 'here carries no mark and sees no refusal, so there is no human-or-child judgement left to make. The ' +
178
+ 'second process after `busy`. This fired because the dispatch mark names this lane or the run hold ' +
179
+ 'names this issue; a person working here carries no mark and holds no run, so sees no refusal, so there is no human-or-child judgement left to make. The ' +
147
180
  'procedure is `.claude/skills/workflow-commands/pre-gate-cut.md`. Reissue this edit once the cut has ' +
148
181
  'answered — an edit reissued right after this refusal passes, so `busy` / `failed` cannot wedge the ' +
149
182
  'run edit after edit; this fires again on the next threshold crossing rather than once per run.'
@@ -195,7 +228,7 @@ const implementation_cut = {
195
228
  is_over_threshold_edit,
196
229
  is_reissued_refusal,
197
230
  takes_the_impl_cut,
198
- uncut_lane_child_issue,
231
+ uncut_run_issue,
199
232
  }
200
233
 
201
234
  export type { LaneCostState }
@@ -3,7 +3,10 @@ import { lane_child_marker } from '#scripts/lane/lane-child-marker'
3
3
  import { json_value } from '#scripts/lib/json-value'
4
4
  import type { GuardedCall } from '#scripts/time-runtime/time-batch-guard'
5
5
  import { time_shell } from '#scripts/time-runtime/time-shell'
6
- import { time_transcript_line } from '#scripts/time-runtime/time-transcript-line'
6
+ import {
7
+ time_transcript_line,
8
+ type Block as TranscriptBlock,
9
+ } from '#scripts/time-runtime/time-transcript-line'
7
10
  import { run_tail } from './run-tail'
8
11
 
9
12
  // A dispatched lane child is stopped from backgrounding a long-running josh command (joshuafolkken/kit#2704).
@@ -60,16 +63,32 @@ function is_background_long_run(
60
63
  return is_candidate(call) && is_lane_child()
61
64
  }
62
65
 
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
+ // The launches `launched_ids` reads off the transcript tail that never saw their finish. A launch is
67
+ // read off its own tool result and a finish off the harness notice, both by `time_transcript_line`.
68
+ function unfinished_ids(
69
+ tail: string,
70
+ launched_ids: (block: TranscriptBlock) => ReadonlyArray<string>,
71
+ ): ReadonlyArray<string> {
66
72
  const lines = tail.split('\n').map((line) => time_transcript_line.parse_line(line))
67
73
  const finished = new Set(lines.map((line) => line?.finished_background ?? ''))
68
- const launched = lines.flatMap((line) => line?.blocks.map((block) => block.background_id) ?? [])
74
+ const blocks = lines.flatMap((line) => line?.blocks ?? [])
75
+ const launched = blocks.flatMap((block) => launched_ids(block))
69
76
 
70
77
  return launched.filter((id) => id !== '' && !finished.has(id))
71
78
  }
72
79
 
80
+ // The background tasks the tail launched and never saw finish — commands and subagents alike
81
+ // (joshuafolkken/kit#2774), since a lane child's turn-end kills either.
82
+ function pending_background_ids(tail: string): ReadonlyArray<string> {
83
+ return unfinished_ids(tail, (block) => [block.background_id, block.agent_id])
84
+ }
85
+
86
+ // The backgrounded subagents alone. A subagent always ends and its notice re-invokes the session; a
87
+ // command may be a server that never exits, so only this set says the run is certain to resume.
88
+ function pending_agent_ids(tail: string): ReadonlyArray<string> {
89
+ return unfinished_ids(tail, (block) => [block.agent_id])
90
+ }
91
+
73
92
  const LANE_BACKGROUND_REASON =
74
93
  '⛔ lane background: this session is a dispatched lane child (`JOSH_LANE_CHILD`), a headless ' +
75
94
  '`claude -p` process whose background Bash tasks are killed when the turn ends — so a backgrounded ' +
@@ -105,6 +124,7 @@ const lane_background = {
105
124
  LANE_BACKGROUND_STOP_REASON,
106
125
  ROW,
107
126
  is_background_long_run,
127
+ pending_agent_ids,
108
128
  pending_background_ids,
109
129
  }
110
130