@joshuafolkken/kit 1.972.0 → 1.978.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -63,6 +63,7 @@ call reports `hold`.
63
63
  boundary — immediately after `pnpm josh main:merge`, before the gate — it takes the cut with
64
64
  `pnpm josh run:cut <N>` (the ordered step in `chain-rule.md`), and it records any park on the Issue before the stop notify.
65
65
  `pre-gate-cut.md` is the single source of both.
66
+ - **`resume: halfrun`** adopted a stopped `halfrun` (#2796): gate in full; `working-tree-hold.md`.
66
67
 
67
68
  ## The stop branches
68
69
 
@@ -41,12 +41,16 @@ josh latest:scope` and update dependencies only on `required` (`latest-gate.md`;
41
41
  start `pnpm josh gate` beside a subagent `/code-review` with the brief `pnpm josh review:brief` prints,
42
42
  join the gate before the stop, iterate to no high/medium findings, at most two reviews → `pnpm josh
43
43
  test:e2e`, run by **you**, because `halfrun` opens no pull request and there is no CI E2E job; a printed
44
- skip is the answer where the project has no suite) → send a `confirmation` Telegram with the resume
45
- commands in the body → **stop**. Plan comments are in the session language. The `confirmation` Telegram
46
- body MUST include the exact resume commands: `pnpm josh notify --task-type confirmation --issue-url
47
- "<issue-url>" --body=$'halfrun ready for manual verification\nNext: pnpm josh git -y "<title> #<N>" &&
48
- pnpm josh followup "<title> #<N>" --notify-message "Implemented <title>\\nCause: ...\\nFix:
49
- ...\\nResult: ...\\n\\nDetails:\\n- <change1>"'`.
44
+ skip is the answer where the project has no suite) → **mark the stop: `pnpm josh run:hold <N>
45
+ --halfrun-stop`** (the record `fullrun #<N>` adopts; `new` passes the filed number) → send a
46
+ `confirmation` Telegram with the resume commands in the body → **stop**. Plan comments are in the session language. The `confirmation` Telegram
47
+ body MUST include the exact resume commands — **`fullrun #<N>` first**, since that is how a verified
48
+ `halfrun` ships (joshuafolkken/kit#2796): its `run:entry` adopts this stop's marked hold and resumes at
49
+ the gate. The direct commands follow for shipping without an agent:
50
+ `pnpm josh notify --task-type confirmation --issue-url "<issue-url>" --body=$'halfrun ready for manual
51
+ verification\nNext: fullrun #<N>\nWithout an agent: pnpm josh git -y "<title> #<N>" && pnpm josh
52
+ followup "<title> #<N>" --notify-message "Implemented <title>\\nCause: ...\\nFix: ...\\nResult:
53
+ ...\\n\\nDetails:\\n- <change1>"'`. The stop report names the same two, in the same order.
50
54
  **Invoking `halfrun` is _not_ authorization to commit, push, or merge** — do not run `pnpm josh git -y` or `pnpm josh followup` yourself. If the user comes
51
55
  back with fixes, treat each as a new round: implement, re-run `pnpm josh gate`, send another
52
56
  `confirmation` Telegram, stop again.
@@ -59,7 +63,8 @@ body per `prompts/collaboration-workflow/issue-template.md`) → add `in-progres
59
63
  any pre-existing changes with `git stash push -m "halfrun new: pre-existing changes"`, popped by
60
64
  message with `pnpm josh stash:pop "halfrun new: pre-existing changes"`, never a positional `git stash pop` →
61
65
  `git switch main && git pull` → `pnpm josh latest:scope` → implement → run the gate (as above, `pnpm
62
- josh test:e2e` run by **you**) → send the `confirmation` Telegram and **stop**.
66
+ josh test:e2e` run by **you**) → `pnpm josh run:hold <N> --halfrun-stop` → send the `confirmation`
67
+ Telegram and **stop**.
63
68
 
64
69
  ## The stop branches
65
70
 
@@ -55,7 +55,8 @@ clean releases it explicitly**: a `fullrun` / `halfrun` that stops on a split, a
55
55
  third-party target ends with `pnpm josh run:release <N>` (bare where that run entered as `new`).
56
56
  **`halfrun`'s stop before commit keeps the hold**, and so does a `needs-human-review` stop: the
57
57
  uncommitted work still in the tree is exactly what a second run would trample, so the release command
58
- goes in the stop report and the Telegram for the person to type. **An expired record over a tree that
58
+ goes in the stop report and the Telegram for the person to type. **`fullrun #<N>` of the same issue
59
+ adopts a `halfrun`'s kept hold** rather than being refused `busy` by it — "The halfrun resume" below. **An expired record over a tree that
59
60
  still has uncommitted changes does not free it**: the command answers `busy` and says to commit, stash,
60
61
  or release once the work is done; only an expired record over a clean tree is replaced.
61
62
 
