@joshuafolkken/kit 1.953.0 → 1.957.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 (34) hide show
  1. package/README.md +2 -0
  2. package/dist/hooks/batch-guard.js +4 -4
  3. package/dist/hooks/{chunk-V7RH56HU.js → chunk-244WYOEO.js} +2 -2
  4. package/dist/hooks/{chunk-IA7WC4NS.js → chunk-3ON7HOYB.js} +1 -1
  5. package/dist/hooks/{chunk-EYFJ74MJ.js → chunk-BELI5XSS.js} +6 -2
  6. package/dist/hooks/{chunk-IBFC25IX.js → chunk-GNOITLRR.js} +4 -4
  7. package/dist/hooks/{chunk-M4U4YNRE.js → chunk-HJXQZ7V4.js} +3 -3
  8. package/dist/hooks/{chunk-452OKRQX.js → chunk-IJPBTDTB.js} +63 -2
  9. package/dist/hooks/{chunk-NKVLBB65.js → chunk-KOCK5FXQ.js} +3 -3
  10. package/dist/hooks/{chunk-O3PEDF3M.js → chunk-LDQDZYHX.js} +1 -1
  11. package/dist/hooks/{chunk-Z5TVZ4AP.js → chunk-M6K7EQDO.js} +8 -8
  12. package/dist/hooks/{chunk-OM5VDM6F.js → chunk-XSL4SRMA.js} +2 -2
  13. package/dist/hooks/codex-hook-adapter.js +10 -10
  14. package/dist/hooks/duplicate-read-guard.js +5 -5
  15. package/dist/hooks/format-edited.js +2 -2
  16. package/dist/hooks/investigation-guard.js +5 -5
  17. package/dist/hooks/pretool-guard.js +9 -9
  18. package/dist/hooks/stop-guard.js +3 -3
  19. package/dist/josh.js +6 -2
  20. package/package.json +1 -1
  21. package/scripts/backlog/backlog-drive-cli.ts +5 -1
  22. package/scripts/backlog/backlog-drive-retrospective.ts +20 -0
  23. package/scripts/backlog/backlog-drive.ts +7 -3
  24. package/scripts/git/issue-labels.ts +19 -0
  25. package/scripts/init/project-profile.ts +9 -5
  26. package/scripts/init/start-plan.ts +145 -0
  27. package/scripts/init/start-prompt.ts +55 -0
  28. package/scripts/init/start-steps.ts +167 -0
  29. package/scripts/init/start.ts +92 -16
  30. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
  31. package/scripts/josh/josh-commands-project.ts +6 -2
  32. package/scripts/run/run-retrospective.ts +34 -0
  33. package/scripts/run/run-step-cli.ts +3 -5
  34. package/scripts/run/run-step.ts +3 -15
package/README.md CHANGED
@@ -26,6 +26,8 @@ pnpm josh format
26
26
  pnpm josh gate
