@joshuafolkken/kit 1.957.0 → 1.978.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 (102) 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 +4 -4
  9. package/.claude/skills/workflow-commands/halfrun.md +12 -7
  10. package/.claude/skills/workflow-commands/observation-filing.md +31 -25
  11. package/.claude/skills/workflow-commands/pre-gate-cut.md +96 -161
  12. package/.claude/skills/workflow-commands/working-tree-hold.md +16 -1
  13. package/.github/workflows/pr-classification.yml +12 -1
  14. package/CLAUDE.md +3 -3
  15. package/README.md +25 -21
  16. package/dist/CLAUDE.md +3 -3
  17. package/dist/hooks/batch-guard.js +4 -4
  18. package/dist/hooks/{chunk-XSL4SRMA.js → chunk-2K52BRWV.js} +2 -2
  19. package/dist/hooks/{chunk-M6K7EQDO.js → chunk-BNGGEY3Z.js} +38 -32
  20. package/dist/hooks/{chunk-KOCK5FXQ.js → chunk-BVMSPG3M.js} +3 -3
  21. package/dist/hooks/{chunk-3ON7HOYB.js → chunk-CRGJTD6E.js} +1 -1
  22. package/dist/hooks/{chunk-HJXQZ7V4.js → chunk-F6OM6SIG.js} +3 -3
  23. package/dist/hooks/{chunk-LDQDZYHX.js → chunk-KQG4776P.js} +1 -1
  24. package/dist/hooks/{chunk-244WYOEO.js → chunk-NXL3SBXY.js} +2 -2
  25. package/dist/hooks/{chunk-IJPBTDTB.js → chunk-Q7LE77CO.js} +88 -18
  26. package/dist/hooks/{chunk-BELI5XSS.js → chunk-ZPLXLJ7O.js} +15 -5
  27. package/dist/hooks/{chunk-GNOITLRR.js → chunk-ZYYSBNR6.js} +4 -4
  28. package/dist/hooks/codex-hook-adapter.js +10 -10
  29. package/dist/hooks/duplicate-read-guard.js +5 -5
  30. package/dist/hooks/format-edited.js +2 -2
  31. package/dist/hooks/investigation-guard.js +5 -5
  32. package/dist/hooks/pretool-guard.js +9 -9
  33. package/dist/hooks/stop-guard.js +206 -176
  34. package/dist/josh.js +8 -4
  35. package/package.json +4 -4
  36. package/prompts/collaboration-workflow/wip-cap.md +1 -1
  37. package/scripts/backlog/backlog-arrival.ts +11 -1
  38. package/scripts/backlog/backlog-budget.ts +25 -8
  39. package/scripts/backlog/backlog-next.ts +45 -2
  40. package/scripts/backlog/backlog-offer.ts +1 -0
  41. package/scripts/backlog/backlog-plan-cli.ts +66 -27
  42. package/scripts/backlog/backlog-plan.ts +30 -2
  43. package/scripts/backlog/backlog-stalled.ts +9 -0
  44. package/scripts/backlog/backlog-waves.ts +169 -0
  45. package/scripts/ci/pr-classification.ts +13 -12
  46. package/scripts/document/document-byte-budget.ts +1 -1
  47. package/scripts/document/entry-read-budget.ts +7 -5
  48. package/scripts/epic/epic-lane-offer.ts +69 -4
  49. package/scripts/epic/epic-next.ts +2 -2
  50. package/scripts/epic/epic-solo.ts +147 -0
  51. package/scripts/epic/epic-triage.ts +47 -0
  52. package/scripts/gate/hook-gate-reuse.ts +7 -8
  53. package/scripts/git/git-closes-keyword.ts +15 -0
  54. package/scripts/git/git-followup-flush.ts +5 -6
  55. package/scripts/git/git-followup-issue-close.ts +75 -0
  56. package/scripts/git/git-pr-followup-wrapup.ts +36 -6
  57. package/scripts/git/git-pr-followup.ts +9 -16
  58. package/scripts/git/git-staging.ts +33 -20
  59. package/scripts/git/issue-labels.ts +75 -5
  60. package/scripts/hooks/stop-guard.ts +1 -0
  61. package/scripts/init/init-bootstrap.ts +92 -0
  62. package/scripts/init/init-install.ts +67 -0
  63. package/scripts/init/init-logic-workspace.ts +1 -1
  64. package/scripts/init/init.ts +58 -13
  65. package/scripts/init/start-plan.ts +1 -1
  66. package/scripts/init/start-steps.ts +5 -28
  67. package/scripts/issue/issue-closing-pr.ts +93 -0
  68. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
  69. package/scripts/josh/josh-commands-project.ts +5 -1
  70. package/scripts/josh/josh-commands-workflow.ts +3 -3
  71. package/scripts/observations/observation-ledger-prepare.ts +31 -0
  72. package/scripts/observations/observation-ledger.ts +5 -5
  73. package/scripts/observations/observations-flush.ts +11 -30
  74. package/scripts/repo/repository-labels.ts +91 -0
  75. package/scripts/rules/implementation-cut.ts +49 -16
  76. package/scripts/rules/lane-background.ts +25 -5
  77. package/scripts/rules/stop-rules.ts +16 -4
  78. package/scripts/run/run-carry-cli.ts +21 -0
  79. package/scripts/run/run-cut-cli.ts +92 -110
  80. package/scripts/run/run-cut-report.ts +167 -0
  81. package/scripts/run/run-cut.ts +23 -7
  82. package/scripts/run/run-entry-cli.ts +70 -3
  83. package/scripts/run/run-halfrun-resume.ts +104 -0
  84. package/scripts/run/run-headless.ts +1 -0
  85. package/scripts/run/run-hold-cli.ts +57 -13
  86. package/scripts/run/run-hold.ts +26 -4
  87. package/scripts/run/run-merge-cli.ts +28 -14
  88. package/scripts/run/run-merge-steps.ts +38 -2
  89. package/scripts/run/run-merge.ts +41 -3
  90. package/scripts/run/run-prep-cli.ts +8 -2
  91. package/scripts/run/run-prep-locate.ts +165 -0
  92. package/scripts/run/run-prep.ts +12 -1
  93. package/scripts/run/run-step-cli.ts +16 -0
  94. package/scripts/run/run-step.ts +28 -4
  95. package/scripts/run/run-tail-cli.ts +4 -2
  96. package/scripts/run/run-wake-work.ts +4 -0
  97. package/scripts/safe-chain/ci-installer-pin.ts +8 -4
  98. package/scripts/security/security-audit.ts +12 -1
  99. package/scripts/sync/sync.ts +6 -0
  100. package/scripts/time-runtime/time-background.ts +11 -0
  101. package/scripts/time-runtime/time-transcript-line.ts +4 -0
  102. package/templates/pnpm-workspace.static.yaml +2 -0