@@ -64,3 +65,17 @@ call it themselves; each child runs the `fullrun` procedure, so it claims on ent
64
65
  followup` releases it at that child's merge. The command's behavior and the answer table are
65
66
  `docs/josh-commands.md` → "`josh run:hold` / `josh run:release`"; this file is the single source of
66
67
  the procedure.
68
+
69
+ ## The halfrun resume
70
+
71
+ **`fullrun #<N>` after a `halfrun` stop resumes it instead of claiming** (joshuafolkken/kit#2796). The
72
+ stop is **recorded, never inferred**: the `halfrun` ends with `pnpm josh run:hold <N> --halfrun-stop`,
73
+ which marks its own record (re-keying a `halfrun new`'s unnumbered one to the filed issue) — a
74
+ `halfrun` still implementing or a `backlogrun` child leaves the same hold over the same dirty tree, and
75
+ must not be adopted. On a marked hold for `#<N>` over a dirty tree, `run:entry` asks the session budget
76
+ (`over` stops as usual), adopts the hold with the `fullrun` mark and prints `entry #<N> — resume:
77
+ halfrun`. **Skip the title, the plan, the split assessment, `git switch main && git pull`,
78
+ `latest:scope` and the implementation**: the diff in the tree is what the person verified. Re-read the
79
+ issue (`pnpm josh issue:read <N>`), then run the gate **in full** from the refactor (`chain-rule.md` →
80
+ "Run the review-to-merge chain") — a fix made during the manual check has had no gate — and ship as
81
+ any `fullrun` does.
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # @joshuafolkken/kit
2
2
 
3
- A development toolkit that sets up a repository for AI-assisted work with one command. `josh init` gives an AI assistant (Claude Code, Codex, Gemini, Cursor) the project's rules, and the `josh` CLI runs the checks and the Git / GitHub Issue workflow the same way in every project. You stop copying config between repositories and re-explaining conventions to the assistant. For the problems kit exists to solve, see [why.md](./docs/why.md) (Japanese).
3
+ A development toolkit for AI-assisted work. `josh init` gives an AI assistant (Claude Code, Codex, Gemini, Cursor) the project's rules, formatting and checks in one command. With `kickoff`, `fullrun` and `backlogrun`, the agent then takes a GitHub Issue through plan → implement → verify → PR → merge, the same way in every project. Why kit exists: [why.md](./docs/why.md) (Japanese).
4
4
 
5
5
  ## What your project gets
6
6
 
7
- kit does not branch by language. It asks one question — does the project use Node tooling? — and picks a [profile](./docs/init.md#project-profiles):
7
+ kit picks a [profile](./docs/init.md#project-profiles) from one question — does the project use Node tooling?
8
8
 
9
9
  | Your project | Profile | What you get |
10
10
  | ---------------------------------------- | -------- | ----------------------------------------------------------------- |
@@ -12,54 +12,58 @@ kit does not branch by language. It asks one question — does the project use N
12
12
  | HTML / CSS (browser JavaScript included) | `static` | Formatting (Prettier), AI assistant files, Git / VS Code settings |
13
13
  | Another language (Python, Rust, Go, …) | `static` | AI assistant files and Git / VS Code settings only |
14
14
 
15
- kit adds no linter or test runner for languages other than JavaScript and TypeScript; in a `static` project, `josh` skips each check that has nothing to run and says why.
15
+ In a `static` project, `josh` skips each check that has nothing to run and says why.
16
16
 
17
17
  ## Quick start
18
18
 
19
- In your project directory:
19
+ Requires [Node.js](https://nodejs.org/) 22.19.0+ and [pnpm](https://pnpm.io/) for every profile ([getting-started.md](./docs/getting-started.md)); the [gh CLI](https://cli.github.com/) for `josh version` and the Issue workflow is required by `josh start` and optional otherwise. Run in your project directory.
20
+
21
+ ### The project is already on GitHub, or will not use GitHub
20
22
 
21
23
  ```bash
22
- pnpm add -D --allow-build=esbuild --allow-build=unrs-resolver @joshuafolkken/kit
23
- pnpm exec josh init
24
+ pnpm --allow-build=esbuild dlx @joshuafolkken/kit init
24
25
  pnpm josh gate
25
26
  ```
26
27
 
27
- `josh init` finishes by running `pnpm install` and `josh format` itself; pass `--no-install` to skip both, for example on CI or offline.
28
+ ### The project has no GitHub repository yet, and will use the Issue workflow
28
29
 
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
+ ```bash
31
+ pnpm add -D --allow-build=esbuild --allow-build=unrs-resolver @joshuafolkken/kit
32
+ pnpm exec josh start
33
+ ```
30
34
 
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.
35
+ `josh start` also runs `git init` when needed and creates and pushes the GitHub repository. Details: [init.md → `josh init` or `josh start`](./docs/init.md#josh-init-or-josh-start).
32
36
 
33
37
  ## Documentation
34
38
 
35
39
  **Get started**
36
40
 
37
- - [getting-started.md](./docs/getting-started.md) — first install for an `index.html` site or a non-Node project (`static`)
38
- - [package.md](./docs/package.md) — add kit to a Node project (`node`)
41
+ - [getting-started.md](./docs/getting-started.md) — first install for a `static` project
42
+ - [package.md](./docs/package.md) — add kit to a Node project
39
43
  - [tutorial.md](./docs/tutorial.md) — your first change with an agent, from Issue to merge
40
- - [how-to.md](./docs/how-to.md) — step-by-step guides by task: update, release, run Issues, fix a failing gate
44
+ - [how-to.md](./docs/how-to.md) — guides by task
41
45
  - [overview.md](./docs/overview.md) — what kit sets up and how it works
42
46
 
43
47
  **Commands and configuration**
44
48
 
45
- - [cli.md](./docs/cli.md) — install the global `josh` command
49
+ - [cli.md](./docs/cli.md) — the global `josh` command
46
50
  - [josh-commands.md](./docs/josh-commands.md) — every `josh` command ([catalog](./docs/josh-command-catalog.md))
47
- - [init.md](./docs/init.md) — what `josh init` creates, per profile
48
- - [sync.md](./docs/sync.md) — what `josh sync` updates after an upgrade
49
- - [manual-config.md](./docs/manual-config.md) — use the presets without `josh init`
50
- - [package-api.md](./docs/package-api.md) — the config presets and libraries the package exports
51
+ - [init.md](./docs/init.md) — what `josh init` creates
52
+ - [sync.md](./docs/sync.md) — what `josh sync` updates
53
+ - [manual-config.md](./docs/manual-config.md) — the presets without `josh init`
54
+ - [package-api.md](./docs/package-api.md) — the package's exports
51
55
 
52
56
  **AI workflow and operations**
53
57
 
54
- - [scripts-ai.md](./docs/scripts-ai.md) — Telegram notifications and the Issue workflow commands
58
+ - [scripts-ai.md](./docs/scripts-ai.md) — Issue workflow commands and Telegram notifications
55
59
  - [cloud-session.md](./docs/cloud-session.md) — running in an agent container
56
- - [eval.md](./docs/eval.md) — measuring whether the agent rules are followed
60
+ - [eval.md](./docs/eval.md) — measuring rule adherence
57
61
  - [troubleshooting.md](./docs/troubleshooting.md) — install and auth errors
58
62
  - [authentication.md](./docs/authentication.md) — existing GitHub Packages installs only
59
63
 
60
64
  ## Contributing
61
65
 
62
- Community standards live in [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md); security reports go through [SECURITY.md](./SECURITY.md). Development conventions are in [CLAUDE.md](./CLAUDE.md); releases are in [publishing.md](./docs/publishing.md), and maintainer records in [docs/maintainers/](./docs/maintainers/).
66
+ Conventions are in [CLAUDE.md](./CLAUDE.md), releases in [publishing.md](./docs/publishing.md). See [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) and [SECURITY.md](./SECURITY.md); maintainer records are in [docs/maintainers/](./docs/maintainers/).
63
67
 
64
68
  ## License
65
69
 
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  ALREADY_DONE_LABEL,
3
3
  BREAKING_CHANGE_LABEL,
4
+ BUGFIX_LABEL,
4
5
  BUG_LABEL,
5
6
  CONTEXT_CUT_THRESHOLD,
6
7
  ENHANCEMENT_LABEL,
@@ -9,6 +10,7 @@ import {
9
10
  NEEDS_DECISION_LABEL,
10
11
  NEEDS_HUMAN_REVIEW_LABEL,
11
12
  RECENT_REQUEST_WINDOW,
13
+ RELEASE_CLASSIFICATION_NAMES,
12
14
  agent_role_profile,
13
15
  agent_session_environment,
14
16
  bash_triggers,
@@ -35,7 +37,7 @@ import {
35
37
  run_watcher_guard,
36
38
  shell_segments,
37
39
  tail_commands
38
- } from "./chunk-WWTMHHZU.js";
40
+ } from "./chunk-Q7LE77CO.js";
39
41
  import {
40
42
  find_local_bin_upwards
41
43
  } from "./chunk-3V37IID6.js";
@@ -3590,16 +3592,7 @@ import { readFileSync as readFileSync6 } from "node:fs";
3590
3592
  import { fileURLToPath as fileURLToPath2 } from "node:url";
3591
3593
  import { execaSync } from "execa";
3592
3594
  import { z as z8 } from "zod";
3593
- var BUGFIX = "bugfix";
3594
- var BREAKING = "breaking-change";
3595
- var CLASSIFICATION_LABELS2 = [
3596
- BREAKING,
3597
- "enhancement",
3598
- BUGFIX,
3599
- "other-change",
3600
- "ignore-for-release"
3601
- ];
3602
- var CLASSIFICATION_SET = new Set(CLASSIFICATION_LABELS2);
3595
+ var CLASSIFICATION_SET = new Set(RELEASE_CLASSIFICATION_NAMES);
3603
3596
  var ISSUE_BUG_LABEL = "bug";
3604
3597
  var DECLARATION = /^- リリース分類: (.+)$/gmu;
3605
3598
  var LEGACY_LABELS = /* @__PURE__ */ new Set(["semver-major", "semver-minor"]);
@@ -3618,14 +3611,14 @@ function classification_error(labels, author) {
3618
3611
  return `Choose exactly one release classification; found: ${selected.join(", ")}`;
3619
3612
  }
3620
3613
  if (selected.length === 1 || author.endsWith("[bot]")) return void 0;
3621
- return `Choose one release classification: ${CLASSIFICATION_LABELS2.join(", ")}`;
3614
+ return `Choose one release classification: ${RELEASE_CLASSIFICATION_NAMES.join(", ")}`;
3622
3615
  }
3623
3616
  function is_classification(value) {
3624
3617
  return CLASSIFICATION_SET.has(value);
3625
3618
  }
3626
3619
  function release_candidates(candidates) {
3627
- if (!candidates.includes(BREAKING)) return candidates;
3628
- return candidates.filter((candidate) => candidate !== "enhancement");
3620
+ if (!candidates.includes(BREAKING_CHANGE_LABEL)) return candidates;
3621
+ return candidates.filter((candidate) => candidate !== ENHANCEMENT_LABEL);
3629
3622
  }
3630
3623
  function issue_candidates(issue_json) {
3631
3624
  const issue = z8.object({ labels: z8.array(LABEL), body: z8.string().nullable() }).parse(JSON.parse(issue_json));
@@ -3634,8 +3627,8 @@ function issue_candidates(issue_json) {
3634
3627
  const candidates = [...release_candidates([...names, ...declared].filter(is_classification))];
3635
3628
  const unknown = declared.filter((value) => !is_classification(value));
3636
3629
  if (unknown.length > 0) throw new Error(`Unknown release classification: ${unknown.join(", ")}`);
3637
- if (names.includes(ISSUE_BUG_LABEL) && !candidates.includes(BREAKING)) {
3638
- candidates.push(BUGFIX);
3630
+ if (names.includes(ISSUE_BUG_LABEL) && !candidates.includes(BREAKING_CHANGE_LABEL)) {
3631
+ candidates.push(BUGFIX_LABEL);
3639
3632
  }
3640
3633
  return candidates;
3641
3634
  }
@@ -3645,7 +3638,7 @@ function select_issue_classification(issue_json) {
3645
3638
  const [label] = selected;
3646
3639
  if (label === void 0 || selected.length > 1) {
3647
3640
  throw new Error(
3648
- `Choose one release classification before opening the PR: ${CLASSIFICATION_LABELS2.join(", ")}; found: ${selected.join(", ") || "none"}. Add "- \u30EA\u30EA\u30FC\u30B9\u5206\u985E: <label>" to the Issue body.`
3641
+ `Choose one release classification before opening the PR: ${RELEASE_CLASSIFICATION_NAMES.join(", ")}; found: ${selected.join(", ") || "none"}. Add "- \u30EA\u30EA\u30FC\u30B9\u5206\u985E: <label>" to the Issue body.`
3649
3642
  );
3650
3643
  }
3651
3644
  return label;
@@ -547,7 +547,8 @@ var run_hold_schema = z.object({
547
547
  issue: z.string(),
548
548
  taken_at: z.string(),
549
549
  pid: z.number(),
550
- is_fullrun: z.boolean().optional()
550
+ is_fullrun: z.boolean().optional(),
551
+ is_halfrun_stop: z.boolean().optional()
551
552
  });
552
553
  function parse_hold(raw) {
553
554
  try {
@@ -582,6 +583,9 @@ function write_hold(target, issue, now = /* @__PURE__ */ new Date()) {
582
583
  function create_hold(target, issue, now = /* @__PURE__ */ new Date(), is_fullrun) {
583
584
  return stamp_file.create_stamp(target, build_hold(issue, now, is_fullrun));
584
585
  }
586
+ function create_halfrun_stop_hold(target, issue, now = /* @__PURE__ */ new Date()) {
587
+ return stamp_file.create_stamp(target, { ...build_hold(issue, now), is_halfrun_stop: true });
588
+ }
585
589
  async function is_tree_dirty() {
586
590
  try {
587
591
  const status2 = await git_command.status();
@@ -635,6 +639,7 @@ var run_hold = {
635
639
  RELEASE_COMMAND,
636
640
  UNNUMBERED_ISSUE,
637
641
  classify,
642
+ create_halfrun_stop_hold,
638
643
  create_hold,
639
644
  describe_holder,
640
645
  foreign_release_message,
@@ -1732,6 +1737,28 @@ var WORKFLOW_LABELS = [
1732
1737
  ...FILING_ROUTE_LABELS,
1733
1738
  ...DEPTH_LABELS
1734
1739
  ];
1740
+ var BUGFIX_LABEL = "bugfix";
1741
+ var OTHER_CHANGE_LABEL = "other-change";
1742
+ var IGNORE_FOR_RELEASE_LABEL = "ignore-for-release";
1743
+ var RELEASE_CLASSIFICATION_NAMES = [
1744
+ BREAKING_CHANGE_LABEL,
1745
+ ENHANCEMENT_LABEL,
1746
+ BUGFIX_LABEL,
1747
+ OTHER_CHANGE_LABEL,
1748
+ IGNORE_FOR_RELEASE_LABEL
1749
+ ];
1750
+ var RELEASE_CLASSIFICATION_METADATA = {
1751
+ [BREAKING_CHANGE_LABEL]: { color: "d93f0b", description: "Release: a breaking change (major)" },
1752
+ [ENHANCEMENT_LABEL]: { color: "84b6eb", description: "Release: a new feature (minor)" },
1753
+ [BUGFIX_LABEL]: { color: "d73a4a", description: "Release: a bug fix (patch)" },
1754
+ [OTHER_CHANGE_LABEL]: { color: "c2e0c6", description: "Release: any other change (patch)" },
1755
+ [IGNORE_FOR_RELEASE_LABEL]: { color: "cfd3d7", description: "Release: left out of the notes" }
1756
+ };
1757
+ var RELEASE_CLASSIFICATION_LABELS = RELEASE_CLASSIFICATION_NAMES.map((name) => ({ name, ...RELEASE_CLASSIFICATION_METADATA[name] }));
1758
+ var REPOSITORY_LABELS = [
1759
+ ...WORKFLOW_LABELS,
1760
+ ...RELEASE_CLASSIFICATION_LABELS
1761
+ ];
1735
1762
  function has_any_label(labels, wanted) {
1736
1763
  return (labels ?? []).some((label) => wanted.has(label.name.toLowerCase()));
1737
1764
  }
@@ -3624,6 +3651,8 @@ export {
3624
3651
  RUN_LANE_LABEL,
3625
3652
  NEEDS_HUMAN_REVIEW_LABEL,
3626
3653
  ALREADY_DONE_LABEL,
3654
+ BUGFIX_LABEL,
3655
+ RELEASE_CLASSIFICATION_NAMES,
3627
3656
  has_any_label,
3628
3657
  has_label_name,
3629
3658
  run_tail,
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env tsx
2
2
  import {
3
3
  pretool_guard
4
- } from "./chunk-BNWIOJFU.js";
5
- import "./chunk-WWTMHHZU.js";
4
+ } from "./chunk-BNGGEY3Z.js";
5
+ import "./chunk-Q7LE77CO.js";
6
6
  import {
7
7
  build_envelope,
8
8
  compose_context,
@@ -3,8 +3,8 @@ import {
3
3
  pretool_guard,
4
4
  pretool_outcome,
5
5
  pretool_outcome_async
6
- } from "./chunk-BNWIOJFU.js";
7
- import "./chunk-WWTMHHZU.js";
6
+ } from "./chunk-BNGGEY3Z.js";
7
+ import "./chunk-Q7LE77CO.js";
8
8
  import "./chunk-3V37IID6.js";
9
9
  import "./chunk-NXL3SBXY.js";
10
10
  import "./chunk-BVMSPG3M.js";
@@ -19,7 +19,7 @@ import {
19
19
  run_hold,
20
20
  run_invocation,
21
21
  run_watcher_guard
22
- } from "./chunk-WWTMHHZU.js";
22
+ } from "./chunk-Q7LE77CO.js";
23
23
  import {
24
24
  session_language
25
25
  } from "./chunk-6V5OPIUU.js";
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.972.0",
2
+ "version": "1.978.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": [
@@ -193,7 +193,7 @@
193
193
  "@stylistic/eslint-plugin": "^5.10.0",
194
194
  "@types/node": "^26.6.3",
195
195
  "@types/semver": "^7.8.0",
196
- "cspell": "^10.3.5",
196
+ "cspell": "^10.3.6",
197
197
  "esbuild": "^0.28.2",
198
198
  "eslint": "^10.11.0",
199
199
  "eslint-config-prettier": "^10.1.8",
@@ -1,18 +1,15 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { fileURLToPath } from 'node:url'
3
+ import {
4
+ BREAKING_CHANGE_LABEL as BREAKING,
5
+ BUGFIX_LABEL as BUGFIX,
6
+ RELEASE_CLASSIFICATION_NAMES as CLASSIFICATION_LABELS,
7
+ ENHANCEMENT_LABEL,
8
+ type ReleaseClassification,
9
+ } from '#scripts/git/issue-labels'
3
10
  import { execaSync } from 'execa'
4
11
  import { z } from 'zod'
5
12
 
6
- const BUGFIX = 'bugfix'
7
- const BREAKING = 'breaking-change'
8
- const CLASSIFICATION_LABELS = [
9
- BREAKING,
10
- 'enhancement',
11
- BUGFIX,
12
- 'other-change',
13
- 'ignore-for-release',
14
- ] as const
15
- type ReleaseClassification = (typeof CLASSIFICATION_LABELS)[number]
16
13
  const CLASSIFICATION_SET: ReadonlySet<string> = new Set(CLASSIFICATION_LABELS)
17
14
  const ISSUE_BUG_LABEL = 'bug'
18
15
  const DECLARATION = /^- リリース分類: (.+)$/gmu
@@ -50,7 +47,7 @@ function release_candidates(
50
47
  ): ReadonlyArray<ReleaseClassification> {
51
48
  if (!candidates.includes(BREAKING)) return candidates
52
49
 
53
- return candidates.filter((candidate) => candidate !== 'enhancement')
50
+ return candidates.filter((candidate) => candidate !== ENHANCEMENT_LABEL)
54
51
  }
55
52
 
56
53
  function issue_candidates(issue_json: string): ReadonlyArray<ReleaseClassification> {
@@ -132,4 +129,8 @@ const pr_classification = {
132
129
  select_issue_classification,
133
130
  }
134
131
 
135
- export { pr_classification, CLASSIFICATION_LABELS, type ReleaseClassification }
132
+ export { pr_classification }
133
+ export {
134
+ RELEASE_CLASSIFICATION_NAMES as CLASSIFICATION_LABELS,
135
+ type ReleaseClassification,
136
+ } from '#scripts/git/issue-labels'
@@ -186,14 +186,16 @@ const DEPTH_LABELS: ReadonlyArray<{
186
186
  },
187
187
  ]
188
188
 
189
- // What `josh start` provisions on a new repository so the first `kickoff` finds every label a run
190
- // applies with its intended color (joshuafolkken/kit#2197). The `epic` and `in-progress` metadata is
191
- // the one the workflow documents create them with; the labels only a person applies are left out.
192
- const WORKFLOW_LABELS: ReadonlyArray<{
189
+ interface LabelDefinition {
193
190
  name: string
194
191
  color: string
195
192
  description: string
196
- }> = [
193
+ }
194
+
195
+ // What `josh start` provisions on a new repository so the first `kickoff` finds every label a run
196
+ // applies with its intended color (joshuafolkken/kit#2197). The `epic` and `in-progress` metadata is
197
+ // the one the workflow documents create them with; the labels only a person applies are left out.
198
+ const WORKFLOW_LABELS: ReadonlyArray<LabelDefinition> = [
197
199
  {
198
200
  name: EPIC_LABEL,
199
201
  color: '5319e7',
@@ -214,6 +216,41 @@ const WORKFLOW_LABELS: ReadonlyArray<{
214
216
  ...DEPTH_LABELS,
215
217
  ]
216
218
 
219
+ // The pull request release classifications `pr-classification.yml` requires exactly one of
220
+ // (joshuafolkken/kit#2797). The check is distributed by `josh sync`, so the labels it asks for are
221
+ // provisioned from this same list — a repository that never had them would otherwise fail the check
222
+ // on every pull request that is not an `enhancement`.
223
+ const BUGFIX_LABEL = 'bugfix'
224
+ const OTHER_CHANGE_LABEL = 'other-change'
225
+ const IGNORE_FOR_RELEASE_LABEL = 'ignore-for-release'
226
+ const RELEASE_CLASSIFICATION_NAMES = [
227
+ BREAKING_CHANGE_LABEL,
228
+ ENHANCEMENT_LABEL,
229
+ BUGFIX_LABEL,
230
+ OTHER_CHANGE_LABEL,
231
+ IGNORE_FOR_RELEASE_LABEL,
232
+ ] as const
233
+ type ReleaseClassification = (typeof RELEASE_CLASSIFICATION_NAMES)[number]
234
+
235
+ const RELEASE_CLASSIFICATION_METADATA: Readonly<
236
+ Record<ReleaseClassification, Omit<LabelDefinition, 'name'>>
237
+ > = {
238
+ [BREAKING_CHANGE_LABEL]: { color: 'd93f0b', description: 'Release: a breaking change (major)' },
239
+ [ENHANCEMENT_LABEL]: { color: '84b6eb', description: 'Release: a new feature (minor)' },
240
+ [BUGFIX_LABEL]: { color: 'd73a4a', description: 'Release: a bug fix (patch)' },
241
+ [OTHER_CHANGE_LABEL]: { color: 'c2e0c6', description: 'Release: any other change (patch)' },
242
+ [IGNORE_FOR_RELEASE_LABEL]: { color: 'cfd3d7', description: 'Release: left out of the notes' },
243
+ }
244
+
245
+ const RELEASE_CLASSIFICATION_LABELS: ReadonlyArray<LabelDefinition> =
246
+ RELEASE_CLASSIFICATION_NAMES.map((name) => ({ name, ...RELEASE_CLASSIFICATION_METADATA[name] }))
247
+
248
+ // Every label kit provisions on a repository — `josh start` on a new one, `josh sync` on every run.
249
+ const REPOSITORY_LABELS: ReadonlyArray<LabelDefinition> = [
250
+ ...WORKFLOW_LABELS,
251
+ ...RELEASE_CLASSIFICATION_LABELS,
252
+ ]
253
+
217
254
  // The shape `gh issue list --json labels` returns; narrowed here so the predicate below takes any
218
255
  // listing row without importing a schema.
219
256
  interface LabelReference {
@@ -295,13 +332,18 @@ const ALL_LABELS: ReadonlySet<string> = new Set([
295
332
  DEPTH_0_LABEL,
296
333
  DEPTH_1_LABEL,
297
334
  DEPTH_2_LABEL,
335
+ BUGFIX_LABEL,
336
+ OTHER_CHANGE_LABEL,
337
+ IGNORE_FOR_RELEASE_LABEL,
298
338
  ])
299
339
 
340
+ export type { LabelDefinition, ReleaseClassification }
300
341
  export {
301
342
  ALL_LABELS,
302
343
  ALREADY_DONE_LABEL,
303
344
  AUTO_OK_LABEL,
304
345
  BUG_LABEL,
346
+ BUGFIX_LABEL,
305
347
  BREAKING_CHANGE_LABEL,
306
348
  depth_label_of,
307
349
  DEPTH_0_LABEL,
@@ -314,12 +356,17 @@ export {
314
356
  FILING_ROUTE_LABELS,
315
357
  has_any_label,
316
358
  has_label_name,
359
+ IGNORE_FOR_RELEASE_LABEL,
317
360
  IN_PROGRESS_LABEL,
318
361
  INTERRUPT_ROUTE_LABEL,
319
362
  label_name_of,
320
363
  NEEDS_DECISION_LABEL,
321
364
  NEEDS_HUMAN_REVIEW_LABEL,
322
365
  NOT_DIRECTLY_RUNNABLE_LABELS,
366
+ OTHER_CHANGE_LABEL,
367
+ RELEASE_CLASSIFICATION_LABELS,
368
+ RELEASE_CLASSIFICATION_NAMES,
369
+ REPOSITORY_LABELS,
323
370
  REVIEW_CAP_ROUTE_LABEL,
324
371
  REVIEW_ROUND2_SKIPPED_LABEL,
325
372
  RUN_LANE_LABEL,
@@ -0,0 +1,92 @@
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { z } from 'zod'
4
+ import { init_install, type InstallStep } from './init-install'
5
+ import { init_logic_workspace } from './init-logic-workspace'
6
+
7
+ const KIT_PACKAGE_NAME = '@joshuafolkken/kit'
8
+ const NODE_MODULES = 'node_modules'
9
+ const PACKAGE_JSON = 'package.json'
10
+ // The static template lists exactly the build scripts kit's own dependency tree carries, which is
11
+ // what installing kit alone needs approved (joshuafolkken/kit#2785).
12
+ const KIT_BUILDS_TEMPLATE = 'templates/pnpm-workspace.static.yaml'
13
+ const HANDOFF_FAILURE = 'josh init from the project-installed kit failed — see the output above'
14
+ // Set on the handed-off run, so a kit that still does not find itself installed in the project — pnpm
15
+ // put it somewhere else — stops instead of installing and handing off again without end.
16
+ const HANDOFF_ENV = 'JOSH_INIT_HANDED_OFF'
17
+ const REPEATED_HANDOFF = `the kit this project installed is not at ${NODE_MODULES}/${KIT_PACKAGE_NAME} — run \`pnpm exec josh init\` where it is`
18
+
19
+ const dependency_fields_schema = z.looseObject({
20
+ dependencies: z.record(z.string(), z.string()).optional(),
21
+ devDependencies: z.record(z.string(), z.string()).optional(),
22
+ })
23
+
24
+ function real_directory(directory: string): string | undefined {
25
+ return existsSync(directory) ? realpathSync(directory) : undefined
26
+ }
27
+
28
+ // A `pnpm dlx` (or global) run executes whatever kit its cache held, which may be days behind the
29
+ // registry — and `init` pins the kit it runs as, so a stale cache used to leave its version and its
30
+ // templates in the project for good (joshuafolkken/kit#2794). Only the kit the project itself installs
31
+ // does the setup. Compared by real path, so a global virtual store shared with the project still
32
+ // reads as the project's own kit.
33
+ function is_project_kit(package_directory: string, project_root: string): boolean {
34
+ const installed = real_directory(path.join(project_root, NODE_MODULES, KIT_PACKAGE_NAME))
35
+
36
+ return installed !== undefined && installed === real_directory(package_directory)
37
+ }
38
+
39
+ function kit_build_flags(template: string): Array<string> {
40
+ return [...init_logic_workspace.template_build_values(template)]
41
+ .filter(([, value]) => value === 'true')
42
+ .map(([name]) => `--allow-build=${name}`)
43
+ }
44
+
45
+ function has_kit_dependency(project_root: string): boolean {
46
+ const manifest_path = path.join(project_root, PACKAGE_JSON)
47
+ if (!existsSync(manifest_path)) return false
48
+ const parsed: unknown = JSON.parse(readFileSync(manifest_path, 'utf8'))
49
+ const manifest = dependency_fields_schema.parse(parsed)
50
+ const names = Object.keys({ ...manifest.dependencies, ...manifest.devDependencies })
51
+
52
+ return names.includes(KIT_PACKAGE_NAME)
53
+ }
54
+
55
+ // A project that already lists kit keeps the version it chose; otherwise pnpm picks it, under the
56
+ // project's own `minimumReleaseAge`, so the version installed is never the cache's decision.
57
+ function kit_install_step(has_kit: boolean, template: string): InstallStep {
58
+ if (has_kit) return { label: 'pnpm install', command: 'pnpm', args: ['install'] }
59
+ const args = ['add', '-D', ...kit_build_flags(template), KIT_PACKAGE_NAME]
60
+
61
+ return { label: `pnpm ${args.join(' ')}`, command: 'pnpm', args }
62
+ }
63
+
64
+ function handoff_step(args: ReadonlyArray<string>): InstallStep {
65
+ const handoff_args = ['exec', 'josh', 'init', ...args]
66
+
67
+ return {
68
+ label: `pnpm ${handoff_args.join(' ')}`,
69
+ command: 'pnpm',
70
+ args: handoff_args,
71
+ env: { [HANDOFF_ENV]: '1' },
72
+ }
73
+ }
74
+
75
+ // Returns the failure to report, or undefined once the project-installed kit finished the setup.
76
+ function hand_off(
77
+ package_directory: string,
78
+ project_root: string,
79
+ args: ReadonlyArray<string>,
80
+ ): string | undefined {
81
+ if (process.env[HANDOFF_ENV] !== undefined) return REPEATED_HANDOFF
82
+ console.info('\n🚀 Installing @joshuafolkken/kit into the project, then running its josh init')
83
+ const template = readFileSync(path.join(package_directory, KIT_BUILDS_TEMPLATE), 'utf8')
84
+ const install = kit_install_step(has_kit_dependency(project_root), template)
85
+ if (!init_install.did_step_succeed(install, project_root)) return `${install.label} failed`
86
+ if (!init_install.did_step_succeed(handoff_step(args), project_root)) return HANDOFF_FAILURE
87
+
88
+ return undefined
89
+ }
90
+
91
+ const init_bootstrap = { is_project_kit, kit_build_flags, hand_off }
92
+ export { init_bootstrap }
@@ -4,6 +4,7 @@ interface InstallStep {
4
4
  label: string
5
5
  command: string
6
6
  args: ReadonlyArray<string>
7
+ env?: Readonly<Record<string, string>>
7
8
  }
8
9
 
9
10
  interface InstallRequest {
@@ -40,6 +41,7 @@ function did_step_succeed(step: InstallStep, project_root: string): boolean {
40
41
  console.info(`\n$ ${step.label}`)
41
42
  const result = execaSync(step.command, step.args, {
42
43
  cwd: project_root,
44
+ env: step.env ?? {},
43
45
  stdio: 'inherit',
44
46
  reject: false,
45
47
  })
@@ -60,5 +62,6 @@ function run_post_init_steps(project_root: string): string | undefined {
60
62
  return undefined
61
63
  }
62
64
 
63
- const init_install = { NO_INSTALL_FLAG, split_install_flag, run_post_init_steps }
65
+ const init_install = { NO_INSTALL_FLAG, split_install_flag, did_step_succeed, run_post_init_steps }
64
66
  export { init_install }
67
+ export type { InstallStep }
@@ -138,6 +138,6 @@ function merge_workspace_yaml(existing: string, template: string): string {
138
138
  return append_user_blocks(cleaned, new_keys, template)
139
139
  }
140
140
 
141
- const init_logic_workspace = { merge_workspace_yaml }
141
+ const init_logic_workspace = { merge_workspace_yaml, template_build_values }
142
142
 
143
143
  export { init_logic_workspace }
@@ -15,12 +15,13 @@ import { execaSync } from 'execa'
15
15
  import { z } from 'zod'
16
16
  import { init_actions, PRETTIER_CONFIG_JS, type FileAction } from './init-actions'
17
17
  import { init_ai_copy } from './init-ai-copy'
18
+ import { init_bootstrap } from './init-bootstrap'
18
19
  import { init_install } from './init-install'
19
20
  import { init_logic } from './init-logic'
20
21
  import { PACKAGE_DIR, PROJECT_ROOT } from './init-paths'
21
22
  import { init_static } from './init-static'
22
23
  import { plugin_install_hint_module } from './plugin-install-hint'
23
- import { project_profile, type ProjectShape } from './project-profile'
24
+ import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
24
25
 
25
26
  const PACKAGE_JSON = 'package.json'
26
27
  const KIT_PACKAGE_NAME = '@joshuafolkken/kit'
@@ -320,10 +321,18 @@ function finish_dependencies(shape: ProjectShape, is_install: boolean): void {
320
321
  if (failure !== undefined) throw new Error(failure)
321
322
  }
322
323
 
323
- function main(args: ReadonlyArray<string> = []): void {
324
- if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return
325
- const { is_install, rest } = init_install.split_install_flag(args)
326
- const requested = project_profile.requested_profile(rest, INIT_USAGE)
324
+ // A kit run from outside the project — `pnpm dlx`, a global install — sets nothing up itself: it
325
+ // installs the project's kit and hands the whole run to that one (joshuafolkken/kit#2794). Under
326
+ // `--no-install` nothing is installed, so the running kit does the setup as before.
327
+ function did_hand_off(args: ReadonlyArray<string>): boolean {
328
+ if (init_bootstrap.is_project_kit(PACKAGE_DIR, PROJECT_ROOT)) return false
329
+ const failure = init_bootstrap.hand_off(PACKAGE_DIR, PROJECT_ROOT, args)
330
+ if (failure !== undefined) throw new Error(failure)
331
+
332
+ return true
333
+ }
334
+
335
+ function set_up_project(requested: ProjectProfile | undefined, is_install: boolean): void {
327
336
  const shape = project_profile.inspect_project(PROJECT_ROOT, requested)
328
337
 
329
338
  initialize_project(shape)
@@ -331,6 +340,15 @@ function main(args: ReadonlyArray<string> = []): void {
331
340
  console.info('\n✅ Done.\n')
332
341
  }
333
342
 
343
+ function main(args: ReadonlyArray<string> = []): void {
344
+ if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return
345
+ const { is_install, rest } = init_install.split_install_flag(args)
346
+ // Validated before the hand-off, so a malformed invocation is refused before kit is installed.
347
+ const requested = project_profile.requested_profile(rest, INIT_USAGE)
348
+
349
+ if (!is_install || !did_hand_off(args)) set_up_project(requested, is_install)
350
+ }
351
+
334
352
  function run_cli(args: ReadonlyArray<string>): void {
335
353
  try {
336
354
  main(args)
@@ -46,7 +46,7 @@ const STEP_LABELS: Readonly<Record<StartStep, string>> = {
46
46
  initialize: 'Initialize kit (the same setup as josh init)',
47
47
  commit: 'Commit every file as the initial commit',
48
48
  repository: 'Create the GitHub repository and push main',
49
- labels: 'Add the workflow labels the repository is missing',
49
+ labels: 'Add the workflow and release-classification labels the repository is missing',
50
50
  }
51
51
 
52
52
  function refused(refusal: string): StepPlan {
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path'
2
2
  import { git_gh_exec } from '#scripts/git/git-gh-exec'
3
- import { WORKFLOW_LABELS } from '#scripts/git/issue-labels'
3
+ import { repository_labels } from '#scripts/repo/repository-labels'
4
4
  import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
5
5
  import { execaSync } from 'execa'
6
6
  import { main as init_main } from './init'
@@ -14,10 +14,7 @@ interface StepContext {
14
14
  visibility: Visibility
15
15
  }
16
16
 
17
- type WorkflowLabel = (typeof WORKFLOW_LABELS)[number]
18
-
19
17
  const DEFAULT_BRANCH = 'main'
20
- const LABELS_ENDPOINT = 'repos/{owner}/{repo}/labels'
21
18
  const GH_INSTALL_HINT =
22
19
  '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
20
  const GH_AUTH_HINT =
@@ -100,29 +97,10 @@ function create_github_repository(context: StepContext): void {
100
97
  run('gh', ['repo', 'create', name, visibility, ...source], context.root)
101
98
  }
102
99
 
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)
100
+ // The repository was created by the step before, so `gh` resolves the placeholder from the
101
+ // directory `josh start` runs in, which is the root.
102
+ function add_missing_labels(): void {
103
+ repository_labels.ensure_labels('{owner}/{repo}')
126
104
  }
127
105
 
128
106
  const STEP_ACTIONS: Readonly<Record<StartStep, (context: StepContext) => void>> = {
@@ -160,7 +138,6 @@ const start_steps = {
160
138
  read_git_state,
161
139
  github_cli_refusal,
162
140
  self_run_refusal,
163
- missing_workflow_labels,
164
141
  run_steps,
165
142
  }
166
143
  export { start_steps }
@@ -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 }
@@ -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
 
@@ -0,0 +1,104 @@
1
+ import { run_hold, type HoldRead, type RunHold } from './run-hold'
2
+
3
+ // joshuafolkken/kit#2796: a `halfrun` stops before its commit and **keeps** its hold, because the
4
+ // uncommitted, hand-verified work is exactly what a second run would trample. The person then types
5
+ // `fullrun #N` to ship it — and `run:entry`'s claim answered `busy` against that very hold, or, once the
6
+ // hold had been cleared, the preflight advised stashing the verified diff away. The hand-off the
7
+ // `halfrun` stop promised could not land, and each run improvised its own way past the guard.
8
+ //
9
+ // **The stop is recorded, never inferred.** A hold naming #N over a dirty tree is also what a `halfrun`
10
+ // still implementing and a `backlogrun` child leave, and adopting either hands a half-written diff to
11
+ // the gate and the merge. So the `halfrun` stop re-writes its own record with a stop mark
12
+ // (`run:hold <N> --halfrun-stop`) — re-keying a `halfrun new`'s unnumbered record to the issue it filed —
13
+ // and only a marked record is adopted. An expired one qualifies too, since a person may verify the next
14
+ // day. The adopted record carries the `fullrun` mark instead, so the implementation-cut guard and
15
+ // `run:cut --resume` read the tree as this `fullrun`'s from here on.
16
+
17
+ const MARKED = 'marked'
18
+ const FOREIGN = 'foreign'
19
+ const UNREADABLE = 'unreadable'
20
+
21
+ type StopMark = typeof FOREIGN | typeof MARKED | typeof UNREADABLE
22
+
23
+ function hold_of(read: HoldRead): RunHold | undefined {
24
+ return read.kind === 'held' || read.kind === 'stale' ? read.hold : undefined
25
+ }
26
+
27
+ function is_halfrun_stop(read: HoldRead, issue: string, is_dirty: boolean): boolean {
28
+ const hold = hold_of(read)
29
+
30
+ return is_dirty && hold?.issue === issue && hold.is_halfrun_stop === true
31
+ }
32
+
33
+ // The stopping run's own record names its issue, or `new` when it entered as `halfrun new`; a free tree
34
+ // is marked as well, since the work to protect is sitting in it either way.
35
+ function is_own_record(read: HoldRead, issue: string): boolean {
36
+ const hold = hold_of(read)
37
+
38
+ return hold === undefined || hold.issue === issue || hold.issue === run_hold.UNNUMBERED_ISSUE
39
+ }
40
+
41
+ function mark_stop_at(target: string, issue: string): StopMark {
42
+ const read = run_hold.read_hold(target)
43
+
44
+ if (read.kind === 'unreadable') return UNREADABLE
45
+
46
+ if (!is_own_record(read, issue)) return FOREIGN
47
+
48
+ run_hold.release_hold(target)
49
+
50
+ return run_hold.create_halfrun_stop_hold(target, issue) ? MARKED : FOREIGN
51
+ }
52
+
53
+ // `false` leaves the record untouched, so the ordinary claim that follows decides the tree exactly as
54
+ // it did before this resume existed.
55
+ function adopt_at(target: string, issue: string, is_dirty: boolean): boolean {
56
+ if (!is_halfrun_stop(run_hold.read_hold(target), issue, is_dirty)) return false
57
+
58
+ run_hold.release_hold(target)
59
+
60
+ return run_hold.create_hold(target, issue, new Date(), true)
61
+ }
62
+
63
+ // **An unreadable git directory is no resume**, never a crash: `run:entry` must still print its one
64
+ // summary line, and the ordinary claim that follows answers `unknown` for the same tree.
65
+ async function hold_target(): Promise<string | undefined> {
66
+ try {
67
+ const directory = await run_hold.worktree_directory()
68
+
69
+ return directory === undefined ? undefined : run_hold.hold_path(directory)
70
+ } catch {
71
+ return undefined
72
+ }
73
+ }
74
+
75
+ // Read-only, so `run:entry` can ask the session budget before it takes anything over.
76
+ async function is_pending(issue: string): Promise<boolean> {
77
+ const target = await hold_target()
78
+
79
+ if (target === undefined) return false
80
+
81
+ return is_halfrun_stop(run_hold.read_hold(target), issue, await run_hold.is_tree_dirty())
82
+ }
83
+
84
+ async function adopt(issue: string): Promise<boolean> {
85
+ const target = await hold_target()
86
+
87
+ if (target === undefined) return false
88
+
89
+ return adopt_at(target, issue, await run_hold.is_tree_dirty())
90
+ }
91
+
92
+ const run_halfrun_resume = {
93
+ FOREIGN,
94
+ MARKED,
95
+ UNREADABLE,
96
+ adopt,
97
+ adopt_at,
98
+ is_halfrun_stop,
99
+ is_pending,
100
+ mark_stop_at,
101
+ }
102
+
103
+ export type { StopMark }
104
+ export { run_halfrun_resume }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { run_halfrun_resume } from './run-halfrun-resume'
3
4
  import { run_hold, type HoldRead, type RunHold } from './run-hold'
4
5
  import { run_preflight, type PreflightDecision } from './run-preflight'
5
6
  import { run_tidy_cli } from './run-tidy-cli'
@@ -46,11 +47,12 @@ const FORCE_FLAG = '--force'
46
47
  // What `fullrun #N`'s entry claims with, so the record says the run is a `fullrun` — the one run whose
47
48
  // implementation cut outside a lane resumes as `fullrun #N` (joshuafolkken/kit#2760).
48
49
  const FULLRUN_FLAG = '--fullrun'
49
- // The most a claim reads: the issue number and the `--fullrun` flag after it.
50
+ const HALFRUN_STOP_FLAG = '--halfrun-stop'
51
+ // The most a claim reads: the issue number and the one flag after it.
50
52
  const MAX_CLAIM_ARGUMENTS = 2
51
53
  const ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/u
52
54
  const USAGE =
53
- 'Usage: josh run:hold [<issue-number> [--fullrun]] | josh run:release [<issue-number> | --force]'
55
+ 'Usage: josh run:hold [<issue-number> [--fullrun | --halfrun-stop]] | josh run:release [<issue-number> | --force]'
54
56
 
55
57
  const HOLD_VERDICT = 'hold'
56
58
  const BUSY_VERDICT = 'busy'
@@ -62,6 +64,7 @@ const NONE_VERDICT = 'none'
62
64
  const HELD_VERDICT = 'held'
63
65
 
64
66
  const FORCE_RELEASE_KIND = 'force-release'
67
+ const HALFRUN_STOP_KIND = 'halfrun-stop'
65
68
 
66
69
  interface ClaimRequest {
67
70
  kind: 'claim'
@@ -70,7 +73,10 @@ interface ClaimRequest {
70
73
  }
71
74
 
72
75
  type HoldRequest =
73
- ClaimRequest | { kind: 'release'; claimant: string } | { kind: typeof FORCE_RELEASE_KIND }
76
+ | ClaimRequest
77
+ | { kind: 'release'; claimant: string }
78
+ | { kind: typeof FORCE_RELEASE_KIND }
79
+ | { kind: typeof HALFRUN_STOP_KIND; issue: string }
74
80
 
75
81
  const FORCE_RELEASE_REQUEST: HoldRequest = { kind: FORCE_RELEASE_KIND }
76
82
 
@@ -78,10 +84,13 @@ function release_request(claimant: string): HoldRequest {
78
84
  return { kind: 'release', claimant }
79
85
  }
80
86
 
81
- // The flag after a numbered claim: absent, or `--fullrun`. Anything else is a usage error.
87
+ // The flag after a numbered claim: absent, `--fullrun` or `--halfrun-stop`. Anything else is a usage
88
+ // error.
82
89
  function numbered_claim(issue: string, flag: string | undefined): HoldRequest | undefined {
83
90
  if (flag === undefined) return { kind: 'claim', issue }
84
91
 
92
+ if (flag === HALFRUN_STOP_FLAG) return { kind: HALFRUN_STOP_KIND, issue }
93
+
85
94
  return flag === FULLRUN_FLAG ? { kind: 'claim', issue, is_fullrun: true } : undefined
86
95
  }
87
96
 
@@ -282,9 +291,23 @@ async function read_worktree(): Promise<string | undefined> {
282
291
  }
283
292
  }
284
293
 
294
+ // **A `halfrun` stop marks its own record** (joshuafolkken/kit#2796) — the positive "this run has ended
295
+ // over its verified diff" that `run:entry` adopts on `fullrun #N`. Another run's record is left alone.
296
+ function mark_halfrun_stop(target: string, issue: string): number {
297
+ const mark = run_halfrun_resume.mark_stop_at(target, issue)
298
+
299
+ if (mark === run_halfrun_resume.MARKED) return report_hold()
300
+
301
+ if (mark === run_halfrun_resume.UNREADABLE) return report_unknown()
302
+
303
+ return report_busy(run_hold.race_message(run_hold.read_hold(target), target))
304
+ }
305
+
285
306
  async function dispatch(request: HoldRequest, target: string, is_linked: boolean): Promise<number> {
286
307
  if (request.kind === 'claim') return await claim(target, request, is_linked)
287
308
 
309
+ if (request.kind === HALFRUN_STOP_KIND) return mark_halfrun_stop(target, request.issue)
310
+
288
311
  if (request.kind === FORCE_RELEASE_KIND) return force_release(target)
289
312
 
290
313
  return release(target, request.claimant)
@@ -48,6 +48,10 @@ interface RunHold {
48
48
  // `backlogrun` child — which hold the tree just the same — must not be read as one. Optional and
49
49
  // `| undefined` because a record written before this field existed is simply not a `fullrun`'s.
50
50
  is_fullrun?: boolean | undefined
51
+ // Set only at a `halfrun`'s stop before commit (`run:hold <N> --halfrun-stop`, joshuafolkken/kit#2796):
52
+ // the positive record that the run has *ended* over its verified diff, which is what lets
53
+ // `fullrun #N` adopt the hold. A running `halfrun` or a `backlogrun` child never carries it.
54
+ is_halfrun_stop?: boolean | undefined
51
55
  }
52
56
 
53
57
  type HoldRead =
@@ -87,6 +91,7 @@ const run_hold_schema = z.object({
87
91
  taken_at: z.string(),
88
92
  pid: z.number(),
89
93
  is_fullrun: z.boolean().optional(),
94
+ is_halfrun_stop: z.boolean().optional(),
90
95
  })
91
96
 
92
97
  // `undefined` for anything that is not a well-formed record, so the caller decides what a broken one
@@ -156,6 +161,11 @@ function create_hold(
156
161
  return stamp_file.create_stamp(target, build_hold(issue, now, is_fullrun))
157
162
  }
158
163
 
164
+ // The claim a `halfrun` stop leaves behind: the same exclusive create, carrying the stop mark.
165
+ function create_halfrun_stop_hold(target: string, issue: string, now: Date = new Date()): boolean {
166
+ return stamp_file.create_stamp(target, { ...build_hold(issue, now), is_halfrun_stop: true })
167
+ }
168
+
159
169
  // **A tree with uncommitted work in it is never handed over, however old its record is.** No age can
160
170
  // be chosen that covers a `halfrun` stop or a `needs-human-review` stop: those are held across a
161
171
  // person's latency rather than a run's, and the thing that must not be trampled is sitting in the
@@ -249,6 +259,7 @@ const run_hold = {
249
259
  RELEASE_COMMAND,
250
260
  UNNUMBERED_ISSUE,
251
261
  classify,
262
+ create_halfrun_stop_hold,
252
263
  create_hold,
253
264
  describe_holder,
254
265
  foreign_release_message,
@@ -86,10 +86,21 @@ function resolve_scanner(project_root: string, platform: string): ScannerChoice
86
86
  return has_path_binary ? { path: BINARY_NAME, is_below_floor: true } : undefined
87
87
  }
88
88
 
89
+ // **Output is forwarded, never handed the terminal itself.** osv-scanner is a Go binary that probes a
90
+ // terminal on its stdout for its background color (an OSC 11 query followed by a cursor-position
91
+ // request) and never reads the answers, so they sat in the terminal's input queue and surfaced at the
92
+ // next shell prompt as `11;rgb:0101/0404/0909;1R` after a push ran this audit from the pre-push hook.
93
+ // A pipe means the scanner sees no terminal and asks nothing; the `inherit` half still prints its report.
94
+ // The fix belongs here, at the one command that asks, rather than on `git push`: piping the push would
95
+ // make its timeout wait on any process a hook left running (joshuafolkken/kit#2801).
96
+ const FORWARDED_OUTPUT = ['inherit', 'pipe'] as const
97
+
89
98
  function run_scanner(scanner_path: string): number {
90
99
  const { LOCKFILE_PATH, build_scanner_arguments } = security_audit_logic
91
100
  const result = execaSync(scanner_path, [...build_scanner_arguments(LOCKFILE_PATH)], {
92
- stdio: 'inherit',
101
+ stdin: 'inherit',
102
+ stdout: FORWARDED_OUTPUT,
103
+ stderr: FORWARDED_OUTPUT,
93
104
  reject: false,
94
105
  })
95
106
 
@@ -8,6 +8,7 @@ import { init_logic } from '#scripts/init/init-logic'
8
8
  import { PACKAGE_DIR, PROJECT_ROOT } from '#scripts/init/init-paths'
9
9
  import { plugin_install_hint_module } from '#scripts/init/plugin-install-hint'
10
10
  import { auto_merge_setting } from '#scripts/repo/auto-merge-setting'
11
+ import { repository_labels } from '#scripts/repo/repository-labels'
11
12
  import { project_config } from '#scripts/safe-chain/project-config'
12
13
  import { security_updates } from '#scripts/security/security-updates'
13
14
  import { sonar_file } from '#scripts/security/sonar-file'
@@ -364,9 +365,14 @@ function sync_package_json_migrations(destination_path: string): void {
364
365
  // unless the repository allows auto-merge (joshuafolkken/kit#834). Neither ever fails the sync: both
365
366
  // are GitHub-side state, not synced artifacts. Unconditional, unlike `init`, because `sync`
366
367
  // overwrites both files on every run.
368
+ //
369
+ // The labels are the same kind of GitHub-side prerequisite, and one kit *can* write: the synced
370
+ // `pr-classification.yml` requires one of the release classification labels, so the missing ones are
371
+ // created here rather than reported (joshuafolkken/kit#2797).
367
372
  function report_repository_settings(name_with_owner: string | undefined): void {
368
373
  security_updates.report_security_updates_section(name_with_owner)
369
374
  auto_merge_setting.report_auto_merge_section(name_with_owner)
375
+ repository_labels.ensure_labels(name_with_owner)
370
376
  }
371
377
 
372
378
  function sync_project_artifacts(is_force: boolean): void {