@joshuafolkken/kit 1.972.0 → 1.983.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 (87) hide show
  1. package/.claude/skills/epic-commands/SKILL.md +6 -5
  2. package/.claude/skills/workflow-commands/SKILL.md +9 -9
  3. package/.claude/skills/workflow-commands/background-commands.md +1 -1
  4. package/.claude/skills/workflow-commands/fullrun-steps.md +8 -8
  5. package/.claude/skills/workflow-commands/fullrun.md +1 -0
  6. package/.claude/skills/workflow-commands/halfrun.md +16 -12
  7. package/.claude/skills/workflow-commands/issue-comments.md +1 -1
  8. package/.claude/skills/workflow-commands/issue-scout.md +21 -22
  9. package/.claude/skills/workflow-commands/kickoff.md +8 -9
  10. package/.claude/skills/workflow-commands/observation-filing.md +9 -10
  11. package/.claude/skills/workflow-commands/prerequisite.md +7 -6
  12. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  13. package/.claude/skills/workflow-commands/rule-residency.md +2 -2
  14. package/.claude/skills/workflow-commands/split-assessment.md +4 -3
  15. package/.claude/skills/workflow-commands/target-repository.md +7 -6
  16. package/.claude/skills/workflow-commands/working-tree-hold.md +16 -1
  17. package/README.md +24 -20
  18. package/dist/hooks/batch-guard.js +4 -4
  19. package/dist/hooks/{chunk-ZPLXLJ7O.js → chunk-47SPMGOD.js} +16 -1
  20. package/dist/hooks/{chunk-BVMSPG3M.js → chunk-5JX6HXDX.js} +3 -3
  21. package/dist/hooks/{chunk-KQG4776P.js → chunk-APVOACVT.js} +1 -1
  22. package/dist/hooks/{chunk-2K52BRWV.js → chunk-ASUZGZFT.js} +2 -2
  23. package/dist/hooks/{chunk-CRGJTD6E.js → chunk-ERXMJJOV.js} +1 -1
  24. package/dist/hooks/{chunk-WWTMHHZU.js → chunk-IM3KM25Y.js} +61 -30
  25. package/dist/hooks/{chunk-BNWIOJFU.js → chunk-LBXQHXMC.js} +138 -358
  26. package/dist/hooks/{chunk-NXL3SBXY.js → chunk-P3UX7SFN.js} +2 -2
  27. package/dist/hooks/{chunk-F6OM6SIG.js → chunk-RFPHKU7C.js} +12 -12
  28. package/dist/hooks/{chunk-ZYYSBNR6.js → chunk-ZT65LR3U.js} +4 -4
  29. package/dist/hooks/codex-hook-adapter.js +10 -10
  30. package/dist/hooks/duplicate-read-guard.js +5 -5
  31. package/dist/hooks/format-edited.js +2 -2
  32. package/dist/hooks/investigation-guard.js +5 -5
  33. package/dist/hooks/pretool-guard.js +9 -9
  34. package/dist/hooks/stop-guard.js +4 -4
  35. package/dist/josh.js +16 -1
  36. package/package.json +2 -2
  37. package/prompts/collaboration-workflow/epic-bundle.md +8 -8
  38. package/prompts/collaboration-workflow/issue-template.md +5 -5
  39. package/prompts/collaboration-workflow/rule-delivery.md +43 -30
  40. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  41. package/prompts/collaboration-workflow/wip-cap.md +3 -3
  42. package/prompts/review.md +11 -9
  43. package/scripts/ci/pr-classification.ts +13 -12
  44. package/scripts/delegation/delegation-policy.ts +9 -9
  45. package/scripts/document/document-byte-budget.ts +1 -1
  46. package/scripts/git/issue-labels.ts +52 -5
  47. package/scripts/init/init-bootstrap.ts +92 -0
  48. package/scripts/init/init-install.ts +4 -1
  49. package/scripts/init/init-logic-workspace.ts +1 -1
  50. package/scripts/init/init.ts +23 -5
  51. package/scripts/init/start-plan.ts +1 -1
  52. package/scripts/init/start-steps.ts +5 -28
  53. package/scripts/issue/issue-file-cli.ts +188 -0
  54. package/scripts/issue/issue-file.ts +192 -0
  55. package/scripts/issue/issue-lint-cli.ts +1 -1
  56. package/scripts/issue/issue-scout-cli.ts +41 -97
  57. package/scripts/issue/issue-scout-closed.ts +102 -0
  58. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +9 -1
  59. package/scripts/josh/josh-command-map.ts +1 -0
  60. package/scripts/josh/josh-commands-issue.ts +11 -0
  61. package/scripts/josh/josh-commands-project.ts +5 -1
  62. package/scripts/propagate/propagate-run.ts +1 -0
  63. package/scripts/propagate/propagate-select.ts +72 -0
  64. package/scripts/propagate/propagate-targets.ts +5 -3
  65. package/scripts/propagate/propagate.ts +86 -24
  66. package/scripts/repo/repository-labels.ts +91 -0
  67. package/scripts/rules/bash-triggers.ts +19 -6
  68. package/scripts/rules/delivered-rules.ts +28 -32
  69. package/scripts/rules/direct-filing.ts +38 -0
  70. package/scripts/rules/filing-cap.ts +11 -8
  71. package/scripts/rules/issue-fold.ts +4 -4
  72. package/scripts/rules/oracle-consulted.ts +4 -4
  73. package/scripts/rules/oracle-firing.ts +13 -16
  74. package/scripts/rules/rule-body-guard.ts +6 -9
  75. package/scripts/rules/stop-rules.ts +3 -2
  76. package/scripts/run/run-entry-cli.ts +34 -1
  77. package/scripts/run/run-halfrun-resume.ts +104 -0
  78. package/scripts/run/run-hold-cli.ts +27 -4
  79. package/scripts/run/run-hold.ts +11 -0
  80. package/scripts/security/security-audit.ts +12 -1
  81. package/scripts/sync/sync.ts +6 -0
  82. package/scripts/test/pilot-files.ts +0 -1
  83. package/scripts/version/fresh-metadata-cache.ts +36 -0
  84. package/scripts/version/latest-update.ts +2 -1
  85. package/scripts/rules/issue-bug-label.ts +0 -85
  86. package/scripts/rules/issue-filing-args.ts +0 -50
  87. package/scripts/rules/issue-scout.ts +0 -64