27
27
  ```
28
28
 
29
+ `josh init` is the entry without GitHub. To start a new project on the GitHub Issue workflow (`kickoff`), run `pnpm exec josh start` instead of `josh init` — it also creates the Git repository, the GitHub repository and the labels ([init.md → `josh init` or `josh start`](./docs/init.md#josh-init-or-josh-start)).
30
+
29
31
  It needs [Node.js](https://nodejs.org/) 22.19.0 or later with [pnpm](https://pnpm.io/), even for a `static` project; [getting-started.md](./docs/getting-started.md) installs both. The global `josh` command ([cli.md](./docs/cli.md)) and the [gh CLI](https://cli.github.com/) for `josh version` and the GitHub Issue workflow are optional.
30
32
 
31
33
  ## Documentation
@@ -9,10 +9,10 @@ import {
9
9
  load_environment_file,
10
10
  notice_refusal_path,
11
11
  refusal_path
12
- } from "./chunk-V7RH56HU.js";
13
- import "./chunk-OM5VDM6F.js";
14
- import "./chunk-O3PEDF3M.js";
15
- import "./chunk-EYFJ74MJ.js";
12
+ } from "./chunk-244WYOEO.js";
13
+ import "./chunk-XSL4SRMA.js";
14
+ import "./chunk-LDQDZYHX.js";
15
+ import "./chunk-BELI5XSS.js";
16
16
  import "./chunk-WPALEIRX.js";
17
17
  import "./chunk-RKUAD3I6.js";
18
18
  export {
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  lane_guard_policy,
3
3
  time_batch_guard
4
- } from "./chunk-OM5VDM6F.js";
4
+ } from "./chunk-XSL4SRMA.js";
5
5
  import {
6
6
  hook_decision
7
- } from "./chunk-O3PEDF3M.js";
7
+ } from "./chunk-LDQDZYHX.js";
8
8
 
9
9
  // scripts/hooks/batch-guard.ts
10
10
  import { text } from "node:stream/consumers";
@@ -5,7 +5,7 @@ import {
5
5
  import {
6
6
  ESLINT_EDIT_CACHE_FLAGS,
7
7
  time_density_hook
8
- } from "./chunk-EYFJ74MJ.js";
8
+ } from "./chunk-BELI5XSS.js";
9
9
 
10
10
  // scripts/hooks/format-edited-file.ts
11
11
  import { existsSync as existsSync2 } from "node:fs";
@@ -1016,9 +1016,13 @@ var PROJECT_COMMANDS = {
1016
1016
  },
1017
1017
  start: {
1018
1018
  script: "scripts/init/start.ts",
1019
- description: "Initialize a project for the GitHub Issue workflow",
1019
+ description: "Create a new project for the GitHub Issue workflow, from git init to labels",
1020
1020
  category: "Project",
1021
- reference: [PROFILE_ARGUMENTS, "developer", ["files", "git", "network"]]
1021
+ reference: [
1022
+ `${PROFILE_ARGUMENTS} [--yes] [--github] [--public]`,
1023
+ "developer",
1024
+ ["files", "git", "network", "processes"]
1025
+ ]
1022
1026
  },
1023
1027
  init: {
1024
1028
  script: "scripts/init/init.ts",
@@ -1,16 +1,16 @@
1
1
  import {
2
2
  investigation_reads
3
- } from "./chunk-M4U4YNRE.js";
3
+ } from "./chunk-HJXQZ7V4.js";
4
4
  import {
5
5
  lane_guard_policy
6
- } from "./chunk-OM5VDM6F.js";
6
+ } from "./chunk-XSL4SRMA.js";
7
7
  import {
8
8
  hook_decision
9
- } from "./chunk-O3PEDF3M.js";
9
+ } from "./chunk-LDQDZYHX.js";
10
10
  import {
11
11
  json_value,
12
12
  time_spans
13
- } from "./chunk-EYFJ74MJ.js";
13
+ } from "./chunk-BELI5XSS.js";
14
14
 
15
15
  // scripts/delegation/duplicate-read-guard.ts
16
16
  import { statSync } from "node:fs";
@@ -1,16 +1,16 @@
1
1
  import {
2
2
  time_batch_guard
3
- } from "./chunk-OM5VDM6F.js";
3
+ } from "./chunk-XSL4SRMA.js";
4
4
  import {
5
5
  hook_decision
6
- } from "./chunk-O3PEDF3M.js";
6
+ } from "./chunk-LDQDZYHX.js";
7
7
  import {
8
8
  cost_blocks,
9
9
  cost_transcript,
10
10
  time_bundle_call,
11
11
  time_shell,
12
12
  time_spans
13
- } from "./chunk-EYFJ74MJ.js";
13
+ } from "./chunk-BELI5XSS.js";
14
14
 
15
15
  // scripts/delegation/investigation-reads.ts
16
16
  import path from "node:path";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  lane_child_marker,
3
3
  run_issue_number
4
- } from "./chunk-O3PEDF3M.js";
4
+ } from "./chunk-LDQDZYHX.js";
5
5
  import {
6
6
  PLATFORM_TEMP_ROOT,
7
7
  cost_blocks,
@@ -12,7 +12,7 @@ import {
12
12
  time_density_hook,
13
13
  time_shell,
14
14
  time_transcript_line
15
- } from "./chunk-EYFJ74MJ.js";
15
+ } from "./chunk-BELI5XSS.js";
16
16
  import {
17
17
  ENV_FILE_NAME,
18
18
  LANE_SEAT_KEY,
@@ -1625,6 +1625,7 @@ var lane_background = {
1625
1625
  };
1626
1626
 
1627
1627
  // scripts/git/issue-labels.ts
1628
+ var EPIC_LABEL = "epic";
1628
1629
  var BUG_LABEL = "bug";
1629
1630
  var ENHANCEMENT_LABEL = "enhancement";
1630
1631
  var BREAKING_CHANGE_LABEL = "breaking-change";
@@ -1632,6 +1633,66 @@ var IN_PROGRESS_LABEL = "in-progress";
1632
1633
  var NEEDS_DECISION_LABEL = "needs-decision";
1633
1634
  var NEEDS_HUMAN_REVIEW_LABEL = "needs-human-review";
1634
1635
  var ALREADY_DONE_LABEL = "already-done";
1636
+ var INTERRUPT_ROUTE_LABEL = "route:interrupt";
1637
+ var REVIEW_CAP_ROUTE_LABEL = "route:review-cap";
1638
+ var SPLIT_ROUTE_LABEL = "route:split";
1639
+ var TIER_A_ROUTE_LABEL = "route:tier-a";
1640
+ var FILING_ROUTE_LABELS = [
1641
+ {
1642
+ name: INTERRUPT_ROUTE_LABEL,
1643
+ // Not `5319e7`, which is the `epic` label's purple: in a listing an interrupt would render as
1644
+ // an epic, and the two are read at a glance rather than by name.
1645
+ color: "1d76db",
1646
+ // Keyed to the tests rather than to severity. "A serious defect" is the self-assessment
1647
+ // `wip-cap.md` bans as a criterion, and this string is what a person reads in `gh label list`.
1648
+ description: "Filed past the WIP cap \u2014 meets one of the three interrupt tests (wip-cap.md)"
1649
+ },
1650
+ {
1651
+ name: REVIEW_CAP_ROUTE_LABEL,
1652
+ color: "eab308",
1653
+ description: "Filed by the review round cap's carry-forward (prompts/review.md \u2192 Review round cap)"
1654
+ },
1655
+ {
1656
+ name: SPLIT_ROUTE_LABEL,
1657
+ color: "0e8a16",
1658
+ description: "A child issue created by a split (split-assessment.md)"
1659
+ },
1660
+ {
1661
+ name: TIER_A_ROUTE_LABEL,
1662
+ color: "d93f0b",
1663
+ description: "Filed Tier A during implementation \u2014 an upstream defect or a prerequisite"
1664
+ }
1665
+ ];
1666
+ var DEPTH_0_LABEL = "depth:0";
1667
+ var DEPTH_1_LABEL = "depth:1";
1668
+ var DEPTH_2_LABEL = "depth:2";
1669
+ var DEPTH_LABELS = [
1670
+ {
1671
+ name: DEPTH_0_LABEL,
1672
+ color: "0e8a16",
1673
+ description: "Depth 0 \u2014 what a consumer of this package touches (SKILL.md \xA72i)"
1674
+ },
1675
+ {
1676
+ name: DEPTH_1_LABEL,
1677
+ color: "fbc02d",
1678
+ description: "Depth 1 \u2014 the run orchestration that executes an Issue (SKILL.md \xA72i)"
1679
+ },
1680
+ {
1681
+ name: DEPTH_2_LABEL,
1682
+ color: "c5def5",
1683
+ description: "Depth 2 \u2014 what measures a run (SKILL.md \xA72i)"
1684
+ }
1685
+ ];
1686
+ var WORKFLOW_LABELS = [
1687
+ {
1688
+ name: EPIC_LABEL,
1689
+ color: "5319e7",
1690
+ description: "Tracks a batch of child issues from one split"
1691
+ },
1692
+ { name: IN_PROGRESS_LABEL, color: "0075ca", description: "Work is actively in progress" },
1693
+ ...FILING_ROUTE_LABELS,
1694
+ ...DEPTH_LABELS
1695
+ ];
1635
1696
  function has_any_label(labels, wanted) {
1636
1697
  return (labels ?? []).some((label) => wanted.has(label.name.toLowerCase()));
1637
1698
  }
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  investigation_reads
3
- } from "./chunk-M4U4YNRE.js";
3
+ } from "./chunk-HJXQZ7V4.js";
4
4
  import {
5
5
  lane_guard_policy
6
- } from "./chunk-OM5VDM6F.js";
6
+ } from "./chunk-XSL4SRMA.js";
7
7
  import {
8
8
  hook_decision
9
- } from "./chunk-O3PEDF3M.js";
9
+ } from "./chunk-LDQDZYHX.js";
10
10
 
11
11
  // scripts/delegation/investigation-guard.ts
12
12
  import { text } from "node:stream/consumers";
@@ -3,7 +3,7 @@ import {
3
3
  stamp_file,
4
4
  time_density_hook,
5
5
  time_hook_transcript
6
- } from "./chunk-EYFJ74MJ.js";
6
+ } from "./chunk-BELI5XSS.js";
7
7
  import {
8
8
  josh_environment_file
9
9
  } from "./chunk-WPALEIRX.js";
@@ -35,31 +35,31 @@ import {
35
35
  run_watcher_guard,
36
36
  shell_segments,
37
37
  tail_commands
38
- } from "./chunk-452OKRQX.js";
38
+ } from "./chunk-IJPBTDTB.js";
39
39
  import {
40
40
  find_local_bin_upwards
41
41
  } from "./chunk-3V37IID6.js";
42
42
  import {
43
43
  batch_outcome
44
- } from "./chunk-V7RH56HU.js";
44
+ } from "./chunk-244WYOEO.js";
45
45
  import {
46
46
  investigation_refusal
47
- } from "./chunk-NKVLBB65.js";
47
+ } from "./chunk-KOCK5FXQ.js";
48
48
  import {
49
49
  duplicate_read_outcome
50
- } from "./chunk-IBFC25IX.js";
50
+ } from "./chunk-GNOITLRR.js";
51
51
  import {
52
52
  investigation_reads
53
- } from "./chunk-M4U4YNRE.js";
53
+ } from "./chunk-HJXQZ7V4.js";
54
54
  import {
55
55
  lane_guard_policy,
56
56
  time_batch_guard
57
- } from "./chunk-OM5VDM6F.js";
57
+ } from "./chunk-XSL4SRMA.js";
58
58
  import {
59
59
  hook_decision,
60
60
  lane_child_marker,
61
61
  run_issue_number
62
- } from "./chunk-O3PEDF3M.js";
62
+ } from "./chunk-LDQDZYHX.js";
63
63
  import {
64
64
  PLATFORM_TEMP_ROOT,
65
65
  cost_attribute,
@@ -71,7 +71,7 @@ import {
71
71
  stamp_file,
72
72
  time_density_hook,
73
73
  time_shell
74
- } from "./chunk-EYFJ74MJ.js";
74
+ } from "./chunk-BELI5XSS.js";
75
75
 
76
76
  // scripts/hooks/pretool-guard.ts
77
77
  import { text } from "node:stream/consumers";
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  lane_child_marker
3
- } from "./chunk-O3PEDF3M.js";
3
+ } from "./chunk-LDQDZYHX.js";
4
4
  import {
5
5
  time_bundle_call,
6
6
  time_format,
7
7
  time_round_trips,
8
8
  time_spans,
9
9
  time_transcript_line
10
- } from "./chunk-EYFJ74MJ.js";
10
+ } from "./chunk-BELI5XSS.js";
11
11
 
12
12
  // scripts/lane/lane-guard-policy.ts
13
13
  var DEFAULT_MODE = "refuse";
@@ -1,25 +1,25 @@
1
1
  #!/usr/bin/env tsx
2
2
  import {
3
3
  pretool_guard
4
- } from "./chunk-Z5TVZ4AP.js";
5
- import "./chunk-452OKRQX.js";
4
+ } from "./chunk-M6K7EQDO.js";
5
+ import "./chunk-IJPBTDTB.js";
6
6
  import {
7
7
  build_envelope,
8
8
  compose_context,
9
9
  format_edited_payload
10
- } from "./chunk-IA7WC4NS.js";
10
+ } from "./chunk-3ON7HOYB.js";
11
11
  import "./chunk-3V37IID6.js";
12
- import "./chunk-V7RH56HU.js";
13
- import "./chunk-NKVLBB65.js";
14
- import "./chunk-IBFC25IX.js";
15
- import "./chunk-M4U4YNRE.js";
16
- import "./chunk-OM5VDM6F.js";
12
+ import "./chunk-244WYOEO.js";
13
+ import "./chunk-KOCK5FXQ.js";
14
+ import "./chunk-GNOITLRR.js";
15
+ import "./chunk-HJXQZ7V4.js";
16
+ import "./chunk-XSL4SRMA.js";
17
17
  import {
18
18
  hook_decision
19
- } from "./chunk-O3PEDF3M.js";
19
+ } from "./chunk-LDQDZYHX.js";
20
20
  import {
21
21
  time_density_hook
22
- } from "./chunk-EYFJ74MJ.js";
22
+ } from "./chunk-BELI5XSS.js";
23
23
  import "./chunk-WPALEIRX.js";
24
24
  import "./chunk-RKUAD3I6.js";
25
25
 
@@ -9,11 +9,11 @@ import {
9
9
  is_enabled,
10
10
  notice_refusal_path,
11
11
  refusal_path
12
- } from "./chunk-IBFC25IX.js";
13
- import "./chunk-M4U4YNRE.js";
14
- import "./chunk-OM5VDM6F.js";
15
- import "./chunk-O3PEDF3M.js";
16
- import "./chunk-EYFJ74MJ.js";
12
+ } from "./chunk-GNOITLRR.js";
13
+ import "./chunk-HJXQZ7V4.js";
14
+ import "./chunk-XSL4SRMA.js";
15
+ import "./chunk-LDQDZYHX.js";
16
+ import "./chunk-BELI5XSS.js";
17
17
  import "./chunk-WPALEIRX.js";
18
18
  import "./chunk-RKUAD3I6.js";
19
19
  export {
@@ -15,9 +15,9 @@ import {
15
15
  relative_to_root,
16
16
  resolve_invocations,
17
17
  select_invocation
18
- } from "./chunk-IA7WC4NS.js";
18
+ } from "./chunk-3ON7HOYB.js";
19
19
  import "./chunk-3V37IID6.js";
20
- import "./chunk-EYFJ74MJ.js";
20
+ import "./chunk-BELI5XSS.js";
21
21
  import "./chunk-RKUAD3I6.js";
22
22
  export {
23
23
  ESLINT_DAEMON,
@@ -10,11 +10,11 @@ import {
10
10
  is_enabled,
11
11
  load_environment_file,
12
12
  refusal_path
13
- } from "./chunk-NKVLBB65.js";
14
- import "./chunk-M4U4YNRE.js";
15
- import "./chunk-OM5VDM6F.js";
16
- import "./chunk-O3PEDF3M.js";
17
- import "./chunk-EYFJ74MJ.js";
13
+ } from "./chunk-KOCK5FXQ.js";
14
+ import "./chunk-HJXQZ7V4.js";
15
+ import "./chunk-XSL4SRMA.js";
16
+ import "./chunk-LDQDZYHX.js";
17
+ import "./chunk-BELI5XSS.js";
18
18
  import "./chunk-WPALEIRX.js";
19
19
  import "./chunk-RKUAD3I6.js";
20
20
  export {
@@ -3,16 +3,16 @@ import {
3
3
  pretool_guard,
4
4
  pretool_outcome,
5
5
  pretool_outcome_async
6
- } from "./chunk-Z5TVZ4AP.js";
7
- import "./chunk-452OKRQX.js";
6
+ } from "./chunk-M6K7EQDO.js";
7
+ import "./chunk-IJPBTDTB.js";
8
8
  import "./chunk-3V37IID6.js";
9
- import "./chunk-V7RH56HU.js";
10
- import "./chunk-NKVLBB65.js";
11
- import "./chunk-IBFC25IX.js";
12
- import "./chunk-M4U4YNRE.js";
13
- import "./chunk-OM5VDM6F.js";
14
- import "./chunk-O3PEDF3M.js";
15
- import "./chunk-EYFJ74MJ.js";
9
+ import "./chunk-244WYOEO.js";
10
+ import "./chunk-KOCK5FXQ.js";
11
+ import "./chunk-GNOITLRR.js";
12
+ import "./chunk-HJXQZ7V4.js";
13
+ import "./chunk-XSL4SRMA.js";
14
+ import "./chunk-LDQDZYHX.js";
15
+ import "./chunk-BELI5XSS.js";
16
16
  import "./chunk-WPALEIRX.js";
17
17
  import "./chunk-RKUAD3I6.js";
18
18
  export {
@@ -16,20 +16,20 @@ import {
16
16
  run_hold,
17
17
  run_invocation,
18
18
  run_watcher_guard
19
- } from "./chunk-452OKRQX.js";
19
+ } from "./chunk-IJPBTDTB.js";
20
20
  import {
21
21
  session_language
22
22
  } from "./chunk-6V5OPIUU.js";
23
23
  import {
24
24
  hook_decision,
25
25
  lane_child_marker
26
- } from "./chunk-O3PEDF3M.js";
26
+ } from "./chunk-LDQDZYHX.js";
27
27
  import {
28
28
  PROJECT_ROOT,
29
29
  stamp_file,
30
30
  time_density_hook,
31
31
  time_hook_transcript
32
- } from "./chunk-EYFJ74MJ.js";
32
+ } from "./chunk-BELI5XSS.js";
33
33
  import "./chunk-WPALEIRX.js";
34
34
  import "./chunk-RKUAD3I6.js";
35
35
 
package/dist/josh.js CHANGED
@@ -1173,9 +1173,13 @@ var PROJECT_COMMANDS = {
1173
1173
  },
1174
1174
  start: {
1175
1175
  script: "scripts/init/start.ts",
1176
- description: "Initialize a project for the GitHub Issue workflow",
1176
+ description: "Create a new project for the GitHub Issue workflow, from git init to labels",
1177
1177
  category: "Project",
1178
- reference: [PROFILE_ARGUMENTS, "developer", ["files", "git", "network"]]
1178
+ reference: [
1179
+ `${PROFILE_ARGUMENTS} [--yes] [--github] [--public]`,
1180
+ "developer",
1181
+ ["files", "git", "network", "processes"]
1182
+ ]
1179
1183
  },
1180
1184
  init: {
1181
1185
  script: "scripts/init/init.ts",
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.953.0",
2
+ "version": "1.957.0",
3
3
  "name": "@joshuafolkken/kit",
4
4
  "description": "Development toolkit for AI-assisted projects: formatting, AI assistant rules and a Git/GitHub Issue workflow for any repository, plus lint, type check, tests and hooks for Node projects",
5
5
  "keywords": [
@@ -3,6 +3,7 @@ import { setTimeout as sleep } from 'node:timers/promises'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { parseArgs } from 'node:util'
5
5
  import { CONTEXT_CUT_THRESHOLD } from '#scripts/cost-runtime/context-cut-threshold'
6
+ import { hook_decision } from '#scripts/josh/hook-decision'
6
7
  import { josh_command } from '#scripts/josh/josh-run'
7
8
  import { lane_await, type AwaitState } from '#scripts/lane/lane-await'
8
9
  import { lane_launch_cli } from '#scripts/lane/lane-launch-cli'
@@ -24,6 +25,7 @@ import { backlog_drive_named_offer } from './backlog-drive-named-offer'
24
25
  import { backlog_drive_offer_argv } from './backlog-drive-offer-argv'
25
26
  import { backlog_drive_owner } from './backlog-drive-owner'
26
27
  import { backlog_drive_restore } from './backlog-drive-restore'
28
+ import { backlog_drive_retrospective } from './backlog-drive-retrospective'
27
29
  import { backlog_offer_cli } from './backlog-offer-cli'
28
30
 
29
31
  // `josh backlog:drive` — the backlogrun parent's loop as one resident wait (joshuafolkken/kit#2499).
@@ -193,7 +195,7 @@ async function read_offer(
193
195
 
194
196
  return offer === undefined
195
197
  ? undefined
196
- : { ...offer, is_retrospective_done: carry.retrospective === true }
198
+ : { ...offer, is_retrospective_owed: backlog_drive_retrospective.is_owed(carry) }
197
199
  }
198
200
 
199
201
  // A non-zero `run:merge` prints its token first only for a hand-off (`busy`, `stop`, `retry`); any
@@ -357,6 +359,8 @@ async function run(argv: ReadonlyArray<string>): Promise<number> {
357
359
  }
358
360
 
359
361
  async function main(argv: ReadonlyArray<string>): Promise<void> {
362
+ // Load `.env` on the real command path only, so `JOSH_RETROSPECTIVE` set there reaches the offer read.
363
+ hook_decision.load_environment_file()
360
364
  process.exitCode = await run(argv)
361
365
  }
362
366
 
@@ -0,0 +1,20 @@
1
+ import { doctor_consumer } from '#scripts/doctor/doctor-consumer'
2
+ import { find_package_directory } from '#scripts/josh/josh-logic'
3
+ import type { RunCarry } from '#scripts/run/run-carry'
4
+ import { run_retrospective } from '#scripts/run/run-retrospective'
5
+
6
+ // Whether a drain owes the retrospective, read through the owed rule `run:step` reads, so a drain it
7
+ // owes nothing at keeps the idle watch rather than waking a session with nothing to run
8
+ // (joshuafolkken/kit#2750). The drive is the parent's loop, never a lane child's.
9
+ function is_owed(carry: RunCarry): boolean {
10
+ const root = find_package_directory(process.cwd())
11
+
12
+ return run_retrospective.is_owed({
13
+ is_retrospective_enabled: run_retrospective.is_enabled(),
14
+ is_lane_child: false,
15
+ is_retrospective_done: carry.retrospective === true,
16
+ is_consumer: doctor_consumer.is_kit_consumer(root),
17
+ })
18
+ }
19
+
20
+ export const backlog_drive_retrospective = { is_owed }
@@ -45,7 +45,9 @@ interface OfferRead {
45
45
  retries: number
46
46
  answer?: string | undefined
47
47
  reason?: string | undefined
48
- is_retrospective_done?: boolean
48
+ // Whether a drain here owes the retrospective: the opt-in switch is on and it has not run yet. Absent
49
+ // reads as not owed, so a drain with the switch off keeps the idle watch (joshuafolkken/kit#2750).
50
+ is_retrospective_owed?: boolean
49
51
  is_finish?: boolean | undefined
50
52
  }
51
53
 
@@ -106,7 +108,9 @@ function ended(reason: string, state: DriveState, token?: string, issue?: string
106
108
 
107
109
  function is_drain(read: OfferRead, state: DriveState): boolean {
108
110
  return (
109
- read.verdict === WATCH_VERDICT && state.in_flight.length === 0 && !read.is_retrospective_done
111
+ read.verdict === WATCH_VERDICT &&
112
+ state.in_flight.length === 0 &&
113
+ read.is_retrospective_owed === true
110
114
  )
111
115
  }
112
116
 
@@ -115,7 +119,7 @@ function needs_retrospective(read: OfferRead, state: DriveState): boolean {
115
119
  read.verdict === STOP_VERDICT &&
116
120
  read.answer === 'exhausted' &&
117
121
  state.in_flight.length === 0 &&
118
- !read.is_retrospective_done
122
+ read.is_retrospective_owed === true
119
123
  )
120
124
  }
121
125
 
@@ -177,6 +177,24 @@ const DEPTH_LABELS: ReadonlyArray<{
177
177
  },
178
178
  ]
179
179
 
180
+ // What `josh start` provisions on a new repository so the first `kickoff` finds every label a run
181
+ // applies with its intended color (joshuafolkken/kit#2197). The `epic` and `in-progress` metadata is
182
+ // the one the workflow documents create them with; the labels only a person applies are left out.
183
+ const WORKFLOW_LABELS: ReadonlyArray<{
184
+ name: string
185
+ color: string
186
+ description: string
187
+ }> = [
188
+ {
189
+ name: EPIC_LABEL,
190
+ color: '5319e7',
191
+ description: 'Tracks a batch of child issues from one split',
192
+ },
193
+ { name: IN_PROGRESS_LABEL, color: '0075ca', description: 'Work is actively in progress' },
194
+ ...FILING_ROUTE_LABELS,
195
+ ...DEPTH_LABELS,
196
+ ]
197
+
180
198
  // The shape `gh issue list --json labels` returns; narrowed here so the predicate below takes any
181
199
  // listing row without importing a schema.
182
200
  interface LabelReference {
@@ -285,4 +303,5 @@ export {
285
303
  REVIEW_ROUND2_SKIPPED_LABEL,
286
304
  SPLIT_ROUTE_LABEL,
287
305
  TIER_A_ROUTE_LABEL,
306
+ WORKFLOW_LABELS,
288
307
  }
@@ -29,12 +29,16 @@ function parse_profile(value: unknown): ProjectProfile | undefined {
29
29
  return undefined
30
30
  }
31
31
 
32
- function requested_profile(args: ReadonlyArray<string>): ProjectProfile | undefined {
33
- if (args.length === 0) return undefined
32
+ function is_profile_pair(args: ReadonlyArray<string>): boolean {
33
+ return args.length === PROFILE_ARG_LENGTH && args[0] === '--profile'
34
+ }
34
35
 
35
- if (args.length !== PROFILE_ARG_LENGTH || args[0] !== '--profile') {
36
- throw new Error('Usage: josh init [--profile static|node]')
37
- }
36
+ function requested_profile(
37
+ args: ReadonlyArray<string>,
38
+ usage = 'josh init [--profile static|node]',
39
+ ): ProjectProfile | undefined {
40
+ if (args.length === 0) return undefined
41
+ if (!is_profile_pair(args)) throw new Error(`Usage: ${usage}`)
38
42
 
39
43
  const profile = parse_profile(args[1])
40
44
  if (profile !== undefined) return profile
@@ -0,0 +1,145 @@
1
+ import type { ProjectProfile } from './project-profile'
2
+
3
+ type Visibility = 'private' | 'public'
4
+
5
+ interface StartOptions {
6
+ profile: ProjectProfile | undefined
7
+ is_yes: boolean
8
+ is_github: boolean
9
+ visibility: Visibility
10
+ }
11
+
12
+ type StartStep = 'git_init' | 'initialize' | 'commit' | 'repository' | 'labels'
13
+
14
+ interface GitState {
15
+ has_git: boolean
16
+ has_github: boolean
17
+ has_origin: boolean
18
+ branch: string | undefined
19
+ has_commits: boolean
20
+ }
21
+
22
+ interface StepPlan {
23
+ steps: ReadonlyArray<StartStep>
24
+ refusal: string | undefined
25
+ }
26
+
27
+ type Interaction = 'interactive' | 'unattended'
28
+ type Consent = 'granted' | 'ask'
29
+
30
+ interface Verdict<T> {
31
+ value: T | undefined
32
+ refusal: string | undefined
33
+ }
34
+
35
+ const DEFAULT_BRANCH = 'main'
36
+ const FULL_STEPS: ReadonlyArray<StartStep> = [
37
+ 'git_init',
38
+ 'initialize',
39
+ 'commit',
40
+ 'repository',
41
+ 'labels',
42
+ ]
43
+
44
+ const STEP_LABELS: Readonly<Record<StartStep, string>> = {
45
+ git_init: 'Create a Git repository on main',
46
+ initialize: 'Initialize kit (the same setup as josh init)',
47
+ commit: 'Commit every file as the initial commit',
48
+ repository: 'Create the GitHub repository and push main',
49
+ labels: 'Add the workflow labels the repository is missing',
50
+ }
51
+
52
+ function refused(refusal: string): StepPlan {
53
+ return { steps: [], refusal }
54
+ }
55
+
56
+ // A repository that already has a history keeps it: nothing is committed on the user's behalf, and
57
+ // only `main` is pushed, because the Issue workflow branches from and merges into it.
58
+ function existing_git_steps(state: GitState): StepPlan {
59
+ if (!state.has_commits) return { steps: FULL_STEPS.slice(1), refusal: undefined }
60
+
61
+ if (state.branch !== DEFAULT_BRANCH) {
62
+ return refused(
63
+ `josh start pushes ${DEFAULT_BRANCH}, but this repository is on ${state.branch ?? 'a detached HEAD'}. Switch to ${DEFAULT_BRANCH}, or run josh init to set up without GitHub.`,
64
+ )
65
+ }
66
+
67
+ return { steps: ['initialize', 'repository', 'labels'], refusal: undefined }
68
+ }
69
+
70
+ // An existing GitHub origin is never replaced or pushed to: only the additive steps run. Any other
71
+ // origin is refused here, before the first write — `gh repo create --remote origin` cannot add a
72
+ // remote that already exists, so the run would otherwise fail after initializing the directory.
73
+ function plan_steps(state: GitState): StepPlan {
74
+ if (state.has_github) return { steps: ['initialize', 'labels'], refusal: undefined }
75
+
76
+ if (state.has_origin) {
77
+ return refused(
78
+ 'josh start creates the GitHub repository as origin, but this repository already has an origin that is not on GitHub. Remove or rename it, or run josh init to set up without GitHub.',
79
+ )
80
+ }
81
+
82
+ if (state.has_git) return existing_git_steps(state)
83
+
84
+ return { steps: FULL_STEPS, refusal: undefined }
85
+ }
86
+
87
+ function interaction_of(options: StartOptions, is_tty: boolean): Verdict<Interaction> {
88
+ if (options.is_yes) return { value: 'unattended', refusal: undefined }
89
+ if (is_tty) return { value: 'interactive', refusal: undefined }
90
+
91
+ return {
92
+ value: undefined,
93
+ refusal:
94
+ 'josh start needs a terminal to confirm its choices. Pass --yes to accept the detected profile, and --github to create the GitHub repository.',
95
+ }
96
+ }
97
+
98
+ // `--yes` accepts the detected defaults, never the creation of a public-facing resource: an
99
+ // unattended run creates and pushes a repository only when `--github` asks for it by name.
100
+ function github_consent(
101
+ steps: ReadonlyArray<StartStep>,
102
+ options: StartOptions,
103
+ interaction: Interaction,
104
+ ): Verdict<Consent> {
105
+ if (!steps.includes('repository') || options.is_github) {
106
+ return { value: 'granted', refusal: undefined }
107
+ }
108
+
109
+ if (interaction === 'interactive') return { value: 'ask', refusal: undefined }
110
+
111
+ return {
112
+ value: undefined,
113
+ refusal:
114
+ 'josh start --yes does not create a GitHub repository or push. Add --github to create one, or run josh init to set up without GitHub.',
115
+ }
116
+ }
117
+
118
+ // What a failed run prints, so the user can tell how far it got before deciding how to resume.
119
+ function progress_report(
120
+ failed: StartStep,
121
+ completed: ReadonlyArray<StartStep>,
122
+ cause: string,
123
+ ): string {
124
+ const done =
125
+ completed.length === 0 ? 'nothing' : completed.map((step) => STEP_LABELS[step]).join('; ')
126
+
127
+ return `josh start stopped at: ${STEP_LABELS[failed]}\nCompleted: ${done}\nCause: ${cause}`
128
+ }
129
+
130
+ function plan_summary(steps: ReadonlyArray<StartStep>): string {
131
+ const lines = steps.map((step, index) => ` ${String(index + 1)}. ${STEP_LABELS[step]}`)
132
+
133
+ return ['josh start will:', ...lines].join('\n')
134
+ }
135
+
136
+ const start_plan = {
137
+ plan_steps,
138
+ interaction_of,
139
+ github_consent,
140
+ progress_report,
141
+ plan_summary,
142
+ STEP_LABELS,
143
+ }
144
+ export { start_plan }
145
+ export type { Consent, GitState, Interaction, StartOptions, StartStep, StepPlan, Visibility }
@@ -0,0 +1,55 @@
1
+ import path from 'node:path'
2
+ import { stdin as input, stdout as output } from 'node:process'
3
+ import { createInterface, type Interface } from 'node:readline/promises'
4
+ import { ask_yes_no_simple } from '#scripts/git/git-prompt'
5
+ import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
6
+ import type { Consent, StartOptions } from './start-plan'
7
+
8
+ interface Choices {
9
+ options: StartOptions
10
+ shape: ProjectShape
11
+ consent: Consent
12
+ root: string
13
+ }
14
+
15
+ const DECLINED =
16
+ 'Stopped before creating the GitHub repository. Nothing was changed. Run josh init to set up without GitHub.'
17
+
18
+ // The detected profile is the default, so pressing Enter accepts it; the reason is shown because an
19
+ // ambiguous project is exactly the case this question exists for.
20
+ async function ask_profile(prompt: Interface, shape: ProjectShape): Promise<ProjectProfile> {
21
+ const question = `Profile — static or node [${shape.profile}, ${shape.reason}]: `
22
+ const raw_answer = await prompt.question(question)
23
+ const answer = raw_answer.trim()
24
+ if (answer.length === 0) return shape.profile
25
+
26
+ return project_profile.requested_profile(['--profile', answer]) ?? shape.profile
27
+ }
28
+
29
+ async function ask_repository(prompt: Interface, choices: Choices): Promise<void> {
30
+ const name = path.basename(choices.root)
31
+ const question = `Create a ${choices.options.visibility} GitHub repository "${name}" and push main? (y/n): `
32
+
33
+ if (!(await ask_yes_no_simple(prompt, question))) throw new Error(DECLINED)
34
+ }
35
+
36
+ async function ask_choices(prompt: Interface, choices: Choices): Promise<ProjectProfile> {
37
+ const profile = choices.options.profile ?? (await ask_profile(prompt, choices.shape))
38
+ if (choices.consent === 'ask') await ask_repository(prompt, choices)
39
+
40
+ return profile
41
+ }
42
+
43
+ async function confirm_choices(choices: Choices): Promise<ProjectProfile> {
44
+ const prompt = createInterface({ input, output })
45
+
46
+ try {
47
+ return await ask_choices(prompt, choices)
48
+ } finally {
49
+ prompt.close()
50
+ }
51
+ }
52
+
53
+ const start_prompt = { confirm_choices }
54
+ export { start_prompt }
55
+ export type { Choices }
@@ -0,0 +1,167 @@
1
+ import path from 'node:path'
2
+ import { git_gh_exec } from '#scripts/git/git-gh-exec'
3
+ import { WORKFLOW_LABELS } from '#scripts/git/issue-labels'
4
+ import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
5
+ import { execaSync } from 'execa'
6
+ import { main as init_main } from './init'
7
+ import { PACKAGE_DIR } from './init-paths'
8
+ import type { ProjectProfile, ProjectShape } from './project-profile'
9
+ import { start_plan, type GitState, type StartStep, type Visibility } from './start-plan'
10
+
11
+ interface StepContext {
12
+ root: string
13
+ profile: ProjectProfile
14
+ visibility: Visibility
15
+ }
16
+
17
+ type WorkflowLabel = (typeof WORKFLOW_LABELS)[number]
18
+
19
+ const DEFAULT_BRANCH = 'main'
20
+ const LABELS_ENDPOINT = 'repos/{owner}/{repo}/labels'
21
+ const GH_INSTALL_HINT =
22
+ 'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/, run gh auth login, then run josh start again. Nothing was changed.'
23
+ const GH_AUTH_HINT =
24
+ 'GitHub CLI is not signed in. Run gh auth login, then run josh start again. Nothing was changed.'
25
+
26
+ function succeeds(command: string, args: ReadonlyArray<string>, root: string): boolean {
27
+ return execaSync(command, args, { cwd: root, reject: false }).exitCode === 0
28
+ }
29
+
30
+ function read_output(
31
+ command: string,
32
+ args: ReadonlyArray<string>,
33
+ root: string,
34
+ ): string | undefined {
35
+ const result = execaSync(command, args, { cwd: root, reject: false })
36
+
37
+ return result.exitCode === 0 ? result.stdout.trim() : undefined
38
+ }
39
+
40
+ // Every step spawned here dials GitHub directly, past a scanner's loopback proxy, the same as every
41
+ // other `gh` spawn under scripts/ (joshuafolkken/kit#2436); a local `git` step is unaffected by it.
42
+ function run(command: string, args: ReadonlyArray<string>, root: string): void {
43
+ execaSync(command, args, { ...git_gh_exec.direct_environment(), cwd: root, stdio: 'inherit' })
44
+ }
45
+
46
+ function read_git_state(root: string, shape: ProjectShape): GitState {
47
+ const { has_git, has_github } = shape
48
+
49
+ if (!has_git) {
50
+ return { has_git, has_github, has_origin: false, branch: undefined, has_commits: false }
51
+ }
52
+
53
+ const has_origin = succeeds('git', ['remote', 'get-url', 'origin'], root)
54
+ const branch = read_output('git', ['symbolic-ref', '--short', 'HEAD'], root)
55
+ const has_commits = succeeds('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], root)
56
+
57
+ return { has_git, has_github, has_origin, branch, has_commits }
58
+ }
59
+
60
+ // Every step talks to GitHub, so the CLI is checked before the first write rather than at the step
61
+ // that needs it — a missing `gh` then leaves the directory exactly as it was.
62
+ function github_cli_refusal(root: string): string | undefined {
63
+ if (!succeeds('gh', ['--version'], root)) return GH_INSTALL_HINT
64
+ if (!succeeds('gh', ['auth', 'status'], root)) return GH_AUTH_HINT
65
+
66
+ return undefined
67
+ }
68
+
69
+ // `josh init` refuses to run inside the kit source repository by printing and setting the exit code
70
+ // rather than throwing, so its step would pass and the later steps would commit and publish the
71
+ // source repository. The same check is raised before the first write instead.
72
+ function self_run_refusal(root: string): string | undefined {
73
+ return self_sync_guard.self_sync_refusal(PACKAGE_DIR, root)
74
+ }
75
+
76
+ function create_git_repository(context: StepContext): void {
77
+ run('git', ['init', `--initial-branch=${DEFAULT_BRANCH}`], context.root)
78
+ }
79
+
80
+ // The same setup `josh init` runs, called rather than copied, with the profile the user confirmed.
81
+ function initialize(context: StepContext): void {
82
+ init_main(['--profile', context.profile])
83
+ }
84
+
85
+ function commit_all(context: StepContext): void {
86
+ run('git', ['add', '--all'], context.root)
87
+ // The first commit of a new repository has no branch to come from, so the hook that keeps commits
88
+ // off main — installed by the initialize step in a node project — would refuse the only commit
89
+ // that has to land there.
90
+ run('git', ['commit', '--no-verify', '--message', 'Initial commit'], context.root)
91
+ // A repository created by an older `git init` may name its unborn branch `master`.
92
+ run('git', ['branch', '--move', '--force', DEFAULT_BRANCH], context.root)
93
+ }
94
+
95
+ function create_github_repository(context: StepContext): void {
96
+ const name = path.basename(context.root)
97
+ const visibility = `--${context.visibility}`
98
+ const source = ['--source', '.', '--remote', 'origin', '--push']
99
+
100
+ run('gh', ['repo', 'create', name, visibility, ...source], context.root)
101
+ }
102
+
103
+ // Labels already present are left as they are — only the missing ones are created, so a repository
104
+ // that recolored a label keeps its choice. GitHub compares label names case-insensitively.
105
+ function missing_workflow_labels(existing: ReadonlyArray<string>): ReadonlyArray<WorkflowLabel> {
106
+ const names = new Set(existing.map((name) => name.toLowerCase()))
107
+
108
+ return WORKFLOW_LABELS.filter((label) => !names.has(label.name))
109
+ }
110
+
111
+ function create_label(label: WorkflowLabel, root: string): void {
112
+ const fields = [`name=${label.name}`, `color=${label.color}`, `description=${label.description}`]
113
+
114
+ run('gh', ['api', LABELS_ENDPOINT, ...fields.flatMap((field) => ['-f', field]), '--silent'], root)
115
+ console.info(` ✔ label ${label.name}`)
116
+ }
117
+
118
+ function add_missing_labels(context: StepContext): void {
119
+ const listing = execaSync('gh', ['api', '--paginate', LABELS_ENDPOINT, '--jq', '.[].name'], {
120
+ ...git_gh_exec.direct_environment(),
121
+ cwd: context.root,
122
+ })
123
+ const existing = listing.stdout.split('\n').filter((name) => name.length > 0)
124
+
125
+ for (const label of missing_workflow_labels(existing)) create_label(label, context.root)
126
+ }
127
+
128
+ const STEP_ACTIONS: Readonly<Record<StartStep, (context: StepContext) => void>> = {
129
+ git_init: create_git_repository,
130
+ initialize,
131
+ commit: commit_all,
132
+ repository: create_github_repository,
133
+ labels: add_missing_labels,
134
+ }
135
+
136
+ function run_step(
137
+ step: StartStep,
138
+ context: StepContext,
139
+ completed: ReadonlyArray<StartStep>,
140
+ ): void {
141
+ try {
142
+ STEP_ACTIONS[step](context)
143
+ } catch (error) {
144
+ const cause = error instanceof Error ? error.message : String(error)
145
+
146
+ throw new Error(start_plan.progress_report(step, completed, cause), { cause: error })
147
+ }
148
+ }
149
+
150
+ function run_steps(steps: ReadonlyArray<StartStep>, context: StepContext): void {
151
+ for (const [index, step] of steps.entries()) {
152
+ const position = `${String(index + 1)}/${String(steps.length)}`
153
+
154
+ console.info(`\n[${position}] ${start_plan.STEP_LABELS[step]}`)
155
+ run_step(step, context, steps.slice(0, index))
156
+ }
157
+ }
158
+
159
+ const start_steps = {
160
+ read_git_state,
161
+ github_cli_refusal,
162
+ self_run_refusal,
163
+ missing_workflow_labels,
164
+ run_steps,
165
+ }
166
+ export { start_steps }
167
+ export type { StepContext }
@@ -1,30 +1,106 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
- import { main as init_main } from './init'
4
- import { project_profile, type ProjectShape } from './project-profile'
3
+ import { project_profile, type ProjectProfile } from './project-profile'
4
+ import {
5
+ start_plan,
6
+ type Consent,
7
+ type Interaction,
8
+ type StartOptions,
9
+ type StartStep,
10
+ } from './start-plan'
11
+ import { start_prompt, type Choices } from './start-prompt'
12
+ import { start_steps } from './start-steps'
5
13
 
6
14
  const ARGUMENT_START_INDEX = 2
15
+ const READY_MESSAGE = 'GitHub workflow ready. Use kickoff new in your assistant to plan an Issue.'
16
+ const START_USAGE = 'josh start [--profile static|node] [--yes] [--github] [--public]'
17
+ const YES_FLAG = '--yes'
18
+ const GITHUB_FLAG = '--github'
19
+ const PUBLIC_FLAG = '--public'
20
+ const SWITCHES: ReadonlySet<string> = new Set([YES_FLAG, GITHUB_FLAG, PUBLIC_FLAG])
7
21
 
8
- function prerequisite(shape: ProjectShape): string | undefined {
9
- if (!shape.has_git) return 'josh start requires Git. Run git init, or use josh init without Git.'
10
- if (!shape.has_github) return 'josh start requires a GitHub origin. Add one, or use josh init.'
22
+ interface Prepared {
23
+ steps: ReadonlyArray<StartStep>
24
+ interaction: Interaction
25
+ choices: Choices
26
+ }
27
+
28
+ // The switches are peeled off first, so what remains is exactly the `--profile` pair `josh init`
29
+ // accepts — one parser for the profile in both commands.
30
+ function parse_start_options(args: ReadonlyArray<string>): StartOptions {
31
+ const profile_args = args.filter((argument) => !SWITCHES.has(argument))
32
+
33
+ return {
34
+ profile: project_profile.requested_profile(profile_args, START_USAGE),
35
+ is_yes: args.includes(YES_FLAG),
36
+ is_github: args.includes(GITHUB_FLAG),
37
+ visibility: args.includes(PUBLIC_FLAG) ? 'public' : 'private',
38
+ }
39
+ }
40
+
41
+ function settle<T>(verdict: { value: T | undefined; refusal: string | undefined }): T {
42
+ if (verdict.value === undefined) throw new Error(verdict.refusal)
11
43
 
12
- return undefined
44
+ return verdict.value
13
45
  }
14
46
 
15
- function main(args: ReadonlyArray<string> = []): void {
16
- // --profile uses the same override as josh init.
17
- const requested = project_profile.requested_profile(args)
18
- const shape = project_profile.inspect_project(process.cwd(), requested)
19
- const message = prerequisite(shape)
20
- if (message !== undefined) throw new Error(message)
21
- init_main(args)
22
- console.info('GitHub workflow ready. Use kickoff in your assistant to plan an Issue.')
47
+ function stop_on(refusal: string | undefined): void {
48
+ if (refusal !== undefined) throw new Error(refusal)
49
+ }
50
+
51
+ // An unattended run already holds every answer: `--profile` or the detected one, and a consent that
52
+ // `github_consent` only grants when `--github` was given.
53
+ async function resolve_profile(
54
+ choices: Choices,
55
+ interaction: Interaction,
56
+ ): Promise<ProjectProfile> {
57
+ if (interaction === 'unattended') return choices.options.profile ?? choices.shape.profile
58
+
59
+ return await start_prompt.confirm_choices(choices)
60
+ }
61
+
62
+ // Every refusal is raised here, before the first write, so a run that stops here changed nothing.
63
+ function prepare(args: ReadonlyArray<string>, is_tty: boolean, root: string): Prepared {
64
+ stop_on(start_steps.self_run_refusal(root))
65
+ const options = parse_start_options(args)
66
+ const interaction = settle(start_plan.interaction_of(options, is_tty))
67
+ const shape = project_profile.inspect_project(root, options.profile)
68
+ const plan = start_plan.plan_steps(start_steps.read_git_state(root, shape))
69
+
70
+ stop_on(plan.refusal)
71
+ const consent: Consent = settle(start_plan.github_consent(plan.steps, options, interaction))
72
+
73
+ stop_on(start_steps.github_cli_refusal(root))
74
+
75
+ return { steps: plan.steps, interaction, choices: { options, shape, consent, root } }
76
+ }
77
+
78
+ async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void> {
79
+ const { steps, interaction, choices } = prepare(args, is_tty, process.cwd())
80
+
81
+ console.info(start_plan.plan_summary(steps))
82
+ const profile = await resolve_profile(choices, interaction)
83
+ const { root, options } = choices
84
+
85
+ start_steps.run_steps(steps, { root, profile, visibility: options.visibility })
86
+ console.info(`\n${READY_MESSAGE}`)
87
+ }
88
+
89
+ async function run(args: ReadonlyArray<string>, is_tty: boolean): Promise<number> {
90
+ try {
91
+ await main(args, is_tty)
92
+
93
+ return 0
94
+ } catch (error) {
95
+ console.error(error instanceof Error ? error.message : String(error))
96
+
97
+ return 1
98
+ }
23
99
  }
24
100
 
25
101
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
26
- main(process.argv.slice(ARGUMENT_START_INDEX))
102
+ process.exitCode = await run(process.argv.slice(ARGUMENT_START_INDEX), process.stdin.isTTY)
27
103
  }
28
104
 
29
- const start = { prerequisite }
105
+ const start = { run, parse_start_options }
30
106
  export { start }
@@ -1103,12 +1103,13 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
1103
1103
  ],
1104
1104
  ],
1105
1105
  "start": [
1106
- "[--profile static|node]",
1106
+ "[--profile static|node] [--yes] [--github] [--public]",
1107
1107
  "developer",
1108
1108
  [
1109
1109
  "files",
1110
1110
  "git",
1111
1111
  "network",
1112
+ "processes",
1112
1113
  ],
1113
1114
  ],
1114
1115
  "stash:pop": [
@@ -14,9 +14,13 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
14
14
  },
15
15
  start: {
16
16
  script: 'scripts/init/start.ts',
17
- description: 'Initialize a project for the GitHub Issue workflow',
17
+ description: 'Create a new project for the GitHub Issue workflow, from git init to labels',
18
18
  category: 'Project',
19
- reference: [PROFILE_ARGUMENTS, 'developer', ['files', 'git', 'network']],
19
+ reference: [
20
+ `${PROFILE_ARGUMENTS} [--yes] [--github] [--public]`,
21
+ 'developer',
22
+ ['files', 'git', 'network', 'processes'],
23
+ ],
20
24
  },
21
25
  init: {
22
26
  script: 'scripts/init/init.ts',
@@ -0,0 +1,34 @@
1
+ import { hook_decision } from '#scripts/josh/hook-decision'
2
+
3
+ // Whether a run owes its end-of-run retrospective, single-sourced so `run:step` and `backlog:drive`
4
+ // never disagree on it (joshuafolkken/kit#2750): a drive that read "owed" where `run:step` read "not
5
+ // owed" handed back at every drain to a session that had nothing to run.
6
+
7
+ // The opt-in switch that gates the retrospective (joshuafolkken/kit#2370). It defaults off, so an unset
8
+ // variable owes no retrospective.
9
+ const RETROSPECTIVE_ENV_KEY = 'JOSH_RETROSPECTIVE'
10
+
11
+ interface RetrospectiveFacts {
12
+ is_retrospective_enabled: boolean
13
+ is_lane_child: boolean
14
+ is_retrospective_done: boolean
15
+ is_consumer: boolean
16
+ }
17
+
18
+ function is_enabled(): boolean {
19
+ return hook_decision.is_switch_opt_in(RETROSPECTIVE_ENV_KEY)
20
+ }
21
+
22
+ // False when the switch is off, for a dispatched lane child (the batch runs one at its own end, never a
23
+ // child's), once it has run this invocation, and in a consumer checkout, where the kit-only command is
24
+ // refused. The switch is read first, so a run that has not opted in never consults the other three.
25
+ function is_owed(facts: RetrospectiveFacts): boolean {
26
+ if (!facts.is_retrospective_enabled) return false
27
+
28
+ return !facts.is_lane_child && !facts.is_retrospective_done && !facts.is_consumer
29
+ }
30
+
31
+ const run_retrospective = { RETROSPECTIVE_ENV_KEY, is_enabled, is_owed }
32
+
33
+ export type { RetrospectiveFacts }
34
+ export { run_retrospective }
@@ -9,6 +9,7 @@ import { run_carry, type CarryRead } from './run-carry'
9
9
  import { run_event_scope } from './run-event-scope'
10
10
  import { run_event_stream } from './run-event-stream'
11
11
  import { run_prep_cli } from './run-prep-cli'
12
+ import { run_retrospective } from './run-retrospective'
12
13
  import { run_step, type StepInput } from './run-step'
13
14
 
14
15
  // `josh run:step <N>` — print the run's next single action, computed from the event stream, the carry
@@ -22,10 +23,6 @@ const SUCCESS_EXIT_CODE = 0
22
23
  const FAILURE_EXIT_CODE = 1
23
24
  const ARGV_OFFSET = 2
24
25
  const USAGE = 'Usage: josh run:step <issue-number>'
25
- // The opt-in switch that gates the end-of-run retrospective (joshuafolkken/kit#2370). Read here rather
26
- // than in `run-step.ts` so the position logic stays a pure function of its input; it defaults off, so an
27
- // unset variable prints no retrospective step.
28
- const RETROSPECTIVE_ENV_KEY = 'JOSH_RETROSPECTIVE'
29
26
 
30
27
  interface RunReads {
31
28
  carry_kind: CarryRead['kind']
@@ -83,7 +80,8 @@ async function gather(issue_number: string): Promise<StepInput> {
83
80
  is_at_cut_cap: run_reads.is_at_cut_cap,
84
81
  is_lane_child: lane_child_marker.is_child_of(process.cwd()),
85
82
  is_consumer: doctor_consumer.is_kit_consumer(find_package_directory(process.cwd())),
86
- is_retrospective_enabled: hook_decision.is_switch_opt_in(RETROSPECTIVE_ENV_KEY),
83
+ // Read here rather than in `run-step.ts` so the position logic stays a pure function of its input.
84
+ is_retrospective_enabled: run_retrospective.is_enabled(),
87
85
  has_changes: parts.has_changes,
88
86
  has_completion_callback: agent_role_profile.has_completion_callback(),
89
87
  }
@@ -2,6 +2,7 @@ import { git_stash } from '#scripts/git/git-stash'
2
2
  import { latest_scope_cli } from '#scripts/version/latest-scope-cli'
3
3
  import type { CarryRead } from './run-carry'
4
4
  import { run_event_stream } from './run-event-stream'
5
+ import { run_retrospective } from './run-retrospective'
5
6
 
6
7
  // `josh run:step <N>` — the run's next single action, computed from three mechanical inputs and nothing
7
8
  // else (joshuafolkken/kit#2248). It is the child of epic #2166 that lifts the fold from an *event* to a
@@ -174,23 +175,10 @@ const EVENT_ACTIONS: Record<string, (issue_number: string) => StepAction> = {
174
175
  // (joshuafolkken/kit#2297).
175
176
  const PARENT_ONLY_EVENTS: ReadonlySet<string> = new Set([KIND.MERGE, KIND.OUTAGE])
176
177
 
177
- // Whether a run at a winding-down position still owes its end-of-run retrospective
178
- // (joshuafolkken/kit#2328) — false when the switch is off (the opt-in default, joshuafolkken/kit#2370),
179
- // for a dispatched lane child, where the batch runs one at its own end and never a child's (the
180
- // `release:scope` precedent), for a run whose retrospective has already run this invocation, and in a
181
- // consumer checkout, where the kit-only command is refused. The switch is read first, so a run that has
182
- // not opted in never consults the other three. The two positions that consult it are the drain and the
183
- // stop; both read exactly this, so the exclusion set is single-sourced.
184
- function is_retrospective_owed(input: StepInput): boolean {
185
- if (!input.is_retrospective_enabled) return false
186
-
187
- return !input.is_lane_child && !input.is_retrospective_done && !input.is_consumer
188
- }
189
-
190
178
  // A stopped run's last owed step is the end-of-run retrospective. When it is not owed the position stops
191
179
  // with no command to run, which is what the position meant before the retrospective existed.
192
180
  function stop_action(input: StepInput): StepAction {
193
- return is_retrospective_owed(input) ? command(RETROSPECTIVE_COMMAND) : verdict(STOP)
181
+ return run_retrospective.is_owed(input) ? command(RETROSPECTIVE_COMMAND) : verdict(STOP)
194
182
  }
195
183
 
196
184
  // The drain fires the retrospective *before* the idle watch, not after it (joshuafolkken/kit#2335): the
@@ -199,7 +187,7 @@ function stop_action(input: StepInput): StepAction {
199
187
  // run proceeds to that watch, which is a `WAIT` rather than a `STOP`: the backlog is empty but the run is
200
188
  // not ending, and the real `STOP` follows when the watch expires with the retrospective already done.
201
189
  function drain_action(input: StepInput): StepAction {
202
- return is_retrospective_owed(input) ? command(RETROSPECTIVE_COMMAND) : verdict(WAIT)
190
+ return run_retrospective.is_owed(input) ? command(RETROSPECTIVE_COMMAND) : verdict(WAIT)
203
191
  }
204
192
 
205
193
  // The hand-off a `backlogrun` parent without a completion callback takes right after dispatching