@@ -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
@@ -0,0 +1,91 @@
1
+ import { git_gh_exec } from '#scripts/git/git-gh-exec'
2
+ import { REPOSITORY_LABELS, type LabelDefinition } from '#scripts/git/issue-labels'
3
+
4
+ // Creates the labels kit's workflow and the distributed `pr-classification.yml` rely on, and only
5
+ // those the repository is missing (joshuafolkken/kit#2797). The one place labels are created: `josh
6
+ // start` provisions a new repository through it and `josh sync` brings every existing one up to date.
7
+ //
8
+ // Labels already present are left as they are, so a repository that recolored one keeps its choice
9
+ // and a second run changes nothing. GitHub compares label names case-insensitively.
10
+ //
11
+ // **Never throws.** A token without the scope or no network must not stop a sync halfway; the names
12
+ // still missing are printed instead, so the person can create them by hand.
13
+
14
+ function labels_endpoint(repo: string): string {
15
+ return `repos/${repo}/labels`
16
+ }
17
+
18
+ // A failed request is an answer to report, never an exception to propagate (see above).
19
+ function request(api: Parameters<typeof git_gh_exec.exec_gh_api_sync>[0]): string | undefined {
20
+ try {
21
+ return git_gh_exec.exec_gh_api_sync(api)
22
+ } catch {
23
+ return undefined
24
+ }
25
+ }
26
+
27
+ function read_existing(repo: string): ReadonlyArray<string> | undefined {
28
+ const path = labels_endpoint(repo)
29
+ const listing = request({ path, should_paginate: true, jq_filter: '.[].name' })
30
+
31
+ return listing?.split('\n').filter((name) => name.length > 0)
32
+ }
33
+
34
+ function missing_labels(existing: ReadonlyArray<string>): ReadonlyArray<LabelDefinition> {
35
+ const names = new Set(existing.map((name) => name.toLowerCase()))
36
+
37
+ return REPOSITORY_LABELS.filter((label) => !names.has(label.name.toLowerCase()))
38
+ }
39
+
40
+ function create_label(label: LabelDefinition, repo: string): boolean {
41
+ const body = JSON.stringify(label)
42
+ const is_created = request({ path: labels_endpoint(repo), method: 'POST', body }) !== undefined
43
+
44
+ if (is_created) console.info(` ✔ label ${label.name}`)
45
+
46
+ return is_created
47
+ }
48
+
49
+ function label_names(labels: ReadonlyArray<LabelDefinition>): ReadonlyArray<string> {
50
+ return labels.map((label) => label.name)
51
+ }
52
+
53
+ // An unreadable listing is not an empty one: nothing is created, and every label is reported as
54
+ // unconfirmed rather than as missing. An unresolved repository (no GitHub remote) is the same case.
55
+ function unreadable(repo: string | undefined): ReadonlyArray<string> {
56
+ const names = label_names(REPOSITORY_LABELS)
57
+ const target = repo ?? 'this repository'
58
+
59
+ console.warn(
60
+ ` ⚠ could not read the labels of ${target}; make sure these exist: ${names.join(', ')}`,
61
+ )
62
+
63
+ return names
64
+ }
65
+
66
+ function create_missing(
67
+ missing: ReadonlyArray<LabelDefinition>,
68
+ repo: string,
69
+ ): ReadonlyArray<string> {
70
+ const names = label_names(missing.filter((label) => !create_label(label, repo)))
71
+
72
+ if (names.length > 0) {
73
+ console.warn(` ⚠ could not create these labels; create them by hand: ${names.join(', ')}`)
74
+ }
75
+
76
+ return names
77
+ }
78
+
79
+ // The label names not confirmed present afterwards — empty when the repository has every one.
80
+ function ensure_labels(repo: string | undefined): ReadonlyArray<string> {
81
+ if (repo === undefined) return unreadable(repo)
82
+
83
+ const existing = read_existing(repo)
84
+ if (existing === undefined) return unreadable(repo)
85
+
86
+ return create_missing(missing_labels(existing), repo)
87
+ }
88
+
89
+ const repository_labels = { missing_labels, ensure_labels }
90
+
91
+ export { repository_labels }
@@ -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
 