@@ -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 }
@@ -1,12 +1,12 @@
1
1
  import { cost_blocks } from '#scripts/cost-runtime/cost-blocks'
2
2
  import type { GuardedCall } from '#scripts/time-runtime/time-batch-guard'
3
3
  import { time_shell } from '#scripts/time-runtime/time-shell'
4
+ import { shell_segments } from './shell-segments'
4
5
 
5
- // The two trigger helpers the enumeration in `delivered-rules.ts` is built from, held here so a rule
6
+ // The trigger helpers the enumeration in `delivered-rules.ts` is built from, held here so a rule
6
7
  // that lives in its own module can build its own row without importing the enumeration back
7
- // (joshuafolkken/kit#2119). `is_issue_filing` is the one three rows now share — the WIP cap, the scout
8
- // gate and the per-run filing cap — so it could not stay a private function of the file that spreads
9
- // their rows.
8
+ // (joshuafolkken/kit#2119). `is_issue_filing` is the one three rows share — the WIP cap, the per-run
9
+ // filing cap and the fold — so it could not stay a private function of the file that spreads their rows.
10
10
 
11
11
  // **Only `Bash`, and the omission is deliberate** (joshuafolkken/kit#1390): Claude Code denies one
12
12
  // call of a turn and runs the rest, so a refused `Edit` would leave its siblings applied and itself
@@ -33,12 +33,25 @@ const ISSUES_ENDPOINT = /repos\/[^\s'"]*\/issues(?=$|["'\s])/u
33
33
  // that limit is recorded beside the non-`gh` one in `prompts/collaboration-workflow/rule-delivery.md`.
34
34
  const TITLE_FIELD = /(?:-f|-F|--field|--raw-field)\s*'?title=/u
35
35
 
