@joshuafolkken/kit 1.983.0 → 1.1003.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-child.md +4 -5
  2. package/.claude/skills/workflow-commands/backlogrun-steps.md +1 -1
  3. package/.github/workflows/sonar-qube.yml +12 -2
  4. package/CLAUDE.md +2 -2
  5. package/README.md +30 -21
  6. package/dist/CLAUDE.md +2 -2
  7. package/dist/hooks/batch-guard.js +4 -4
  8. package/dist/hooks/{chunk-ZT65LR3U.js → chunk-4GDR2YTV.js} +4 -4
  9. package/dist/hooks/{chunk-ASUZGZFT.js → chunk-56LGMJJV.js} +2 -2
  10. package/dist/hooks/{chunk-LBXQHXMC.js → chunk-6PSTVOLH.js} +263 -74
  11. package/dist/hooks/{chunk-P3UX7SFN.js → chunk-A2H5I2VD.js} +2 -2
  12. package/dist/hooks/{chunk-ERXMJJOV.js → chunk-AWTFKKFW.js} +1 -1
  13. package/dist/hooks/{chunk-IM3KM25Y.js → chunk-EZLHXKJ6.js} +10 -7
  14. package/dist/hooks/{chunk-APVOACVT.js → chunk-GBJUERYK.js} +1 -1
  15. package/dist/hooks/{chunk-5JX6HXDX.js → chunk-KPZN42RT.js} +3 -3
  16. package/dist/hooks/{chunk-47SPMGOD.js → chunk-PAXMLGSS.js} +26 -7
  17. package/dist/hooks/{chunk-RFPHKU7C.js → chunk-WMIR5SNX.js} +3 -3
  18. package/dist/hooks/codex-hook-adapter.js +10 -10
  19. package/dist/hooks/duplicate-read-guard.js +5 -5
  20. package/dist/hooks/format-edited.js +2 -2
  21. package/dist/hooks/investigation-guard.js +5 -5
  22. package/dist/hooks/pretool-guard.js +9 -9
  23. package/dist/hooks/stop-guard.js +98 -26
  24. package/dist/josh.js +5 -5
  25. package/package.json +5 -1
  26. package/prettier/basic.d.ts +3 -0
  27. package/prettier/basic.js +10 -0
  28. package/prettier/index.js +2 -2
  29. package/prettier/static.js +3 -10
  30. package/prompts/sonar-hotspot-handling.md +6 -0
  31. package/prompts/testing-guide.md +1 -1
  32. package/scripts/agent/agent-diagnostics.ts +1 -0
  33. package/scripts/agent/agent-role-profile.ts +1 -1
  34. package/scripts/behavior/behavior-rules.ts +8 -1
  35. package/scripts/build/build-claude-md.ts +13 -4
  36. package/scripts/ci/pr-classification.ts +11 -2
  37. package/scripts/document/document-byte-budget.ts +1 -1
  38. package/scripts/gate/gate-steps.ts +1 -1
  39. package/scripts/gate/project-checks.ts +12 -11
  40. package/scripts/gate/type-check-command.ts +1 -1
  41. package/scripts/git/git-branch.ts +14 -3
  42. package/scripts/git/git-command.ts +5 -2
  43. package/scripts/git/git-issue.ts +13 -5
  44. package/scripts/git/git-pr.ts +16 -10
  45. package/scripts/git/git-preflight.ts +138 -0
  46. package/scripts/git/telegram-notify.ts +21 -0
  47. package/scripts/hooks/stop-guard.ts +2 -0
  48. package/scripts/init/basic-path-migration.ts +24 -0
  49. package/scripts/init/distributed-paths.ts +22 -0
  50. package/scripts/init/init-actions.ts +27 -19
  51. package/scripts/init/init-ai-copy.ts +86 -28
  52. package/scripts/init/{init-static.ts → init-basic.ts} +6 -6
  53. package/scripts/init/init-bootstrap.ts +4 -3
  54. package/scripts/init/init-install.ts +1 -1
  55. package/scripts/init/init-logic-templates.ts +1 -1
  56. package/scripts/init/init-logic.ts +1 -1
  57. package/scripts/init/init.ts +41 -19
  58. package/scripts/init/kit-setup-state.ts +30 -0
  59. package/scripts/init/kit-written-paths.ts +33 -0
  60. package/scripts/init/project-profile-cli.ts +15 -1
  61. package/scripts/init/project-profile.ts +41 -12
  62. package/scripts/init/session-language-environment.ts +83 -0
  63. package/scripts/init/start-exec.ts +28 -0
  64. package/scripts/init/start-plan.ts +35 -13
  65. package/scripts/init/start-prompt.ts +22 -8
  66. package/scripts/init/start-setup-pr.ts +138 -0
  67. package/scripts/init/start-steps.ts +30 -30
  68. package/scripts/init/start.ts +7 -3
  69. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +3 -3
  70. package/scripts/josh/josh-commands-development.ts +3 -3
  71. package/scripts/josh/josh-commands-project.ts +2 -2
  72. package/scripts/lane/lane-leftover.ts +143 -0
  73. package/scripts/lane/lane-open.ts +14 -1
  74. package/scripts/lint/cspell-cached.ts +3 -3
  75. package/scripts/lint/format.ts +1 -1
  76. package/scripts/lint/lint-parallel.ts +2 -2
  77. package/scripts/propagate/propagate-steps.ts +14 -1
  78. package/scripts/rules/filing-cap.ts +1 -0
  79. package/scripts/rules/issue-citation.ts +30 -1
  80. package/scripts/rules/last-prompt.ts +79 -0
  81. package/scripts/rules/stop-rules.ts +5 -3
  82. package/scripts/run/run-hold.ts +3 -3
  83. package/scripts/run/run-preflight.ts +75 -5
  84. package/scripts/run/run-progress-cli.ts +14 -2
  85. package/scripts/safe-chain/ci-installer-pin.ts +19 -12
  86. package/scripts/safe-chain/project-config.ts +1 -1
  87. package/scripts/sync/sync-ai-files.ts +204 -0
  88. package/scripts/sync/sync.ts +48 -184
  89. package/scripts/test/josh-harness-environment.ts +1 -1
  90. package/scripts/test/pilot-files.ts +0 -1
  91. package/scripts/test/test-declared-changed.ts +1 -1
  92. package/scripts/test/test-declared-logic.ts +55 -14
  93. package/scripts/test/test-declared.ts +23 -11
  94. package/scripts/test/test-unit-guard.ts +6 -6
  95. package/scripts/time-runtime/time-shell.ts +36 -2
  96. package/scripts-ai/git-workflow.ts +10 -0
  97. package/templates/CLAUDE.basic.md +8 -0
  98. package/templates/workflows/ci.yml +27 -0
  99. /package/{templates/CLAUDE.static.md → dist/CLAUDE.basic.md} +0 -0
  100. /package/templates/{cursorrules.static → cursorrules.basic} +0 -0
  101. /package/templates/{pnpm-workspace.static.yaml → pnpm-workspace.basic.yaml} +0 -0
  102. /package/templates/{prettierignore.static → prettierignore.basic} +0 -0