@@ -67,9 +67,12 @@ interface StopContext {
67
67
  headless_refusals: number
68
68
  // This session is a dispatched lane child for this checkout — `lane_child_marker.is_child_of`.
69
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).
70
+ // A background task this run launched — a command or a subagent — has not finished —
71
+ // `lane_background.pending_background_ids` over the transcript tail (joshuafolkken/kit#2704).
72
72
  background_pending: boolean
73
+ // A backgrounded subagent this run launched has not finished — `lane_background.pending_agent_ids`
74
+ // (joshuafolkken/kit#2774). Apart from `background_pending`, since only a subagent is sure to end.
75
+ agent_pending: boolean
73
76
  // The resolved `JOSH_SESSION_LANG` — `session_language.resolve_session_lang` (joshuafolkken/kit#2470).
74
77
  session_lang: string
75
78
  }
@@ -221,12 +224,21 @@ function needs_filing(context: StopContext): boolean {
221
224
  return filing_offer.is_first_party_target(context.message, context.session_owner)
222
225
  }
223
226
 
227
+ // **A running background subagent stands both hold rules down** (joshuafolkken/kit#2774). Its completion
228
+ // notice re-invokes the session, so the turn-end is a wait on the run's own work, not on a person — a
229
+ // notify there says "nothing needed" and dilutes the ones that do. A backgrounded command does not
230
+ // count: a dev server never ends, and a stop behind it would leave a person silently waiting. A lane
231
+ // child never reaches this with a task running: `needs_background_wait` answers it first.
232
+ function is_held_idle(context: StopContext): boolean {
233
+ return context.hold_present && !context.agent_pending
234
+ }
235
+
224
236
  function needs_notify(context: StopContext): boolean {
225
- return context.hold_present && !context.notified
237
+ return is_held_idle(context) && !context.notified
226
238
  }
