@joshuafolkken/kit 1.972.0 → 1.983.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/epic-commands/SKILL.md +6 -5
- package/.claude/skills/workflow-commands/SKILL.md +9 -9
- package/.claude/skills/workflow-commands/background-commands.md +1 -1
- package/.claude/skills/workflow-commands/fullrun-steps.md +8 -8
- package/.claude/skills/workflow-commands/fullrun.md +1 -0
- package/.claude/skills/workflow-commands/halfrun.md +16 -12
- package/.claude/skills/workflow-commands/issue-comments.md +1 -1
- package/.claude/skills/workflow-commands/issue-scout.md +21 -22
- package/.claude/skills/workflow-commands/kickoff.md +8 -9
- package/.claude/skills/workflow-commands/observation-filing.md +9 -10
- package/.claude/skills/workflow-commands/prerequisite.md +7 -6
- package/.claude/skills/workflow-commands/retrospective.md +5 -5
- package/.claude/skills/workflow-commands/rule-residency.md +2 -2
- package/.claude/skills/workflow-commands/split-assessment.md +4 -3
- package/.claude/skills/workflow-commands/target-repository.md +7 -6
- package/.claude/skills/workflow-commands/working-tree-hold.md +16 -1
- package/README.md +24 -20
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-ZPLXLJ7O.js → chunk-47SPMGOD.js} +16 -1
- package/dist/hooks/{chunk-BVMSPG3M.js → chunk-5JX6HXDX.js} +3 -3
- package/dist/hooks/{chunk-KQG4776P.js → chunk-APVOACVT.js} +1 -1
- package/dist/hooks/{chunk-2K52BRWV.js → chunk-ASUZGZFT.js} +2 -2
- package/dist/hooks/{chunk-CRGJTD6E.js → chunk-ERXMJJOV.js} +1 -1
- package/dist/hooks/{chunk-WWTMHHZU.js → chunk-IM3KM25Y.js} +61 -30
- package/dist/hooks/{chunk-BNWIOJFU.js → chunk-LBXQHXMC.js} +138 -358
- package/dist/hooks/{chunk-NXL3SBXY.js → chunk-P3UX7SFN.js} +2 -2
- package/dist/hooks/{chunk-F6OM6SIG.js → chunk-RFPHKU7C.js} +12 -12
- package/dist/hooks/{chunk-ZYYSBNR6.js → chunk-ZT65LR3U.js} +4 -4
- 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 +4 -4
- package/dist/josh.js +16 -1
- package/package.json +2 -2
- package/prompts/collaboration-workflow/epic-bundle.md +8 -8
- package/prompts/collaboration-workflow/issue-template.md +5 -5
- package/prompts/collaboration-workflow/rule-delivery.md +43 -30
- package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
- package/prompts/collaboration-workflow/wip-cap.md +3 -3
- package/prompts/review.md +11 -9
- package/scripts/ci/pr-classification.ts +13 -12
- package/scripts/delegation/delegation-policy.ts +9 -9
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/git/issue-labels.ts +52 -5
- package/scripts/init/init-bootstrap.ts +92 -0
- package/scripts/init/init-install.ts +4 -1
- package/scripts/init/init-logic-workspace.ts +1 -1
- package/scripts/init/init.ts +23 -5
- package/scripts/init/start-plan.ts +1 -1
- package/scripts/init/start-steps.ts +5 -28
- package/scripts/issue/issue-file-cli.ts +188 -0
- package/scripts/issue/issue-file.ts +192 -0
- package/scripts/issue/issue-lint-cli.ts +1 -1
- package/scripts/issue/issue-scout-cli.ts +41 -97
- package/scripts/issue/issue-scout-closed.ts +102 -0
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +9 -1
- package/scripts/josh/josh-command-map.ts +1 -0
- package/scripts/josh/josh-commands-issue.ts +11 -0
- package/scripts/josh/josh-commands-project.ts +5 -1
- package/scripts/propagate/propagate-run.ts +1 -0
- package/scripts/propagate/propagate-select.ts +72 -0
- package/scripts/propagate/propagate-targets.ts +5 -3
- package/scripts/propagate/propagate.ts +86 -24
- package/scripts/repo/repository-labels.ts +91 -0
- package/scripts/rules/bash-triggers.ts +19 -6
- package/scripts/rules/delivered-rules.ts +28 -32
- package/scripts/rules/direct-filing.ts +38 -0
- package/scripts/rules/filing-cap.ts +11 -8
- package/scripts/rules/issue-fold.ts +4 -4
- package/scripts/rules/oracle-consulted.ts +4 -4
- package/scripts/rules/oracle-firing.ts +13 -16
- package/scripts/rules/rule-body-guard.ts +6 -9
- package/scripts/rules/stop-rules.ts +3 -2
- package/scripts/run/run-entry-cli.ts +34 -1
- package/scripts/run/run-halfrun-resume.ts +104 -0
- package/scripts/run/run-hold-cli.ts +27 -4
- package/scripts/run/run-hold.ts +11 -0
- package/scripts/security/security-audit.ts +12 -1
- package/scripts/sync/sync.ts +6 -0
- package/scripts/test/pilot-files.ts +0 -1
- package/scripts/version/fresh-metadata-cache.ts +36 -0
- package/scripts/version/latest-update.ts +2 -1
- package/scripts/rules/issue-bug-label.ts +0 -85
- package/scripts/rules/issue-filing-args.ts +0 -50
- package/scripts/rules/issue-scout.ts +0 -64
|
@@ -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
|
-
|
|
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
|
-
|
|
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 `--
|
|
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)
|
package/scripts/run/run-hold.ts
CHANGED
|
@@ -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
|
-
|
|
101
|
+
stdin: 'inherit',
|
|
102
|
+
stdout: FORWARDED_OUTPUT,
|
|
103
|
+
stderr: FORWARDED_OUTPUT,
|
|
93
104
|
reject: false,
|
|
94
105
|
})
|
|
95
106
|
|
package/scripts/sync/sync.ts
CHANGED
|
@@ -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 {
|
|
@@ -320,7 +320,6 @@ const PILOT_FILES: ReadonlyArray<string> = [
|
|
|
320
320
|
'scripts/rules/issue-comments-rule.test.ts',
|
|
321
321
|
'scripts/rules/issue-depth-label.test.ts',
|
|
322
322
|
'scripts/rules/issue-fold.test.ts',
|
|
323
|
-
'scripts/rules/issue-scout.test.ts',
|
|
324
323
|
'scripts/rules/josh-git-bare.test.ts',
|
|
325
324
|
'scripts/rules/lane-interactive-ask-rule.test.ts',
|
|
326
325
|
'scripts/rules/lane-park-rule.test.ts',
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
const CACHE_DIRECTORY_PREFIX = 'josh-latest-cache-'
|
|
6
|
+
const CACHE_DIRECTORY_FLAG = '--config.cache-dir='
|
|
7
|
+
|
|
8
|
+
// Points every stage at one metadata cache directory. pnpm keeps registry metadata there and the
|
|
9
|
+
// package store elsewhere, so an empty one costs a metadata refetch, never a tarball download.
|
|
10
|
+
function with_cache_directory(
|
|
11
|
+
stages: ReadonlyArray<ReadonlyArray<string>>,
|
|
12
|
+
cache_directory: string,
|
|
13
|
+
): Array<Array<string>> {
|
|
14
|
+
return stages.map((stage) => [...stage, `${CACHE_DIRECTORY_FLAG}${cache_directory}`])
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A cached metadata file written through safe-chain lacks every version that was under 24 hours old
|
|
18
|
+
// at the time, and with `minimumReleaseAge` set pnpm reuses a cache file younger than that age
|
|
19
|
+
// instead of refetching it — so a version that has since aged in stays invisible for up to a day
|
|
20
|
+
// (joshuafolkken/kit#2805). A fresh directory per run means nothing filtered earlier is ever read.
|
|
21
|
+
function run_in_fresh_cache<T>(
|
|
22
|
+
stages: ReadonlyArray<ReadonlyArray<string>>,
|
|
23
|
+
run_stages: (stages: Array<Array<string>>) => T,
|
|
24
|
+
): T {
|
|
25
|
+
const cache_directory = mkdtempSync(path.join(tmpdir(), CACHE_DIRECTORY_PREFIX))
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
return run_stages(with_cache_directory(stages, cache_directory))
|
|
29
|
+
} finally {
|
|
30
|
+
rmSync(cache_directory, { recursive: true, force: true })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const fresh_metadata_cache = { with_cache_directory, run_in_fresh_cache }
|
|
35
|
+
|
|
36
|
+
export { fresh_metadata_cache }
|
|
@@ -11,6 +11,7 @@ import { lockfile_overrides } from '#scripts/overrides/lockfile-overrides'
|
|
|
11
11
|
import { overrides_files } from '#scripts/overrides/overrides-files'
|
|
12
12
|
import { overrides_check } from '#scripts/overrides/overrides-logic'
|
|
13
13
|
import { execaSync } from 'execa'
|
|
14
|
+
import { fresh_metadata_cache } from './fresh-metadata-cache'
|
|
14
15
|
import { latest_regression, type VersionRegression } from './latest-regression'
|
|
15
16
|
import { preinstall_version_update } from './preinstall-version-update'
|
|
16
17
|
|
|
@@ -99,7 +100,7 @@ function find_regressions_after_update(snapshot: TreeSnapshot): Array<VersionReg
|
|
|
99
100
|
// Exits zero: the tree is left exactly as it was found, nothing is broken, and every workflow that
|
|
100
101
|
// runs `josh latest` in its preamble would otherwise stop for a situation that resolves itself.
|
|
101
102
|
function update_without_downgrading(snapshot: TreeSnapshot): UpdateOutcome {
|
|
102
|
-
const status =
|
|
103
|
+
const status = fresh_metadata_cache.run_in_fresh_cache(build_update_commands(), run_update_stages)
|
|
103
104
|
if (status !== 0) return { status, is_rolled_back: false }
|
|
104
105
|
|
|
105
106
|
const regressions = find_regressions_after_update(snapshot)
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
import { issue_classification } from '#scripts/issue/issue-classification'
|
|
4
|
-
import type { GuardRun } from '#scripts/josh/hook-decision'
|
|
5
|
-
import { time_density_hook } from '#scripts/time-runtime/time-density-hook'
|
|
6
|
-
import { time_shell } from '#scripts/time-runtime/time-shell'
|
|
7
|
-
import { bash_triggers } from './bash-triggers'
|
|
8
|
-
import { issue_filing_args } from './issue-filing-args'
|
|
9
|
-
import { shell_segments } from './shell-segments'
|
|
10
|
-
import { tail_commands } from './tail-commands'
|
|
11
|
-
|
|
12
|
-
const LINT_NAMES = new Set(['issue:lint'])
|
|
13
|
-
const LINT_PATH = /\b(?:issue:lint|iln)\s+(?:'([^']+)'|"([^"]+)"|(\S+))/u
|
|
14
|
-
|
|
15
|
-
function lint_argument(segment: string): string | undefined {
|
|
16
|
-
const [, single_quoted, double_quoted, bare] = LINT_PATH.exec(segment) ?? []
|
|
17
|
-
|
|
18
|
-
return single_quoted ?? double_quoted ?? bare
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function latest_lint_path(tail: string): string | undefined {
|
|
22
|
-
const segments = tail_commands
|
|
23
|
-
.prior_bash_commands(tail)
|
|
24
|
-
.flatMap((command) => shell_segments.segments_of(command))
|
|
25
|
-
const lint = segments.findLast((segment) => shell_segments.is_josh_command(segment, LINT_NAMES))
|
|
26
|
-
|
|
27
|
-
return lint === undefined ? undefined : lint_argument(lint)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function linted_body(body_path: string): string | undefined {
|
|
31
|
-
try {
|
|
32
|
-
return readFileSync(body_path, 'utf8')
|
|
33
|
-
} catch {
|
|
34
|
-
return undefined
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function matches_linted_file(command: string, lint_path: string): boolean {
|
|
39
|
-
const filing_path = issue_filing_args.body_file(command)
|
|
40
|
-
|
|
41
|
-
return filing_path !== undefined && path.resolve(filing_path) === path.resolve(lint_path)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function needs_classification_labels(command: string, tail: string): boolean {
|
|
45
|
-
const lint_path = latest_lint_path(tail)
|
|
46
|
-
|
|
47
|
-
if (lint_path === undefined) return false
|
|
48
|
-
const body = linted_body(lint_path)
|
|
49
|
-
|
|
50
|
-
if (body === undefined) return false
|
|
51
|
-
|
|
52
|
-
if (!matches_linted_file(command, lint_path) || issue_classification.problems(body).length > 0) {
|
|
53
|
-
return true
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const expected = issue_classification.required_labels(body)
|
|
57
|
-
|
|
58
|
-
return issue_classification.CLASSIFICATION_LABELS.some(
|
|
59
|
-
(label) => expected.includes(label) !== issue_filing_args.has_label(command, label),
|
|
60
|
-
)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function decide(call: { input: unknown }, run: GuardRun): boolean {
|
|
64
|
-
const command = time_shell.bash_command(call.input)
|
|
65
|
-
const tail = time_density_hook.read_tail(run.transcript)
|
|
66
|
-
|
|
67
|
-
return needs_classification_labels(command, tail)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const ISSUE_CLASSIFICATION_REASON =
|
|
71
|
-
'⛔ issue classification mismatch: use the exact body file checked by `pnpm josh issue:lint` ' +
|
|
72
|
-
'in this creation call (`-F body=@<path>` or `gh issue create --body-file <path>`) and ' +
|
|
73
|
-
'apply exactly the `labels:` it reports (`bug`, `enhancement`, `breaking-change`). See ' +
|
|
74
|
-
'`prompts/collaboration-workflow/issue-template.md`.'
|
|
75
|
-
|
|
76
|
-
const ROW = {
|
|
77
|
-
id: 'issue-bug-label',
|
|
78
|
-
is_trigger: bash_triggers.on_bash_command(bash_triggers.is_issue_filing),
|
|
79
|
-
reason: ISSUE_CLASSIFICATION_REASON,
|
|
80
|
-
decide,
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const issue_bug_label_rule = { ROW, latest_lint_path, needs_classification_labels }
|
|
84
|
-
|
|
85
|
-
export { issue_bug_label_rule }
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
const SHELL_WORD = /(?:[^\s'"]|'[^']*'|"[^"]*")+/gu
|
|
2
|
-
const QUOTE = /['"]/gu
|
|
3
|
-
const RAW_FIELDS = new Set(['-f', '--raw-field'])
|
|
4
|
-
const FILE_FIELDS = new Set(['-F', '--field'])
|
|
5
|
-
const LABEL_OPTIONS = new Set(['-l', '--label'])
|
|
6
|
-
const BODY_FILE_OPTIONS = new Set(['--body-file'])
|
|
7
|
-
const INLINE_BODY_OPTIONS = new Set(['-b', '--body'])
|
|
8
|
-
const BODY_FILE_PREFIX = 'body=@'
|
|
9
|
-
|
|
10
|
-
function words_of(command: string): Array<string> {
|
|
11
|
-
return [...command.matchAll(SHELL_WORD)].map((match) => match[0].replaceAll(QUOTE, ''))
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function values_after(words: ReadonlyArray<string>, flags: ReadonlySet<string>): Array<string> {
|
|
15
|
-
return words.flatMap((word, index) => {
|
|
16
|
-
const value = words[index + 1]
|
|
17
|
-
|
|
18
|
-
return value !== undefined && flags.has(word) ? [value] : []
|
|
19
|
-
})
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function has_inline_body(words: ReadonlyArray<string>): boolean {
|
|
23
|
-
const has_raw = values_after(words, RAW_FIELDS).some((value) => value.startsWith('body='))
|
|
24
|
-
|
|
25
|
-
return has_raw || values_after(words, INLINE_BODY_OPTIONS).length > 0
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function body_file(command: string): string | undefined {
|
|
29
|
-
const words = words_of(command)
|
|
30
|
-
const fields = values_after(words, FILE_FIELDS).filter((value) => value.startsWith('body='))
|
|
31
|
-
const options = values_after(words, BODY_FILE_OPTIONS)
|
|
32
|
-
|
|
33
|
-
if (has_inline_body(words) || fields.length + options.length !== 1) return undefined
|
|
34
|
-
|
|
35
|
-
return fields[0]?.startsWith(BODY_FILE_PREFIX)
|
|
36
|
-
? fields[0].slice(BODY_FILE_PREFIX.length)
|
|
37
|
-
: options[0]
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function has_label(command: string, label: string): boolean {
|
|
41
|
-
const words = words_of(command)
|
|
42
|
-
const fields = [...values_after(words, RAW_FIELDS), ...values_after(words, FILE_FIELDS)]
|
|
43
|
-
const options = values_after(words, LABEL_OPTIONS).flatMap((value) => value.split(','))
|
|
44
|
-
|
|
45
|
-
return fields.includes(`labels[]=${label}`) || options.includes(label)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const issue_filing_args = { body_file, has_label, words_of }
|
|
49
|
-
|
|
50
|
-
export { issue_filing_args }
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import type { GuardedCall } from '#scripts/time-runtime/time-batch-guard'
|
|
2
|
-
import { bash_triggers } from './bash-triggers'
|
|
3
|
-
import { shell_segments } from './shell-segments'
|
|
4
|
-
import { tail_commands } from './tail-commands'
|
|
5
|
-
|
|
6
|
-
// The trigger and the delivered text behind the `issue-scout` row of `delivered-rules.ts`
|
|
7
|
-
// (joshuafolkken/kit#2119).
|
|
8
|
-
//
|
|
9
|
-
// **A filing without `pnpm josh issue:scout` in front of it, refused at the filing itself.** §2e
|
|
10
|
-
// requires the scout before every `gh api … issues` call, and a duplicate is likeliest for the work
|
|
11
|
-
// that just finished — the one case self-restraint is least able to catch. The input is mechanical —
|
|
12
|
-
// did the run already run the scout — so it is a delivered rule rather than resident prose.
|
|
13
|
-
//
|
|
14
|
-
// **It is `issue-comments`'s shape, not the WIP cap's.** Like that row it is once per run with an
|
|
15
|
-
// `already_satisfied` stand-down read off the tail, and the refusal hands over the command rather than
|
|
16
|
-
// asking the run to "scout first" — a body-only read is refused so the reissue makes the comments
|
|
17
|
-
// present, and here a scout-less filing is refused so the reissue makes the scout present. **No new
|
|
18
|
-
// delivery path**: the trigger is `bash_triggers.is_issue_filing`, reused verbatim.
|
|
19
|
-
|
|
20
|
-
// `pnpm josh issue:scout`, the one act the rule asks for. The alias `isc` is expanded where the
|
|
21
|
-
// command is read, so the canonical name matches both spellings (joshuafolkken/kit#1789).
|
|
22
|
-
const SCOUT_NAMES: ReadonlySet<string> = new Set(['issue:scout'])
|
|
23
|
-
|
|
24
|
-
// Keeping the rule: a call that runs the scout. Segment-wise, so a spelling quoted inside a filing's
|
|
25
|
-
// own body is not read as the scout that filing skipped.
|
|
26
|
-
function runs_the_scout(command: string): boolean {
|
|
27
|
-
return shell_segments
|
|
28
|
-
.segments_of(command)
|
|
29
|
-
.some((segment) => shell_segments.is_josh_command(segment, SCOUT_NAMES))
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// The `already_satisfied` stand-down: the run has already scouted, so this filing asks for nothing new.
|
|
33
|
-
// The tool-name gate the row's trigger applies has already passed, so `call` only marks the shape; the
|
|
34
|
-
// answer is entirely in the tail.
|
|
35
|
-
function already_scouted(tail: string, _call: GuardedCall): boolean {
|
|
36
|
-
return tail_commands.prior_bash_commands(tail).some((command) => runs_the_scout(command))
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// The instruction in the shape a refusal can carry: what the call skipped, why it matters most here,
|
|
40
|
-
// and the command that fixes it. The duplicate risk is named because it is the half that makes the
|
|
41
|
-
// rule believable — the filing most likely to duplicate is the one about work that just finished.
|
|
42
|
-
const ISSUE_SCOUT_REASON =
|
|
43
|
-
'⛔ issue scout: this files an Issue with no `pnpm josh issue:scout` earlier in the run. A ' +
|
|
44
|
-
'duplicate is likeliest for the work that just finished, which is exactly where self-restraint is ' +
|
|
45
|
-
'weakest. Reissue after scouting: `pnpm josh issue:scout "<title>"` (add `--body "<one-line ' +
|
|
46
|
-
'summary, citing #N where the work follows one>"` so the epic half can answer), then read its ' +
|
|
47
|
-
'`Duplicates:` — an **open** candidate covering the same work stops the run rather than filing a ' +
|
|
48
|
-
'second Issue, and a `(closed)` one means the work is already done — before reissuing this filing. ' +
|
|
49
|
-
'The procedure is `.claude/skills/workflow-commands/SKILL.md` → §2e. It fires once per run and ' +
|
|
50
|
-
'cannot repeat on the call in hand.'
|
|
51
|
-
|
|
52
|
-
// The enumeration row itself, so `delivered-rules.ts` spreads one entry rather than restating the
|
|
53
|
-
// trigger and reason it already single-sources here. Once per run with the stand-down above.
|
|
54
|
-
const ROW = {
|
|
55
|
-
id: 'issue-scout',
|
|
56
|
-
is_trigger: bash_triggers.on_bash_command(bash_triggers.is_issue_filing),
|
|
57
|
-
reason: ISSUE_SCOUT_REASON,
|
|
58
|
-
already_satisfied: already_scouted,
|
|
59
|
-
keeps: bash_triggers.on_bash_command(runs_the_scout),
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const issue_scout = { ISSUE_SCOUT_REASON, ROW, already_scouted, runs_the_scout }
|
|
63
|
-
|
|
64
|
-
export { issue_scout }
|