@@ -23,7 +23,7 @@ Req N: ...
23
23
 
24
24
  - **Every user-facing behavior change** (UI interaction, keyboard shortcut, visible state change) must have a corresponding E2E test.
25
25
  - **Every logic/utility change** (pure functions, filters, transforms) must have a unit test.
26
- - **static の HTML/CSS だけの変更:** `package.json` に `josh.profile: static` が記録されていれば自動テストは不要。ブラウザーで対象ページを開き、想定する画面幅でレイアウト・リンク・HTML 内の操作を確認して結果を記録する。同じ変更に JavaScript または TypeScript のファイルが含まれれば自動テストを行う。
26
+ - **basic で kit がテストできない言語だけの変更:** `package.json` に `josh.profile: basic`(改名前の `static` も同じ)が記録され、変更が HTML/CSS や Lua などのソースだけなら自動テストは不要。HTML/CSS はブラウザーで対象ページを開き、想定する画面幅でレイアウト・リンク・HTML 内の操作を確認する。そのほかのソースは変更した処理を実際に動かす。どちらも結果を記録する。同じ変更に JavaScript または TypeScript のファイルが含まれれば自動テストを行う。
27
27
  - If a test is genuinely infeasible (e.g., native OS date-picker popup cannot be driven by Playwright), write a comment in the test file explaining why and test the closest observable behavior instead (e.g., that the editor remains open after the `change` event fires).
28
28
  - Do **not** report a requirement as done if its test is missing.
29
29
 