36
- function is_issue_filing(command: string): boolean {
36
+ // A filing made by hand rather than through `josh issue:file` — the call the `direct-filing` row
37
+ // refuses on every occurrence (joshuafolkken/kit#2808).
38
+ function is_direct_filing(command: string): boolean {
37
39
  if (ISSUE_CREATE_COMMAND.test(command)) return true
38
40
 
39
41
  return ISSUES_ENDPOINT.test(command) && TITLE_FIELD.test(command)
40
42
  }
41
43
 
42
- const bash_triggers = { is_issue_filing, on_bash_command }
44
+ // `pnpm josh issue:file`, in either spelling — **the** filing act once a direct one is refused, so the
45
+ // run-level filing rows (the WIP cap, the per-run cap, the fold) trigger on it. Segment-wise, so a
46
+ // spelling quoted inside another command's body is not read as a filing.
47
+ const FILING_NAMES: ReadonlySet<string> = new Set(['issue:file'])
48
+
49
+ function is_issue_filing(command: string): boolean {
50
+ return shell_segments
51
+ .segments_of(command)
52
+ .some((segment) => shell_segments.is_josh_command(segment, FILING_NAMES))
53
+ }
54
+
55
+ const bash_triggers = { is_direct_filing, is_issue_filing, on_bash_command }
43
56
 
44
57
  export { bash_triggers }
@@ -3,15 +3,14 @@ import { hook_decision, type GuardRun, type TranscriptGuardSpec } from '#scripts
3
3
  import { lane_guard_policy } from '#scripts/lane/lane-guard-policy'
4
4
  import { time_batch_guard, type GuardedCall } from '#scripts/time-runtime/time-batch-guard'
5
5
  import { bash_triggers } from './bash-triggers'
6
+ import { direct_filing } from './direct-filing'
6
7
  import { early_heartbeat } from './early-heartbeat'
7
8
  import { file_body } from './file-body'
8
9
  import { filing_cap } from './filing-cap'
9
10
  import { gh_api } from './gh-api'
10
11
  import { git_force } from './git-force'
11
12
  import { implementation_cut } from './implementation-cut'
12
- import { issue_bug_label_rule } from './issue-bug-label'
13
13
  import { issue_fold } from './issue-fold'
14
- import { issue_scout } from './issue-scout'
15
14
  import { josh_git_bare } from './josh-git-bare'
16
15
  import { lane_background } from './lane-background'
17
16
  import { lane_carry_conflict } from './lane-carry-conflict'
@@ -135,9 +134,9 @@ type MeasuredRule = Omit<DeliveredRule, 'is_trigger'> & {
135
134
  const STAMP_PREFIX = 'josh-rule-guard-'
136
135
 
137
136
  // **`is_issue_filing` and `on_bash_command` moved to `bash-triggers.ts`** (joshuafolkken/kit#2119):
138
- // three rows now share the filing trigger — the WIP cap, the scout gate and the per-run filing cap —
139
- // and the last two live in their own modules, which could not import the enumeration back to reach a
140
- // private function of it. `on_bash_command` went with it because those modules build their own rows.
137
+ // three rows share the filing trigger — the WIP cap, the per-run filing cap and the fold — and the
138
+ // last two live in their own modules, which could not import the enumeration back to reach a private
139
+ // function of it. `on_bash_command` went with it because those modules build their own rows.
141
140
  const { is_issue_filing, on_bash_command } = bash_triggers
142
141
 
143
142
  // The whole of the WIP cap, in the shape a refusal can carry: the count, the refusal, the two
@@ -239,8 +238,8 @@ const ISSUE_COMMENTS_REASON =
239
238
  "except for two answers that are not the run's to make: work a comment reassigns to another " +
240
239
  'Issue is out of scope and is not implemented, and a comment saying the Issue no longer has a ' +
241
240
  'reason to exist stops the run with a `confirmation` Telegram. The procedure is ' +
242
- '`.claude/skills/workflow-commands/SKILL.md` → "An Issue\'s comments are part of the Issue". It ' +
243
- 'fires once per run and cannot repeat on the call in hand.'
241
+ '`.claude/skills/workflow-commands/SKILL.md` → "An Issue\'s comments are part of the Issue". ' +
242
+ 'Every body-only read is refused until the comments are read.'
244
243
 
245
244
  // The reading of the call itself — which spellings carry a body inline, and what the shell does to
246
245
  // the value — is `shell-body-trigger.ts`, beside its own cases, as is the refusal text. A row states
@@ -295,26 +294,24 @@ const DELIVERED_RULES: ReadonlyArray<DeliveredRule> = [
295
294
  // before any of those speak; on a first-party filing it is silent (the owners match), so `wip-cap`
296
295
  // still speaks first, exactly as before. It fires on every occurrence (`git-force.ts`).
297
296
  third_party_write.ROW,
297
+ // **A hand-built filing is refused on every occurrence** (joshuafolkken/kit#2808) and pointed at
298
+ // `josh issue:file`, which runs the scout, the lint, the labels, the `## Origin` check and
299
+ // `epic:bundle` itself. It claims the direct call only, so the rows below — which trigger on the
300
+ // `issue:file` call — never overlap it.
301
+ direct_filing.ROW,
298
302
  {
299
303
  id: 'wip-cap',
300
304
  is_trigger: on_bash_command(is_issue_filing),
301
305
  reason: WIP_CAP_REASON,
302
306
  keeps: on_bash_command(counts_open_issues),
303
307
  },
304
- // **Three rows share the filing trigger** (joshuafolkken/kit#2119), listed after `wip-cap` so the
305
- // backlog count still speaks first: `issue-scout` refuses a filing the run has not scouted (once per
306
- // run, stood down once the scout is on the tail), and `filing-cap` refuses every filing past the
307
- // per-run ceiling. Each is a real instance of the admissible overlap the comment on `delivery`
308
- // describes — the losing rule's delivery is still correct one reissue later — so a scout-less,
309
- // over-cap filing is delivered `wip-cap`, then `issue-scout`, then `filing-cap` across its reissues.
310
- issue_scout.ROW,
308
+ // **Two more rows share the filing trigger** (joshuafolkken/kit#2119, joshuafolkken/kit#2213), listed
309
+ // after `wip-cap` so the backlog count still speaks first: `filing-cap` refuses every filing past
310
+ // the per-run ceiling, and `issue-fold` refuses a *second* filing the run has not folded. Each is a
311
+ // real instance of the admissible overlap the comment on `delivery` describes — the losing rule's
312
+ // delivery is still correct one reissue later — so an unfolded, over-cap second filing is delivered
313
+ // `wip-cap`, then `filing-cap`, then `issue-fold` across its reissues.
311
314
  filing_cap.ROW,
312
- // **`issue-fold` shares the same filing trigger** (joshuafolkken/kit#2213), listed after
313
- // `filing-cap`: it refuses a *second* filing the run has not folded, once per run, stood down off
314
- // the tail when the run has already folded or has filed nothing earlier. It is another instance of
315
- // the admissible overlap — the losing rule's delivery is still correct one reissue later — so a
316
- // scout-less, unfolded, over-cap second filing is delivered `wip-cap`, then `issue-scout`, then
317
- // `filing-cap`, then `issue-fold` across its reissues.
318
315
  issue_fold.ROW,
319
316
  {
320
317
  id: 'issue-comments',
@@ -541,13 +538,9 @@ const DELIVERED_RULES: ReadonlyArray<DeliveredRule> = [
541
538
  rule_body_guard.ROW,
542
539
  // **The generic oracle-consulted rows, one per oracle that declared a firing point**
543
540
  // (joshuafolkken/kit#2324). Each refuses the action the oracle governs when the run has not run that
544
- // oracle's command first, stood down once it has — `issue-scout`'s shape read from the registry
545
- // rather than hand-written. Listed last so an oracle whose firing point overlaps a filing
546
- // (`issue:lint`) delivers on the reissue, after `wip-cap` / `issue-scout` / `filing-cap` /
547
- // `issue-fold` have each had their turn. The non-overlapping firing point (`pkg:scout` on a package
548
- // add) claims a command no row above matches.
541
+ // oracle's command first, stood down once it has. Listed last; the one firing point left
542
+ // (`pkg:scout` on a package add) claims a command no row above matches.
549
543
  ...oracle_consulted.ROWS,
550
- issue_bug_label_rule.ROW,
551
544
  ]
552
545
 
553
546
  // A turn that issued more than this many calls is a turn that batched. The guard counts turns that
@@ -703,10 +696,13 @@ function is_first_delivery(
703
696
  // **It is a trade, and the other side is named rather than hidden**: an arm allowed inside that window
704
697
  // that really does run is not recorded either, so a second one before it fires goes uncaught. A missing
705
698
  // record loses one detection; a wrong one blocks every arm until it expires.
706
- // **Both extras hang off `is_first_delivery`, and a row declares at most one.** `decide` is for a
707
- // recurring rule (joshuafolkken/kit#1570); `already_satisfied` is for a once-per-run rule the run can
708
- // satisfy earlier (joshuafolkken/kit#1905), where the earlier act stands the delivery down before the
709
- // once-per-run record is spent. A row with neither takes `is_first_delivery` unchanged.
699
+ // **Both extras replace `is_first_delivery`, and a row declares at most one.** `decide` is for a
700
+ // recurring rule (joshuafolkken/kit#1570); `already_satisfied` is for a precondition the run satisfies
701
+ // with an earlier act (joshuafolkken/kit#1905), and that act is the only way past it: the row refuses
702
+ // every candidate call until the act is on the tail (joshuafolkken/kit#2807). Once per run, the
703
+ // reissue went through with the precondition still unmet — a guard that passed precisely the run that
704
+ // skipped the procedure. Repeating cannot wedge a run that obeys, because the stand-down answers first.
705
+ // A row with neither takes `is_first_delivery` unchanged.
710
706
  function delivery_decision(rule: DeliveredRule): TranscriptGuardSpec['should_block'] {
711
707
  const { decide, already_satisfied } = rule
712
708
 
@@ -719,7 +715,7 @@ function delivery_decision(rule: DeliveredRule): TranscriptGuardSpec['should_blo
719
715
  run: GuardRun,
720
716
  ): boolean {
721
717
  if (already_satisfied?.(tail, call) === true) return false
722
- if (decide === undefined) return is_first_delivery(tail, call, delivered_at_ms, run)
718
+ if (decide === undefined) return true
723
719
 
724
720
  return decide(call, run, !will_batch_guard_refuse(tail, call, run), delivered_at_ms)
725
721
  }
@@ -776,11 +772,11 @@ const delivered_rules = {
776
772
  DELIVERED_RULES,
777
773
  EARLY_HEARTBEAT_REASON: early_heartbeat.EARLY_HEARTBEAT_REASON,
778
774
  FILE_BODY_REASON: file_body.FILE_BODY_REASON,
775
+ DIRECT_FILING_REASON: direct_filing.DIRECT_FILING_REASON,
779
776
  FILING_CAP_REASON: filing_cap.FILING_CAP_REASON,
780
777
  GIT_FORCE_REASON: git_force.GIT_FORCE_REASON,
781
778
  ISSUE_COMMENTS_REASON,
782
779
  ISSUE_FOLD_REASON: issue_fold.ISSUE_FOLD_REASON,
783
- ISSUE_SCOUT_REASON: issue_scout.ISSUE_SCOUT_REASON,
784
780
  LANE_INTERACTIVE_ASK_REASON: lane_interactive_ask.LANE_INTERACTIVE_ASK_REASON,
785
781
  LANE_PARK_REASON: lane_park.LANE_PARK_REASON,
786
782
  MEASURED_RULES,
@@ -0,0 +1,38 @@
1
+ import { bash_triggers } from './bash-triggers'
2
+
3
+ // The `direct-filing` row of `delivered-rules.ts` (joshuafolkken/kit#2808). Filing an Issue owes a
4
+ // duplicate scout, a body lint, the classification / depth / `route:` labels, a `## Origin` on a
5
+ // cross-repository filing and an `epic:bundle` afterwards — steps spread over as many documents, each
6
+ // with its own guard, and joshuafolkken/kit#2805 ran two of them and skipped the rest. `josh issue:file`
7
+ // runs them all in one command, so a hand-built `gh api …/issues` / `gh issue create` filing is refused
8
+ // outright and pointed there; the step-by-step guards that used to sit on the direct call went with it.
9
+
10
+ const DIRECT_FILING_REASON =
11
+ '⛔ direct filing: an Issue is filed with `pnpm josh issue:file "<title>" --body-file <path> ' +
12
+ '--depth <0|1|2> [--route <route>] [--label <name>] [--repo <owner/repo>]`, never with `gh api …/issues` ' +
13
+ 'or `gh issue create`. The command runs every filing step in order — the duplicate scout, the body ' +
14
+ 'lint and its classification labels, the depth and `route:` labels, the `## Origin` check when filing ' +
15
+ 'into another repository, and `epic:bundle` after the create — so none of them is a step to remember. ' +
16
+ 'When the scout prints candidates it holds the filing: read each, then reissue with `--distinct <N,…>` ' +
17
+ 'for the ones that are separate deliverables. Reference: `docs/josh-commands.md` → "`josh issue:file`". ' +
18
+ '**This rule fires on every occurrence, not once per run.**'
19
+
20
+ function is_any_filing(command: string): boolean {
21
+ return bash_triggers.is_direct_filing(command) || bash_triggers.is_issue_filing(command)
22
+ }
23
+
24
+ // `decide` returns true so it refuses every occurrence. Keeping the rule is filing through the command;
25
+ // the trigger is the violation itself, so the occasion it governs is a filing in either spelling
26
+ // (`reaches`, the `run-tail` shape).
27
+ const ROW = {
28
+ id: 'direct-filing',
29
+ is_trigger: bash_triggers.on_bash_command(bash_triggers.is_direct_filing),
30
+ reason: DIRECT_FILING_REASON,
31
+ decide: (): boolean => true,
32
+ keeps: bash_triggers.on_bash_command(bash_triggers.is_issue_filing),
33
+ reaches: bash_triggers.on_bash_command(is_any_filing),
34
+ }
35
+
36
+ const direct_filing = { DIRECT_FILING_REASON, ROW }
37
+
38
+ export { direct_filing }
@@ -21,7 +21,9 @@ import { tail_commands } from './tail-commands'
21
21
  // **A guard-refused filing does not count.** Claude Code writes a denied call to the transcript as a
22
22
  // `tool_use` block with an errored `tool_result`, so the WIP cap's own reissue would otherwise
23
23
  // double-count one Issue — the same exclusion `investigation-reads.ts` makes for a refused read
24
- // (joshuafolkken/kit#1764). The filing is counted only where its result carried no guard refusal.
24
+ // (joshuafolkken/kit#1764). **Nor does one `issue:file` held itself** (joshuafolkken/kit#2808): a lint
25
+ // problem or an unacknowledged duplicate exits non-zero having filed nothing, and the reissue is the
26
+ // same Issue. The filing is counted only where its result did not fail.
25
27
 
26
28
  const FILING_CAP = 10
27
29
  // The current call is not yet on the tail, so the count read there is of filings *already* made. At
@@ -31,8 +33,9 @@ const CAP_REACHED = FILING_CAP
31
33
  interface TailFilings {
32
34
  // The `tool_use` id of every filing on the tail.
33
35
  filing_ids: Array<string>
34
- // Each result's id mapped to the guard that refused it, and `''` for a result no guard refused.
35
- refusals: Map<string, string>
36
+ // The id of every result that came back as a failure — a guard refusal, or `issue:file` holding the
37
+ // filing itself (a lint problem, an unacknowledged duplicate), which files nothing either.
38
+ failures: Set<string>
36
39
  }
37
40
 
38
41
  function is_filing_use(block: Block): boolean {
@@ -42,7 +45,7 @@ function is_filing_use(block: Block): boolean {
42
45
  }
43
46
 
44
47
  function collect_block(block: Block, found: TailFilings): void {
45
- if (block.result_id !== '') found.refusals.set(block.result_id, block.refusal_guard)
48
+ if (block.result_id !== '' && block.is_error === true) found.failures.add(block.result_id)
46
49
  if (is_filing_use(block)) found.filing_ids.push(block.id)
47
50
  }
48
51
 
@@ -55,18 +58,18 @@ function collect_line(line: string, found: TailFilings): void {
55
58
  }
56
59
 
57
60
  function scan_tail(tail: string): TailFilings {
58
- const found: TailFilings = { filing_ids: [], refusals: new Map<string, string>() }
61
+ const found: TailFilings = { filing_ids: [], failures: new Set<string>() }
59
62
 
60
63
  for (const line of tail.split('\n')) collect_line(line, found)
61
64
 
62
65
  return found
63
66
  }
64
67
 
65
- // How many Issues the run has already filed, counting only filings no guard refused.
68
+ // How many Issues the run has already filed, counting only filings whose result did not fail.
66
69
  function prior_filing_count(tail: string): number {
67
- const { filing_ids, refusals } = scan_tail(tail)
70
+ const { filing_ids, failures } = scan_tail(tail)
68
71
 
69
- return filing_ids.filter((id) => (refusals.get(id) ?? '') === '').length
72
+ return filing_ids.filter((id) => !failures.has(id)).length
70
73
  }
71
74
 
72
75
  // A person's prompt: a `user` line carrying no tool result. Tool results ride `user` lines too, so the
@@ -15,8 +15,8 @@ import { tail_commands } from './tail-commands'
15
15
  // binds. The input is mechanical — has the run filed before, did it fold — so it is a delivered rule
16
16
  // rather than resident prose.
17
17
  //
18
- // **It is `issue-scout`'s shape, not `filing-cap`'s.** Once per run with an `already_satisfied`
19
- // stand-down read off the tail: a filing with no prior filing is stood down (nothing to fold), and so
18
+ // **It is `issue-scout`'s shape, not `filing-cap`'s.** Refused until an `already_satisfied`
19
+ // stand-down read off the tail answers: a filing with no prior filing is stood down (nothing to fold), and so
20
20
  // is one the run has already folded. The refusal hands over the command rather than asking the run to
21
21
  // "fold first". **No new predicate**: the filing trigger is `bash_triggers.is_issue_filing`, and the
22
22
  // prior-filing count is `filing_cap.prior_filing_count`, both reused verbatim.
@@ -55,8 +55,8 @@ const ISSUE_FOLD_REASON =
55
55
  '"<title>" …` — `fold` means file one Issue covering them, `separate` means file them apart ' +
56
56
  '(separable findings whose combined size clears the split guide), and the default is `fold`. Add ' +
57
57
  '`--not-separable` when they are really one deliverable. The procedure is ' +
58
- '`.claude/skills/workflow-commands/split-assessment.md` → "The question". It fires once per run ' +
59
- 'and cannot repeat on the call in hand.'
58
+ '`.claude/skills/workflow-commands/split-assessment.md` → "The question". Every later filing is ' +
59
+ 'refused until the fold is on the transcript.'
60
60
 
61
61
  // The enumeration row itself, so `delivered-rules.ts` spreads one entry rather than restating the
62
62
  // trigger and reason it already single-sources here. Once per run with the stand-down above.
@@ -11,8 +11,8 @@ import { tail_commands } from './tail-commands'
11
11
  // `DeliveredRule` for each such oracle — refused when the run performs the governed action without
12
12
  // having run the oracle's command earlier, stood down once it has.
13
13
  //
14
- // **It is `issue-scout`'s shape, generalized.** Each generated row is once per run with an
15
- // `already_satisfied` stand-down read off the tail — refuse the governed call, hand back the command,
14
+ // **It is `issue-scout`'s shape, generalized.** Each generated row refuses until its
15
+ // `already_satisfied` stand-down reads the command off the tail — refuse the governed call, hand back the command,
16
16
  // stand down once that command is on the tail. What `issue-scout` and `issue-fold` hand-wrote per
17
17
  // oracle, this reads from the registry: the refusal is assembled from the oracle's decision, command,
18
18
  // answer vocabulary and single source, so no oracle carries a hand-written refusal of its own. **No new
@@ -51,8 +51,8 @@ function reason_for(oracle: Oracle, firing_point: FiringPoint): string {
51
51
  `⛔ decision oracle not consulted: this is ${firing_point.describes}, but the run has not run ` +
52
52
  `\`${command_line(oracle)}\` first — the oracle that decides ${oracle.decision.toLowerCase()}. ` +
53
53
  `It answers ${oracle.vocabulary.join(' | ')}; single source ${oracle.single_source}. Reissue ` +
54
- `after running it and reading its verdict. It fires once per run and cannot repeat on the call ` +
55
- `in hand.`
54
+ `after running it and reading its verdict. Every such call is refused until that command is on ` +
55
+ `the transcript.`
56
56
  )
57
57
  }
58
58
 
@@ -1,4 +1,3 @@
1
- import { bash_triggers } from './bash-triggers'
2
1
  import { decision_oracle } from './decision-oracle'
3
2
  import { shell_segments } from './shell-segments'
4
3
 
@@ -32,9 +31,10 @@ function is_package_add(command: string): boolean {
32
31
  return shell_segments.segments_of(command).some((segment) => PACKAGE_ADD.test(segment))
33
32
  }
34
33
 
35
- // The two firing points wired — the oracles whose governed call can be named without guessing: the
36
- // package add and the Issue filing. The rest declare why none can be named, so the unenforced oracles
37
- // are visible rather than silently missing.
34
+ // The firing point wired — the oracle whose governed call can be named without guessing: the package
35
+ // add. The Issue filing's `issue:lint` point left with joshuafolkken/kit#2808, when `josh issue:file`
36
+ // began running the lint itself. The rest declare why none can be named, so the unenforced oracles are
37
+ // visible rather than silently missing.
38
38
  const FIRING: ReadonlyMap<string, FiringPoint> = new Map([
39
39
  [
40
40
  'pkg:scout',
@@ -43,13 +43,6 @@ const FIRING: ReadonlyMap<string, FiringPoint> = new Map([
43
43
  governs: is_package_add,
44
44
  },
45
45
  ],
46
- [
47
- 'issue:lint',
48
- {
49
- describes: 'an Issue-filing call (`gh api …/issues`, `gh issue create`)',
50
- governs: bash_triggers.is_issue_filing,
51
- },
52
- ],
53
46
  ])
54
47
 
55
48
  // The reason an oracle names no firing point, in three recurring shapes plus the one-off reasons for
@@ -66,9 +59,12 @@ const SELF = 'the command is itself the governed act; there is no earlier call t
66
59
  const LATEST_SCOPE_REASON =
67
60
  'the run it governs (`pnpm josh latest`) is already gated at its point of use by the latest-gate; ' +
68
61
  'a firing here would double-gate it'
69
- const EPIC_BUNDLE_REASON =
70
- 'it runs after a filing to place it, and that filing is already gated by `issue-scout`; it has no ' +
71
- 'earlier call of its own'
62
+ // `josh issue:file` runs the scout, the lint and `epic:bundle` itself, and a filing made any other way
63
+ // is refused by the `direct-filing` row (joshuafolkken/kit#2808) — so none of the three has a call left
64
+ // to gate before.
65
+ const FILING_STEP_REASON =
66
+ '`pnpm josh issue:file` runs it as one of its filing steps, and a direct filing is refused by the ' +
67
+ '`direct-filing` delivered rule; there is no separate call left to gate'
72
68
  const RELEASE_SCOPE_REASON =
73
69
  'it reads the release owed *after* `pnpm josh followup` has merged (`git_followup_pending`), so it ' +
74
70
  'trails the merge rather than gating it; a firing on `followup` would gate a check that must run ' +
@@ -84,8 +80,9 @@ const NOT_NAMED: ReadonlyMap<string, string> = new Map([
84
80
  ['latest:scope', LATEST_SCOPE_REASON],
85
81
  ['run:hold', SELF],
86
82
  ['cost:cut', READ],
87
- ['epic:bundle', EPIC_BUNDLE_REASON],
88
- ['issue:scout', 'the filing it governs is already gated by the `issue-scout` delivered rule'],
83
+ ['epic:bundle', FILING_STEP_REASON],
84
+ ['issue:scout', FILING_STEP_REASON],
85
+ ['issue:lint', FILING_STEP_REASON],
89
86
  [
90
87
  'issue:fold',
91
88
  'the second filing it governs is already gated by the `issue-fold` delivered rule',
@@ -22,16 +22,13 @@ import { tail_commands } from './tail-commands'
22
22
  // URL is stripped) all fail. What stays is the case the Issue names — writing rule prose into a rule
23
23
  // document.
24
24
  //
25
- // **No `decide`, so once per run.** The refusal changes what the run *knows* — that a new rule owes
26
- // question 0 and the ordering question — which is the shape `rule-delivery.md` says once-per-run is
27
- // right for: told once, the run applies it to every later rule it writes.
28
- //
29
25
  // **The stand-down is what turned the reminder into a check** (joshuafolkken/kit#2324). Until now the
30
26
  // row only *reminded* — it fired once and let the reissue through unconditionally, so "reissue and it
31
27
  // goes" meant a computable rule could still be written into prose with nothing having answered the two
32
28
  // questions. Now the pass condition is a *record*: the run has run `pnpm josh oracle:list` (question 0)
33
29
  // and `pnpm josh run:step` (the ordering question) earlier in the transcript. Both present, the edit is
34
- // stood down; either missing, it is refused. It passes on answering the questions, not on reading the
30
+ // stood down; either missing, it is refused — on the reissue too, since a row with a stand-down
31
+ // refuses until it is met (joshuafolkken/kit#2807). It passes on answering the questions, not on reading the
35
32
  // reminder. Keeping the rule is therefore running those two commands, which `keeps` names.
36
33
 
37
34
  // The two edit tools the `PreToolUse` matcher routes here. A `Write` carries the whole new file; an
@@ -133,7 +130,7 @@ function writes_rule_prose(
133
130
  }
134
131
 
135
132
  // The instruction in the shape a refusal can carry: what the edit is doing, the two questions it
136
- // skips, the commands that answer them, and the reissue every once-per-run delivery needs.
133
+ // skips, the commands that answer them, and the reissue once they have run.
137
134
  const RULE_BODY_REASON =
138
135
  '⛔ rule written into prose: this edit adds rule text to CLAUDE.md, a `prompts/**` doc or a skill ' +
139
136
  'body. Before it lands, run the residency questions the addition skips. A rule whose answer is ' +
@@ -144,9 +141,9 @@ const RULE_BODY_REASON =
144
141
  'sequencing rule moves into. Only a rule that is neither — not computable, not ordering — stays ' +
145
142
  'prose, and then as a trigger-plus-pointer line rather than a second copy of its procedure. The ' +
146
143
  'criterion is `prompts/collaboration-workflow/residency.md` (question 0 and the ordering question); ' +
147
- 'the delivery enumeration is `prompts/collaboration-workflow/rule-delivery.md`. This fires once per ' +
148
- 'run — once you have confirmed the rule can be neither an oracle nor an ordering step, reissue this ' +
149
- 'edit and it will go through.'
144
+ 'the delivery enumeration is `prompts/collaboration-workflow/rule-delivery.md`. Every such edit is ' +
145
+ 'refused until both commands are on the transcript — run them, confirm the rule can be neither an ' +
146
+ 'oracle nor an ordering step, then reissue this edit.'
150
147
 
151
148
  // The two placement oracles the residency questions name: question 0 is `pnpm josh oracle:list`, the
152
149
  // ordering question is `pnpm josh run:step` (its degenerate pre-implementation form `run:next` counts
@@ -136,8 +136,9 @@ const FILING_OFFER_REASON =
136
136
  '⛔ filing offer: your reply offers to file an Issue instead of filing it. Filing into a ' +
137
137
  'first-party repository is Tier A — the trigger is the judgement that it is worth filing, not the ' +
138
138
  "run's progress (`.claude/skills/workflow-commands/SKILL.md` → §2i, `observation-filing.md`). Run " +
139
- '`pnpm josh issue:scout "<title>"`, file it, run `pnpm josh epic:bundle <new>`, then end with the ' +
140
- 'one-line citation of what was filed — do not repeat your previous reply. If it is not worth filing ' +
139
+ '`pnpm josh issue:file "<title>" --body-file <path> --depth <0|1|2>` — it scouts, lints, labels and ' +
140
+ 'runs `epic:bundle` itself — then end with the one-line citation of what was filed; do not repeat ' +
141
+ 'your previous reply. If it is not worth filing ' +
141
142
  'after all, say so in one line instead.'
142
143
 
143
144
  // **A headless parent's turn-end is its process's end** (joshuafolkken/kit#2437). Under `claude -p` the
@@ -4,6 +4,7 @@ import { josh_command } from '#scripts/josh/josh-run'
4
4
  import { lane_child_marker } from '#scripts/lane/lane-child-marker'
5
5
  import { run_cut_report } from './run-cut-report'
6
6
  import { run_entry, type EntryParts } from './run-entry'
7
+ import { run_halfrun_resume } from './run-halfrun-resume'
7
8
  import { run_hold_cli } from './run-hold-cli'
8
9
  import { run_next } from './run-next'
9
10
  import { run_prep } from './run-prep'
@@ -33,6 +34,7 @@ const NEWLINE = '\n'
33
34
  const HOLD_STOP_NOTE =
34
35
  '(tree not held — see the reason above; clean up and retry, or release with `pnpm josh run:release`)'
35
36
  const COST_STOP_NOTE = '(session budget spent — see the figure above; resume in a fresh session)'
37
+ const HALFRUN_RESUME_TOKEN = 'halfrun'
36
38
 
37
39
  // Exactly one issue number, or the call is refused — `run:entry` opens one run, and a second number
38
40
  // would claim and read a second issue a caller reads as this one's.
@@ -126,11 +128,33 @@ function resume_report(issue_number: string, resume: Resume): number {
126
128
  return resume.code
127
129
  }
128
130
 
131
+ // **A `halfrun` stopped before its commit is resumed, not claimed** (joshuafolkken/kit#2796): its hold
132
+ // is kept over the verified diff, so the claim below would answer `busy` against it. The budget is asked
133
+ // first, as for any run; then the hold is adopted and the run goes to the gate, skipping everything up
134
+ // to and including implementation.
135
+ async function resume_halfrun(issue_number: string): Promise<number> {
136
+ const cost = await check_cost()
137
+
138
+ if (cost === run_entry.COST_OVER) {
139
+ return stop_report(issue_number, HALFRUN_RESUME_TOKEN, cost, COST_STOP_NOTE)
140
+ }
141
+
142
+ if (!(await run_halfrun_resume.adopt(issue_number))) {
143
+ return stop_report(issue_number, run_hold_cli.BUSY_VERDICT, cost, HOLD_STOP_NOTE)
144
+ }
145
+
146
+ console.info(`entry #${issue_number} — resume: ${HALFRUN_RESUME_TOKEN}`)
147
+
148
+ return SUCCESS_EXIT_CODE
149
+ }
150
+
129
151
  async function open_run(issue_number: string): Promise<number> {
130
152
  const resume = await ask_resume(issue_number)
131
153
 
132
154
  if (resume.token !== run_cut_report.FRESH_VERDICT) return resume_report(issue_number, resume)
133
155
 
156
+ if (await run_halfrun_resume.is_pending(issue_number)) return await resume_halfrun(issue_number)
157
+
134
158
  const hold = await claim_hold(issue_number)
135
159
 
136
160
  if (hold !== run_hold_cli.HOLD_VERDICT) {
@@ -162,7 +186,16 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
162
186
  process.exitCode = await run(argv)
163
187
  }
164
188
 
165
- const run_entry_cli = { SUCCESS_EXIT_CODE, USAGE, check_cost, claim_hold, main, parse_number, run }
189
+ const run_entry_cli = {
190
+ HALFRUN_RESUME_TOKEN,
191
+ SUCCESS_EXIT_CODE,
192
+ USAGE,
193
+ check_cost,
194
+ claim_hold,
195
+ main,
196
+ parse_number,
197
+ run,
198
+ }
166
199
 
167
200
  if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
168
201