@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.
- package/.claude/skills/workflow-commands/backlogrun-child.md +4 -5
- package/.claude/skills/workflow-commands/backlogrun-steps.md +1 -1
- package/.github/workflows/sonar-qube.yml +12 -2
- package/CLAUDE.md +2 -2
- package/README.md +30 -21
- package/dist/CLAUDE.md +2 -2
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-ZT65LR3U.js → chunk-4GDR2YTV.js} +4 -4
- package/dist/hooks/{chunk-ASUZGZFT.js → chunk-56LGMJJV.js} +2 -2
- package/dist/hooks/{chunk-LBXQHXMC.js → chunk-6PSTVOLH.js} +263 -74
- package/dist/hooks/{chunk-P3UX7SFN.js → chunk-A2H5I2VD.js} +2 -2
- package/dist/hooks/{chunk-ERXMJJOV.js → chunk-AWTFKKFW.js} +1 -1
- package/dist/hooks/{chunk-IM3KM25Y.js → chunk-EZLHXKJ6.js} +10 -7
- package/dist/hooks/{chunk-APVOACVT.js → chunk-GBJUERYK.js} +1 -1
- package/dist/hooks/{chunk-5JX6HXDX.js → chunk-KPZN42RT.js} +3 -3
- package/dist/hooks/{chunk-47SPMGOD.js → chunk-PAXMLGSS.js} +26 -7
- package/dist/hooks/{chunk-RFPHKU7C.js → chunk-WMIR5SNX.js} +3 -3
- package/dist/hooks/codex-hook-adapter.js +10 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +98 -26
- package/dist/josh.js +5 -5
- package/package.json +5 -1
- package/prettier/basic.d.ts +3 -0
- package/prettier/basic.js +10 -0
- package/prettier/index.js +2 -2
- package/prettier/static.js +3 -10
- package/prompts/sonar-hotspot-handling.md +6 -0
- package/prompts/testing-guide.md +1 -1
- package/scripts/agent/agent-diagnostics.ts +1 -0
- package/scripts/agent/agent-role-profile.ts +1 -1
- package/scripts/behavior/behavior-rules.ts +8 -1
- package/scripts/build/build-claude-md.ts +13 -4
- package/scripts/ci/pr-classification.ts +11 -2
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/gate/gate-steps.ts +1 -1
- package/scripts/gate/project-checks.ts +12 -11
- package/scripts/gate/type-check-command.ts +1 -1
- package/scripts/git/git-branch.ts +14 -3
- package/scripts/git/git-command.ts +5 -2
- package/scripts/git/git-issue.ts +13 -5
- package/scripts/git/git-pr.ts +16 -10
- package/scripts/git/git-preflight.ts +138 -0
- package/scripts/git/telegram-notify.ts +21 -0
- package/scripts/hooks/stop-guard.ts +2 -0
- package/scripts/init/basic-path-migration.ts +24 -0
- package/scripts/init/distributed-paths.ts +22 -0
- package/scripts/init/init-actions.ts +27 -19
- package/scripts/init/init-ai-copy.ts +86 -28
- package/scripts/init/{init-static.ts → init-basic.ts} +6 -6
- package/scripts/init/init-bootstrap.ts +4 -3
- package/scripts/init/init-install.ts +1 -1
- package/scripts/init/init-logic-templates.ts +1 -1
- package/scripts/init/init-logic.ts +1 -1
- package/scripts/init/init.ts +41 -19
- package/scripts/init/kit-setup-state.ts +30 -0
- package/scripts/init/kit-written-paths.ts +33 -0
- package/scripts/init/project-profile-cli.ts +15 -1
- package/scripts/init/project-profile.ts +41 -12
- package/scripts/init/session-language-environment.ts +83 -0
- package/scripts/init/start-exec.ts +28 -0
- package/scripts/init/start-plan.ts +35 -13
- package/scripts/init/start-prompt.ts +22 -8
- package/scripts/init/start-setup-pr.ts +138 -0
- package/scripts/init/start-steps.ts +30 -30
- package/scripts/init/start.ts +7 -3
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +3 -3
- package/scripts/josh/josh-commands-development.ts +3 -3
- package/scripts/josh/josh-commands-project.ts +2 -2
- package/scripts/lane/lane-leftover.ts +143 -0
- package/scripts/lane/lane-open.ts +14 -1
- package/scripts/lint/cspell-cached.ts +3 -3
- package/scripts/lint/format.ts +1 -1
- package/scripts/lint/lint-parallel.ts +2 -2
- package/scripts/propagate/propagate-steps.ts +14 -1
- package/scripts/rules/filing-cap.ts +1 -0
- package/scripts/rules/issue-citation.ts +30 -1
- package/scripts/rules/last-prompt.ts +79 -0
- package/scripts/rules/stop-rules.ts +5 -3
- package/scripts/run/run-hold.ts +3 -3
- package/scripts/run/run-preflight.ts +75 -5
- package/scripts/run/run-progress-cli.ts +14 -2
- package/scripts/safe-chain/ci-installer-pin.ts +19 -12
- package/scripts/safe-chain/project-config.ts +1 -1
- package/scripts/sync/sync-ai-files.ts +204 -0
- package/scripts/sync/sync.ts +48 -184
- package/scripts/test/josh-harness-environment.ts +1 -1
- package/scripts/test/pilot-files.ts +0 -1
- package/scripts/test/test-declared-changed.ts +1 -1
- package/scripts/test/test-declared-logic.ts +55 -14
- package/scripts/test/test-declared.ts +23 -11
- package/scripts/test/test-unit-guard.ts +6 -6
- package/scripts/time-runtime/time-shell.ts +36 -2
- package/scripts-ai/git-workflow.ts +10 -0
- package/templates/CLAUDE.basic.md +8 -0
- package/templates/workflows/ci.yml +27 -0
- /package/{templates/CLAUDE.static.md → dist/CLAUDE.basic.md} +0 -0
- /package/templates/{cursorrules.static → cursorrules.basic} +0 -0
- /package/templates/{pnpm-workspace.static.yaml → pnpm-workspace.basic.yaml} +0 -0
- /package/templates/{prettierignore.static → prettierignore.basic} +0 -0
package/prompts/testing-guide.md
CHANGED
|
@@ -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
|
-
- **
|
|
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
|
|
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
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
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:
|
|
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.
|
|
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
|
|
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 === '
|
|
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
|
|
95
|
-
return
|
|
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
|
|
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 (!
|
|
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
|
|
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 (!
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
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
|
package/scripts/git/git-issue.ts
CHANGED
|
@@ -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 }
|
package/scripts/git/git-pr.ts
CHANGED
|
@@ -202,22 +202,27 @@ async function create_for_issue(
|
|
|
202
202
|
)
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
}
|
|
213
|
+
if (existing_label !== undefined) return existing_label
|
|
213
214
|
|
|
214
|
-
const issue_json = await git_gh_command.issue_view_json(
|
|
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 #${
|
|
218
|
+
throw new Error(`Could not read issue #${issue_number} for release classification`)
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
|
|
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
|
|