227
239
 
228
240
  function needs_release(context: StopContext): boolean {
229
- return context.hold_present && context.tree_clean
241
+ return is_held_idle(context) && context.tree_clean
230
242
  }
231
243
 
232
244
  function citation_reason(message: string): string | undefined {
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { cost_cli } from '#scripts/cost-runtime/cost-cli'
4
+ import { cost_verdict } from '#scripts/cost-runtime/cost-verdict'
3
5
  import {
4
6
  run_carry,
5
7
  type CarryClaim,
@@ -44,6 +46,9 @@ const UNKNOWN_VERDICT = 'unknown'
44
46
  // the run carries on uncut rather than paying a cold preamble the accumulation it would shed no longer
45
47
  // covers, exactly as an under-threshold pre-gate cut carries on to the gate.
46
48
  const CAPPED_VERDICT = 'capped'
49
+ // The asking session is over the shared context-cut threshold, so nothing was claimed
50
+ // (joshuafolkken/kit#2760).
51
+ const OVER_VERDICT = 'over'
47
52
  const NO_CUTS = 0
48
53
 
49
54
  const CLAIM_VERDICTS: Record<CarryClaim, string> = {
@@ -235,7 +240,22 @@ function adopt(target: string, request: CarryClaimRequest, is_json: boolean): nu
235
240
  return report_claim(target, read.carry, request, is_json)
236
241
  }
237
242
 
243
+ // **A session already over the shared cut threshold claims nothing** (joshuafolkken/kit#2760). A claim
244
+ // assumed the asking session was fresh, so a `backlogrun` parent that had taken its cut at 233k could
245
+ // retype the keyword in the same conversation and be answered `resumed` by its own hand-off — measured
246
+ // twice, at 232k and 258k. The verdict is `pnpm josh cost --cut`'s, so no second threshold exists; only a
247
+ // measured `over` refuses, because a provider with no transcript cannot be priced at all.
248
+ function report_over(is_json: boolean): number {
249
+ console.error(
250
+ 'This session is over the shared context-cut threshold (`pnpm josh cost --cut` answers `over`), so no run was begun or resumed here. End this conversation and type the invocation again in a fresh session.',
251
+ )
252
+
253
+ return report(OVER_VERDICT, undefined, is_json, FAILURE_EXIT_CODE)
254
+ }
255
+
238
256
  function claim_record(target: string, request: CarryClaimRequest, is_json: boolean): number {
257
+ if (cost_cli.session_verdict() === cost_verdict.OVER_VERDICT) return report_over(is_json)
258
+
239
259
  return request.is_adoption ? adopt(target, request, is_json) : begin(target, request, is_json)
240
260
  }
241
261
 
@@ -425,6 +445,7 @@ const run_carry_cli = {
425
445
  EXPIRED_VERDICT,
426
446
  MISMATCH_VERDICT,
427
447
  NONE_VERDICT,
448
+ OVER_VERDICT,
428
449
  RESUMED_VERDICT,
429
450
  STANDING_VERDICT,
430
451
  UNKNOWN_VERDICT,