@@ -37,6 +37,7 @@ const PROVIDER_CLI_TABLE: Readonly<Record<AgentProvider, ProviderCli>> = {
37
37
  const MODEL_CLI_FLOORS: ReadonlyMap<string, string> = new Map([
38
38
  ['claude-opus-5-5', '2.1.280'],
39
39
  ['gpt-6-sol', '0.155.0'],
40
+ ['gpt-6.1-sol', '0.159.1'],
40
41
  ])
41
42
 
42
43
  const default_ports: DiagnosticPorts = {
@@ -44,7 +44,7 @@ const OPENAI_PROVIDER: AgentProvider = 'openai'
44
44
  // whenever the CLI moves it, so a run log could not say which model produced it and a model migration
45
45
  // could not be measured apart from everything else. A new lane records the id it resolved; a lane
46
46
  // created before a migration keeps the model it recorded (`with_phase_effort` leaves it untouched).
47
- const OPENAI_MODEL = 'gpt-6-sol'
47
+ const OPENAI_MODEL = 'gpt-6.1-sol'
48
48
  const ANTHROPIC_MODEL = 'claude-opus-5-5'
49
49
  const CODEX_SESSION_KEY = 'CODEX_THREAD_ID'
50
50
 
@@ -1,4 +1,5 @@
1
1
  import { json_value } from '#scripts/lib/json-value'
2
+ import { time_shell } from '#scripts/time-runtime/time-shell'
2
3
  import type { Block, TranscriptLine } from '#scripts/time-runtime/time-transcript-line'
3
4
  import type { Assertion, Finding } from './behavior-assertion'
4
5
 
@@ -57,8 +58,14 @@ function command_of(block: Block): string | undefined {
57
58
  // Whether any segment of a command directly mutates the index: it begins with a staging or commit
58
59
  // subcommand and is not a dry run. Each `;`/`&&`/`|`-separated segment is judged on its own, so a
59
60
  // mutation anywhere in a chain is caught while a mention inside another command's argument is not.
61
+ //
62
+ // Quoted spans are removed before the split (joshuafolkken/kit#2841): a `|` inside a quoted pattern —
63
+ // `grep -E "commit|git add" file` — is not a separator, and cutting there synthesized a segment that
64
+ // began with `git add` out of a read-only search. `unquoted` honors escapes and heredoc bodies, so a
65
+ // stray `it's` cannot pair with a later quote and erase the real `git add` between them.
60
66
  function is_index_mutation(command: string): boolean {
61
- return command
67
+ return time_shell
68
+ .unquoted(command)
62
69
  .split(SEGMENT_SEPARATOR)
63
70
  .map((segment) => segment.trim())
64
71
  .some((segment) => INDEX_MUTATION_HEAD.test(segment) && !is_dry_run(segment))
@@ -7,11 +7,17 @@ import { init_logic } from '#scripts/init/init-logic'
7
7
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
8
8
  const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
9
9
  const DIST_ROOT = path.join(REPO_ROOT, 'dist')
10
- const STATIC_CLAUDE_FILENAME = 'CLAUDE.static.md'
10
+ const BASIC_CLAUDE_FILENAME = 'CLAUDE.basic.md'
11
11
  const SOURCE_CLAUDE_MD = path.join(REPO_ROOT, 'CLAUDE.md')
12
12
  const DIST_CLAUDE_MD = path.join(DIST_ROOT, 'CLAUDE.md')
13
- const SOURCE_STATIC_CLAUDE_MD = path.join(REPO_ROOT, 'templates', STATIC_CLAUDE_FILENAME)
14
- const DIST_STATIC_CLAUDE_MD = path.join(DIST_ROOT, STATIC_CLAUDE_FILENAME)
13
+ const SOURCE_BASIC_CLAUDE_MD = path.join(REPO_ROOT, 'templates', BASIC_CLAUDE_FILENAME)
14
+ // A basic project's `CLAUDE.md` imports the rules from dist/. One initialized before
15
+ // joshuafolkken/kit#2829 imports them under the profile's old name until `josh init` is re-run
16
+ // and migrates that import, so the same rules ship under both names.
17
+ const DIST_BASIC_CLAUDE_MDS: ReadonlyArray<string> = [
18
+ path.join(DIST_ROOT, BASIC_CLAUDE_FILENAME),
19
+ path.join(DIST_ROOT, 'CLAUDE.static.md'),
20
+ ]
15
21
 
16
22
  // Read kit's own CLAUDE.md and apply the distribution path transform. kit's source keeps relative
17
23
  // paths so it resolves inside the kit repository; the published copy rewrites them so every backtick
@@ -27,7 +33,9 @@ function generate_distributed_claude_md(): string {
27
33
  function build_claude_md(): string {
28
34
  mkdirSync(path.dirname(DIST_CLAUDE_MD), { recursive: true })
29
35
  writeFileSync(DIST_CLAUDE_MD, generate_distributed_claude_md())
30
- writeFileSync(DIST_STATIC_CLAUDE_MD, readFileSync(SOURCE_STATIC_CLAUDE_MD, 'utf8'))
36
+ const basic_rules = readFileSync(SOURCE_BASIC_CLAUDE_MD, 'utf8')
37
+
38
+ for (const destination of DIST_BASIC_CLAUDE_MDS) writeFileSync(destination, basic_rules)
31
39
 
32
40
  return DIST_CLAUDE_MD
33
41
  }
@@ -40,6 +48,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) main()
40
48
 
41
49
  export {
42
50
  build_claude_md,
51
+ DIST_BASIC_CLAUDE_MDS,
43
52
  generate_distributed_claude_md,
44
53
  SOURCE_CLAUDE_MD,
45
54
  DIST_CLAUDE_MD,
@@ -12,7 +12,11 @@ import { z } from 'zod'
12
12
 
13
13
  const CLASSIFICATION_SET: ReadonlySet<string> = new Set(CLASSIFICATION_LABELS)
14
14
  const ISSUE_BUG_LABEL = 'bug'
15
- const DECLARATION = /^- リリース分類: (.+)$/gmu
15
+ // The line an Issue body declares its release classification with. A body written by a script
16
+ // (`josh propagate`'s upgrade issue) builds it with `declaration`, so the writer and this parser share
17
+ // one format (joshuafolkken/kit#2858).
18
+ const DECLARATION_PREFIX = '- リリース分類: '
19
+ const DECLARATION = new RegExp(`^${DECLARATION_PREFIX}(.+)$`, 'gmu')
16
20
  const LEGACY_LABELS: ReadonlySet<string> = new Set(['semver-major', 'semver-minor'])
17
21
 
18
22
  const LABEL = z.object({ name: z.string() })
@@ -38,6 +42,10 @@ function classification_error(labels: ReadonlyArray<string>, author: string): st
38
42
  return `Choose one release classification: ${CLASSIFICATION_LABELS.join(', ')}`
39
43
  }
40
44
 
45
+ function declaration(label: ReleaseClassification): string {
46
+ return `${DECLARATION_PREFIX}${label}`
47
+ }
48
+
41
49
  function is_classification(value: string): value is ReleaseClassification {
42
50
  return CLASSIFICATION_SET.has(value)
43
51
  }
@@ -75,7 +83,7 @@ function select_issue_classification(issue_json: string): ReleaseClassification
75
83
 
76
84
  if (label === undefined || selected.length > 1) {
77
85
  throw new Error(
78
- `Choose one release classification before opening the PR: ${CLASSIFICATION_LABELS.join(', ')}; found: ${selected.join(', ') || 'none'}. Add "- リリース分類: <label>" to the Issue body.`,
86
+ `Choose one release classification before opening the PR: ${CLASSIFICATION_LABELS.join(', ')}; found: ${selected.join(', ') || 'none'}. Add "${DECLARATION_PREFIX}<label>" to the Issue body.`,
79
87
  )
80
88
  }
81
89
 
@@ -125,6 +133,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
125
133
  const pr_classification = {
126
134
  classification_error,
127
135
  check_event,
136
+ declaration,
128
137
  is_classification,
129
138
  select_issue_classification,
130
139
  }
@@ -82,7 +82,7 @@ const DOCUMENT_BYTE_BUDGET: ReadonlyArray<DocumentBudget> = [
82
82
  { path: '.claude/skills/workflow-commands/rule-residency.md', bytes: 24_576 },
83
83
  { path: '.claude/skills/workflow-commands/target-repository.md', bytes: 8192 },
84
84
  { path: '.claude/skills/workflow-commands/working-tree-hold.md', bytes: 8192 },
85
- { path: 'docs/josh-commands.md', bytes: 176_128 },
85
+ { path: 'docs/josh-commands.md', bytes: 180_224 },
86
86
  { path: 'prompts/agent-rules.md', bytes: 4096 },
87
87
  { path: 'prompts/coding-standards.md', bytes: 16_384 },
88
88
  { path: 'prompts/collaboration-workflow.md', bytes: 8192 },
@@ -19,7 +19,7 @@ async function build_gate_step(
19
19
  start_directory: string,
20
20
  plan: GatePlan = gate_plan.resolve_gate_plan(),
21
21
  ): Promise<GateStep> {
22
- const cwd = project_checks.is_static(start_directory)
22
+ const cwd = project_checks.is_basic(start_directory)
23
23
  ? project_checks.project_root(start_directory)
24
24
  : undefined
25
25
 
@@ -1,6 +1,7 @@
1
1
  import { existsSync, globSync, readFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { find_local_bin_upwards } from '#scripts/build/local-bin'
4
+ import { project_profile } from '#scripts/init/project-profile'
4
5
  import { SKIP_MARKER } from '#scripts/test/skip-marker'
5
6
  import { z } from 'zod'
6
7
 
@@ -74,11 +75,11 @@ function josh_settings(manifest: unknown): unknown {
74
75
  return manifest.josh
75
76
  }
76
77
 
77
- function is_static_manifest(manifest: unknown): boolean {
78
+ function is_basic_manifest(manifest: unknown): boolean {
78
79
  const josh = josh_settings(manifest)
79
80
  if (typeof josh !== 'object' || josh === null || !('profile' in josh)) return false
80
81
 
81
- return josh.profile === 'static'
82
+ return project_profile.parse_profile(josh.profile) === 'basic'
82
83
  }
83
84
 
84
85
  function read_manifest(directory: string): unknown {
@@ -91,8 +92,8 @@ function read_manifest(directory: string): unknown {
91
92
  }
92
93
  }
93
94
 
94
- function is_static(directory: string): boolean {
95
- return is_static_manifest(read_manifest(directory))
95
+ function is_basic(directory: string): boolean {
96
+ return is_basic_manifest(read_manifest(directory))
96
97
  }
97
98
 
98
99
  function is_declared(directory: string, package_name: string): boolean {
@@ -124,30 +125,30 @@ function skip_notice(check: string, reason: string): string {
124
125
  return `josh ${check}: ${reason} ${SKIP_MARKER} ${check}.`
125
126
  }
126
127
 
127
- // Shared by `josh check` and the gate's type-check step, so neither reaches `tsc` on a static
128
+ // Shared by `josh check` and the gate's type-check step, so neither reaches `tsc` on a basic
128
129
  // project that has nothing to type-check (joshuafolkken/kit#2709).
129
130
  function type_check_skip_reason(directory: string): string | undefined {
130
- if (!is_static(directory)) return undefined
131
+ if (!is_basic(directory)) return undefined
131
132
  if (!has_files(directory, TYPE_FILES)) return 'no TypeScript files were found'
132
133
  if (!has_config(directory, TYPE_CONFIGS)) return 'no TypeScript configuration was found'
133
134
 
134
135
  return missing_tool_reason(directory, 'typescript', 'tsc')
135
136
  }
136
137
 
137
- // Shared by `josh lint`, `josh lint:related` and `josh format`, so a static project without Web
138
+ // Shared by `josh lint`, `josh lint:related` and `josh format`, so a basic project without Web
138
139
  // files — a Python or Rust project, which `josh init` gives no Prettier — is skipped for the same
139
140
  // reason by all three (joshuafolkken/kit#2606, joshuafolkken/kit#2709).
140
141
  function prettier_skip_reason(directory: string): string | undefined {
141
- if (!is_static(directory)) return undefined
142
+ if (!is_basic(directory)) return undefined
142
143
  if (!has_files(directory, WEB_FILES)) return 'no HTML, CSS or JavaScript files were found'
143
144
 
144
145
  return missing_tool_reason(directory, 'prettier', 'prettier')
145
146
  }
146
147
 
147
- // Shared by `josh lint`, `josh lint:related` and `josh format`, so a static project without ESLint
148
+ // Shared by `josh lint`, `josh lint:related` and `josh format`, so a basic project without ESLint
148
149
  // is skipped for the same reason by all three (joshuafolkken/kit#2693, joshuafolkken/kit#2709).
149
150
  function eslint_skip_reason(directory: string): string | undefined {
150
- if (!is_static(directory)) return undefined
151
+ if (!is_basic(directory)) return undefined
151
152
  if (!has_files(directory, SCRIPT_FILES)) return 'no JavaScript or TypeScript files were found'
152
153
  if (!has_config(directory, ESLINT_CONFIGS)) return 'no ESLint configuration was found'
153
154
 
@@ -165,7 +166,7 @@ const project_checks = {
165
166
  has_bin,
166
167
  has_config,
167
168
  has_files,
168
- is_static,
169
+ is_basic,
169
170
  prettier_skip_reason,
170
171
  project_root,
171
172
  skip_notice,
@@ -5,7 +5,7 @@ import { execa } from 'execa'
5
5
  import { project_checks } from './project-checks'
6
6
 
7
7
  // `josh check` — the project-wide TypeScript check. It used to be a bare `tsc --noEmit` shell entry,
8
- // so on a static project with nothing to type-check it failed with `Command "tsc" not found` while
8
+ // so on a basic project with nothing to type-check it failed with `Command "tsc" not found` while
9
9
  // the gate, which asks `type_check_skip_reason` first, skipped the same step
10
10
  // (joshuafolkken/kit#2709). Both now read that one decision.
11
11
 
@@ -84,6 +84,18 @@ function has_same_issue_prefix(branch_a: string, branch_b: string): boolean {
84
84
  return prefix_a !== undefined && prefix_a === prefix_b
85
85
  }
86
86
 
87
+ // The one rule `check_and_create_branch` refuses on, kept pure so `git-preflight.ts` asks it before
88
+ // anything is pulled, switched or staged.
89
+ function is_mismatch(
90
+ current_branch: string,
91
+ target_branch_name: string,
92
+ default_branch: string,
93
+ ): boolean {
94
+ if (current_branch === default_branch || current_branch === target_branch_name) return false
95
+
96
+ return !has_same_issue_prefix(current_branch, target_branch_name)
97
+ }
98
+
87
99
  async function handle_default_branch(target_branch_name: string): Promise<void> {
88
100
  await pull_latest()
89
101
  const is_branch_exists: boolean = await exists(target_branch_name)
@@ -103,9 +115,7 @@ async function check_and_create_branch(
103
115
  return target_branch_name
104
116
  }
105
117
 
106
- if (current_branch !== target_branch_name) {
107
- if (has_same_issue_prefix(current_branch, target_branch_name)) return current_branch
108
-
118
+ if (is_mismatch(current_branch, target_branch_name, default_branch)) {
109
119
  git_error.display_branch_mismatch_error(current_branch, target_branch_name)
110
120
  }
111
121
 
@@ -118,6 +128,7 @@ const git_branch = {
118
128
  switch_to,
119
129
  exists,
120
130
  check_and_create_branch,
131
+ is_mismatch,
121
132
  // Exported so a lane's branch name can be checked against the real matcher rather than against a
122
133
  // second copy of the pattern (joshuafolkken/kit#1497). A copy is what let `lane/<N>` ship: nothing
123
134
  // asserted that the name `lane:open` writes is one this function accepts.
@@ -19,8 +19,11 @@ async function branch(): Promise<string> {
19
19
  // recognizes the observation ledger by its path then misses it: `git-staging.ts` stages the directory
20
20
  // — ledger included — into an unrelated pull request, and the flush refuses the directory as "other
21
21
  // changes".
22
- async function status(): Promise<string> {
23
- return await git_spawn.read(['status', PORCELAIN_FLAG, UNTRACKED_FILES_FLAG])
22
+ // `directory` reads another work tree of the repository — a lane — instead of this one.
23
+ async function status(directory?: string): Promise<string> {
24
+ const location = directory === undefined ? [] : ['-C', directory]
25
+
26
+ return await git_spawn.read([...location, 'status', PORCELAIN_FLAG, UNTRACKED_FILES_FLAG])
24
27
  }
25
28
 
26
29
  // The absolute path every other git command's output is relative to. Asking git rather than reading
@@ -92,14 +92,18 @@ async function lane_issue_title(branch_name: string, issue_number: string): Prom
92
92
  return title
93
93
  }
94
94
 
95
+ function branch_derivation_error(branch_name: string): string {
96
+ return `Cannot derive issue info from branch "${branch_name}" in non-interactive mode. ${argument_hint(EXAMPLE_ISSUE_NUMBER)}`
97
+ }
98
+
99
+ function branch_number(branch_name: string): string | undefined {
100
+ return BRANCH_NUMBER_PATTERN.exec(branch_name)?.[1]
101
+ }
102
+
95
103
  async function derive_issue_input_from_branch(branch_name: string): Promise<string> {
96
104
  const match = BRANCH_NUMBER_PATTERN.exec(branch_name)
97
105
 
98
- if (match === null) {
99
- throw new Error(
100
- `Cannot derive issue info from branch "${branch_name}" in non-interactive mode. ${argument_hint(EXAMPLE_ISSUE_NUMBER)}`,
101
- )
102
- }
106
+ if (match === null) throw new Error(branch_derivation_error(branch_name))
103
107
 
104
108
  const [, number = '', slug = ''] = match
105
109
  const title = is_lane_branch(branch_name, number)
@@ -162,6 +166,10 @@ const git_issue = {
162
166
  get_and_display,
163
167
  resolve_and_display,
164
168
  derive_from_branch,
169
+ // The pure halves of the two resolutions above, read by `git-preflight.ts` before anything changes.
170
+ parse: parse_issue_input,
171
+ branch_number,
172
+ branch_derivation_error,
165
173
  }
166
174
 
167
175
  export type { IssueInfo }
@@ -202,22 +202,27 @@ async function create_for_issue(
202
202
  )
203
203
  }
204
204
 
205
- async function create_with_issue_info(issue_info: IssueInfo, extra_body?: string): Promise<void> {
206
- const existing_label = await existing_pr_label(issue_info.branch_name)
207
-
208
- if (existing_label !== undefined) {
209
- await create_for_issue(issue_info, extra_body, existing_label)
205
+ // The label the pull request is opened with — also asked by `git-preflight.ts` before the commit, so
206
+ // a missing classification is reported beside the other unmet preconditions (joshuafolkken/kit#2817).
207
+ async function release_classification(
208
+ issue_number: string,
209
+ branch_name: string,
210
+ ): Promise<ReleaseClassification> {
211
+ const existing_label = await existing_pr_label(branch_name)
210
212
 
211
- return
212
- }
213
+ if (existing_label !== undefined) return existing_label
213
214
 
214
- const issue_json = await git_gh_command.issue_view_json(issue_info.number, 'labels,body')
215
+ const issue_json = await git_gh_command.issue_view_json(issue_number, 'labels,body')
215
216
 
216
217
  if (issue_json === undefined) {
217
- throw new Error(`Could not read issue #${issue_info.number} for release classification`)
218
+ throw new Error(`Could not read issue #${issue_number} for release classification`)
218
219
  }
219
220
 
220
- const label = pr_classification.select_issue_classification(issue_json)
221
+ return pr_classification.select_issue_classification(issue_json)
222
+ }
223
+
224
+ async function create_with_issue_info(issue_info: IssueInfo, extra_body?: string): Promise<void> {
225
+ const label = await release_classification(issue_info.number, issue_info.branch_name)
221
226
 
222
227
  await create_for_issue(issue_info, extra_body, label)
223
228
  }
@@ -225,6 +230,7 @@ async function create_with_issue_info(issue_info: IssueInfo, extra_body?: string
225
230
  const git_pr = {
226
231
  create,
227
232
  create_with_issue_info,
233
+ release_classification,
228
234
  }
229
235
 
230
236
  export { git_pr }
@@ -0,0 +1,138 @@
1
+ import { live_evidence } from '#scripts/review/live-evidence'
2
+ import { changed_paths } from './changed-paths'
3
+ import { git_branch } from './git-branch'
4
+ import { git_command } from './git-command'
5
+ import { git_gh_command } from './git-gh-command'
6
+ import { git_issue, type IssueInfo } from './git-issue'
7
+ import { git_pr } from './git-pr'
8
+
9
+ // joshuafolkken/kit#2817. A first `josh git -y` was refused once per unmet precondition — the title's
10
+ // issue number, the branch name, the issue's release classification — each only after the stage
11
+ // before it had already staged, switched or pushed. **Every precondition decidable at the start is
12
+ // asked here, before anything changes, and all of them are reported at once.** No rule is new: each
13
+ // answer comes from the function the later stage refuses with, so the two cannot drift apart.
14
+
15
+ interface PreflightInput {
16
+ cli_input: string | undefined
17
+ will_open_pr: boolean
18
+ }
19
+
20
+ // The issue the run resolves to and the branch it will commit on, when both are known.
21
+ interface Target {
22
+ number: string
23
+ branch_name: string
24
+ }
25
+
26
+ interface Resolution {
27
+ target: Target | undefined
28
+ problems: Array<string>
29
+ }
30
+
31
+ function error_message(error: unknown): string {
32
+ return error instanceof Error ? error.message : String(error)
33
+ }
34
+
35
+ function resolve_from_branch(current_branch: string): Resolution {
36
+ const number = git_issue.branch_number(current_branch)
37
+
38
+ if (number === undefined) {
39
+ return { target: undefined, problems: [git_issue.branch_derivation_error(current_branch)] }
40
+ }
41
+
42
+ return { target: { number, branch_name: current_branch }, problems: [] }
43
+ }
44
+
45
+ // A branch is created only from the default branch, so on any other the run commits where it stands.
46
+ function resolve_parsed(
47
+ parsed: IssueInfo,
48
+ current_branch: string,
49
+ default_branch: string,
50
+ ): Resolution {
51
+ const branch_name = current_branch === default_branch ? parsed.branch_name : current_branch
52
+ const target = { number: parsed.number, branch_name }
53
+
54
+ if (!git_branch.is_mismatch(current_branch, parsed.branch_name, default_branch)) {
55
+ return { target, problems: [] }
56
+ }
57
+
58
+ return {
59
+ target,
60
+ problems: [
61
+ `Branch mismatch: on "${current_branch}", but the title resolves to "${parsed.branch_name}".`,
62
+ ],
63
+ }
64
+ }
65
+
66
+ function resolve_from_title(
67
+ cli_input: string,
68
+ current_branch: string,
69
+ default_branch: string,
70
+ ): Resolution {
71
+ try {
72
+ return resolve_parsed(git_issue.parse(cli_input), current_branch, default_branch)
73
+ } catch (error) {
74
+ return { target: undefined, problems: [error_message(error)] }
75
+ }
76
+ }
77
+
78
+ async function resolve_target(cli_input: string | undefined): Promise<Resolution> {
79
+ const current_branch = await git_branch.current()
80
+
81
+ if (cli_input === undefined) return resolve_from_branch(current_branch)
82
+
83
+ return resolve_from_title(cli_input, current_branch, await git_command.get_default_branch())
84
+ }
85
+
86
+ async function classification_problems(target: Target): Promise<Array<string>> {
87
+ try {
88
+ await git_pr.release_classification(target.number, target.branch_name)
89
+
90
+ return []
91
+ } catch (error) {
92
+ return [error_message(error)]
93
+ }
94
+ }
95
+
96
+ // A notice rather than a refusal: the evidence is the output of running the change, which does not
97
+ // exist before the commit. Saying so now is what spares the round trip at `josh followup`.
98
+ async function announce_evidence(branch_name: string): Promise<void> {
99
+ // Before staging, a new file is still untracked, so the tracked diff alone would miss it.
100
+ const [paths, body] = await Promise.all([
101
+ changed_paths.read_changed_paths(false),
102
+ git_gh_command.pr_get_body(branch_name),
103
+ ])
104
+
105
+ if (live_evidence.verdict_for(paths, body) !== 'required') return
106
+
107
+ console.info(
108
+ `💡 This change touches runtime code: the PR body needs a ${live_evidence.EVIDENCE_HEADING} section before \`josh followup\` merges it.`,
109
+ )
110
+ }
111
+
112
+ function refusal(problems: ReadonlyArray<string>): Error {
113
+ const lines = problems.map((problem) => ` - ${problem}`)
114
+
115
+ return new Error(
116
+ [
117
+ `Preflight found ${String(problems.length)} unmet precondition(s); nothing was changed:`,
118
+ ...lines,
119
+ ].join('\n'),
120
+ )
121
+ }
122
+
123
+ async function check(input: PreflightInput): Promise<void> {
124
+ const { target, problems } = await resolve_target(input.cli_input)
125
+ // The classification and the evidence belong to the pull request, so a `--skip-pr` run asks neither.
126
+ const pr_target = input.will_open_pr ? target : undefined
127
+ const pr_problems = pr_target === undefined ? [] : await classification_problems(pr_target)
128
+ const all_problems = [...problems, ...pr_problems]
129
+
130
+ if (all_problems.length > 0) throw refusal(all_problems)
131
+
132
+ if (pr_target !== undefined) await announce_evidence(pr_target.branch_name)
133
+ }
134
+
135
+ const git_preflight = { check }
136
+
137
+ export type { PreflightInput }
138
+ export { git_preflight }
@@ -61,6 +61,8 @@ const TASK_DEFINITIONS: Record<TelegramTaskType, TaskDefinition> = {
61
61
  stranded: { icon: '🚨', label: 'Run stranded — nobody is driving it' },
62
62
  }
63
63
 
64
+ const NOTIFY_SWITCH_KEY = 'JOSH_NOTIFY'
65
+ const NOTIFY_OFF_VALUE = 'off'
64
66
  const NOT_CONFIGURED_PREFIX = 'Telegram is not configured'
65
67
  const SEND_FAILED_PREFIX = 'Telegram notification failed'
66
68
  const REDACTED = '<redacted>'
@@ -136,6 +138,14 @@ function load_config(): TelegramConfig {
136
138
  return { bot_token: result.data.telegram_bot_token, chat_id: result.data.telegram_chat_id }
137
139
  }
138
140
 
141
+ // **An explicit opt-out is not a missing credential** (joshuafolkken/kit#2821). A consumer who never
142
+ // meant to use Telegram had no way to say so, so every stop printed the #1564 failure as noise. Only
143
+ // the stated value `off` disables the send: an unset or mistyped switch still falls through to
144
+ // `load_config`, so a forgotten setup stays the loud failure #1564 made it.
145
+ function is_notify_disabled(): boolean {
146
+ return parse_environment_string(process.env[NOTIFY_SWITCH_KEY]).toLowerCase() === NOTIFY_OFF_VALUE
147
+ }
148
+
139
149
  function build_header(task_type: TelegramTaskType, repo_name: string | undefined): string {
140
150
  const { icon, label } = TASK_DEFINITIONS[task_type]
141
151
  if (repo_name === undefined || repo_name.length === 0) return `${icon} ${label}`
@@ -210,7 +220,18 @@ function build_send_failure(error: unknown, config: TelegramConfig): Error {
210
220
  // warning and exiting 0, so a message that reached nobody looked exactly like one that arrived.
211
221
  //
212
222
  // The failure it throws carries a `cause`, and that cause is redacted too — see `build_send_failure`.
223
+ //
224
+ // The opt-out is checked here, before `load_config`, so every caller — the tolerant form included —
225
+ // skips the same way and none has to repeat the check.
213
226
  async function send(input: TelegramSendInput): Promise<void> {
227
+ if (is_notify_disabled()) {
228
+ console.info(
229
+ `🔕 Telegram notifications are disabled (${NOTIFY_SWITCH_KEY}=${NOTIFY_OFF_VALUE}).`,
230
+ )
231
+
232
+ return
233
+ }
234
+
214
235
  const config = load_config()
215
236
  const text = build_text(input)
216
237
 
@@ -10,6 +10,7 @@ import { lane_child_marker } from '#scripts/lane/lane-child-marker'
10
10
  import { filing_cap } from '#scripts/rules/filing-cap'
11
11
  import { lane_background } from '#scripts/rules/lane-background'
12
12
  import { lane_park } from '#scripts/rules/lane-park'
13
+ import { last_prompt } from '#scripts/rules/last-prompt'
13
14
  import { stop_rules, type StopContext, type StopOutcome } from '#scripts/rules/stop-rules'
14
15
  import { run_cut } from '#scripts/run/run-cut'
15
16
  import { run_headless } from '#scripts/run/run-headless'
@@ -70,6 +71,7 @@ async function build_context(
70
71
  tree_clean: !(await run_hold.is_tree_dirty()),
71
72
  notified: lane_park.mentions_confirmation_notify(tail),
72
73
  message: payload_tail.message,
74
+ prompt: last_prompt.prompt_text(tail),
73
75
  stop_hook_active: payload_tail.stop_hook_active,
74
76
  cut_pending: run_cut.carried_cut_sync() !== undefined,
75
77
  filed: was_filed(tail),
@@ -0,0 +1,24 @@
1
+ // The two references `josh init` writes into a basic project, under the paths they had before
2
+ // joshuafolkken/kit#2829 renamed the static profile. kit still ships both old paths so an earlier
3
+ // project keeps working; re-running `josh init` moves the project onto the new ones, which is what
4
+ // lets the old paths be retired once projects have moved.
5
+ const RENAMED_REFERENCES: ReadonlyArray<readonly [RegExp, string]> = [
6
+ [
7
+ /@node_modules\/@joshuafolkken\/kit\/dist\/CLAUDE\.static\.md(?![\w.])/gu,
8
+ '@node_modules/@joshuafolkken/kit/dist/CLAUDE.basic.md',
9
+ ],
10
+ [/@joshuafolkken\/kit\/prettier\/static(?=['"])/gu, '@joshuafolkken/kit/prettier/basic'],
11
+ ]
12
+
13
+ function migrate_basic_paths(content: string): string {
14
+ let migrated = content
15
+
16
+ for (const [pattern, replacement] of RENAMED_REFERENCES) {
17
+ migrated = migrated.replaceAll(pattern, () => replacement)
18
+ }
19
+
20
+ return migrated
21
+ }
22
+
23
+ const basic_path_migration = { migrate_basic_paths }
24
+ export { basic_path_migration }
@@ -97,10 +97,32 @@ function ensure_claude_md_import(existing: string | undefined): string {
97
97
  return `${CLAUDE_MD_BOOTSTRAP}\n\n${CLAUDE_MD_IMPORT_LINE}\n\n${updated}`
98
98
  }
99
99
 
100
+ // The lines `ensure_claude_md_import` writes. A basic project imports other rules, so a sync from
101
+ // before joshuafolkken/kit#2827, or a switch from full, leaves these behind for it to remove.
102
+ const FULL_CLAUDE_MD_LINES = new Set([
103
+ CLAUDE_MD_BOOTSTRAP,
104
+ OLD_CLAUDE_MD_BOOTSTRAP,
105
+ CLAUDE_MD_IMPORT_LINE,
106
+ ])
107
+
108
+ // Each written line goes with the blank line after it, so the rest of the file is left as it was.
109
+ function remove_claude_md_import(content: string): string {
110
+ const lines = content.split('\n')
111
+
112
+ return lines
113
+ .filter((line, index) => {
114
+ if (FULL_CLAUDE_MD_LINES.has(line)) return false
115
+
116
+ return line !== '' || !FULL_CLAUDE_MD_LINES.has(lines[index - 1] ?? '')
117
+ })
118
+ .join('\n')
119
+ }
120
+
100
121
  const distributed_paths = {
101
122
  transform_prompt_paths,
102
123
  transform_distributed_paths,
103
124
  ensure_claude_md_import,
125
+ remove_claude_md_import,
104
126
  CLAUDE_MD_IMPORT_LINE,
105
127
  }
106
128