@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,91 @@
|
|
|
1
|
+
import { git_gh_exec } from '#scripts/git/git-gh-exec'
|
|
2
|
+
import { REPOSITORY_LABELS, type LabelDefinition } from '#scripts/git/issue-labels'
|
|
3
|
+
|
|
4
|
+
// Creates the labels kit's workflow and the distributed `pr-classification.yml` rely on, and only
|
|
5
|
+
// those the repository is missing (joshuafolkken/kit#2797). The one place labels are created: `josh
|
|
6
|
+
// start` provisions a new repository through it and `josh sync` brings every existing one up to date.
|
|
7
|
+
//
|
|
8
|
+
// Labels already present are left as they are, so a repository that recolored one keeps its choice
|
|
9
|
+
// and a second run changes nothing. GitHub compares label names case-insensitively.
|
|
10
|
+
//
|
|
11
|
+
// **Never throws.** A token without the scope or no network must not stop a sync halfway; the names
|
|
12
|
+
// still missing are printed instead, so the person can create them by hand.
|
|
13
|
+
|
|
14
|
+
function labels_endpoint(repo: string): string {
|
|
15
|
+
return `repos/${repo}/labels`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A failed request is an answer to report, never an exception to propagate (see above).
|
|
19
|
+
function request(api: Parameters<typeof git_gh_exec.exec_gh_api_sync>[0]): string | undefined {
|
|
20
|
+
try {
|
|
21
|
+
return git_gh_exec.exec_gh_api_sync(api)
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function read_existing(repo: string): ReadonlyArray<string> | undefined {
|
|
28
|
+
const path = labels_endpoint(repo)
|
|
29
|
+
const listing = request({ path, should_paginate: true, jq_filter: '.[].name' })
|
|
30
|
+
|
|
31
|
+
return listing?.split('\n').filter((name) => name.length > 0)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function missing_labels(existing: ReadonlyArray<string>): ReadonlyArray<LabelDefinition> {
|
|
35
|
+
const names = new Set(existing.map((name) => name.toLowerCase()))
|
|
36
|
+
|
|
37
|
+
return REPOSITORY_LABELS.filter((label) => !names.has(label.name.toLowerCase()))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function create_label(label: LabelDefinition, repo: string): boolean {
|
|
41
|
+
const body = JSON.stringify(label)
|
|
42
|
+
const is_created = request({ path: labels_endpoint(repo), method: 'POST', body }) !== undefined
|
|
43
|
+
|
|
44
|
+
if (is_created) console.info(` ✔ label ${label.name}`)
|
|
45
|
+
|
|
46
|
+
return is_created
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function label_names(labels: ReadonlyArray<LabelDefinition>): ReadonlyArray<string> {
|
|
50
|
+
return labels.map((label) => label.name)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// An unreadable listing is not an empty one: nothing is created, and every label is reported as
|
|
54
|
+
// unconfirmed rather than as missing. An unresolved repository (no GitHub remote) is the same case.
|
|
55
|
+
function unreadable(repo: string | undefined): ReadonlyArray<string> {
|
|
56
|
+
const names = label_names(REPOSITORY_LABELS)
|
|
57
|
+
const target = repo ?? 'this repository'
|
|
58
|
+
|
|
59
|
+
console.warn(
|
|
60
|
+
` ⚠ could not read the labels of ${target}; make sure these exist: ${names.join(', ')}`,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return names
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function create_missing(
|
|
67
|
+
missing: ReadonlyArray<LabelDefinition>,
|
|
68
|
+
repo: string,
|
|
69
|
+
): ReadonlyArray<string> {
|
|
70
|
+
const names = label_names(missing.filter((label) => !create_label(label, repo)))
|
|
71
|
+
|
|
72
|
+
if (names.length > 0) {
|
|
73
|
+
console.warn(` ⚠ could not create these labels; create them by hand: ${names.join(', ')}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return names
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The label names not confirmed present afterwards — empty when the repository has every one.
|
|
80
|
+
function ensure_labels(repo: string | undefined): ReadonlyArray<string> {
|
|
81
|
+
if (repo === undefined) return unreadable(repo)
|
|
82
|
+
|
|
83
|
+
const existing = read_existing(repo)
|
|
84
|
+
if (existing === undefined) return unreadable(repo)
|
|
85
|
+
|
|
86
|
+
return create_missing(missing_labels(existing), repo)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const repository_labels = { missing_labels, ensure_labels }
|
|
90
|
+
|
|
91
|
+
export { repository_labels }
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { cost_blocks } from '#scripts/cost-runtime/cost-blocks'
|
|
2
2
|
import type { GuardedCall } from '#scripts/time-runtime/time-batch-guard'
|
|
3
3
|
import { time_shell } from '#scripts/time-runtime/time-shell'
|
|
4
|
+
import { shell_segments } from './shell-segments'
|
|
4
5
|
|
|
5
|
-
// The
|
|
6
|
+
// The trigger helpers the enumeration in `delivered-rules.ts` is built from, held here so a rule
|
|
6
7
|
// that lives in its own module can build its own row without importing the enumeration back
|
|
7
|
-
// (joshuafolkken/kit#2119). `is_issue_filing` is the one three rows
|
|
8
|
-
//
|
|
9
|
-
// their rows.
|
|
8
|
+
// (joshuafolkken/kit#2119). `is_issue_filing` is the one three rows share — the WIP cap, the per-run
|
|
9
|
+
// filing cap and the fold — so it could not stay a private function of the file that spreads their rows.
|
|
10
10
|
|
|
11
11
|
// **Only `Bash`, and the omission is deliberate** (joshuafolkken/kit#1390): Claude Code denies one
|
|
12
12
|
// call of a turn and runs the rest, so a refused `Edit` would leave its siblings applied and itself
|
|
@@ -33,12 +33,25 @@ const ISSUES_ENDPOINT = /repos\/[^\s'"]*\/issues(?=$|["'\s])/u
|
|
|
33
33
|
// that limit is recorded beside the non-`gh` one in `prompts/collaboration-workflow/rule-delivery.md`.
|
|
34
34
|
const TITLE_FIELD = /(?:-f|-F|--field|--raw-field)\s*'?title=/u
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
// A filing made by hand rather than through `josh issue:file` — the call the `direct-filing` row
|
|
37
|
+
// refuses on every occurrence (joshuafolkken/kit#2808).
|
|
38
|
+
function is_direct_filing(command: string): boolean {
|
|
37
39
|
if (ISSUE_CREATE_COMMAND.test(command)) return true
|
|
38
40
|
|
|
39
41
|
return ISSUES_ENDPOINT.test(command) && TITLE_FIELD.test(command)
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
// `pnpm josh issue:file`, in either spelling — **the** filing act once a direct one is refused, so the
|
|
45
|
+
// run-level filing rows (the WIP cap, the per-run cap, the fold) trigger on it. Segment-wise, so a
|
|
46
|
+
// spelling quoted inside another command's body is not read as a filing.
|
|
47
|
+
const FILING_NAMES: ReadonlySet<string> = new Set(['issue:file'])
|
|
48
|
+
|
|
49
|
+
function is_issue_filing(command: string): boolean {
|
|
50
|
+
return shell_segments
|
|
51
|
+
.segments_of(command)
|
|
52
|
+
.some((segment) => shell_segments.is_josh_command(segment, FILING_NAMES))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const bash_triggers = { is_direct_filing, is_issue_filing, on_bash_command }
|
|
43
56
|
|
|
44
57
|
export { bash_triggers }
|
|
@@ -3,15 +3,14 @@ import { hook_decision, type GuardRun, type TranscriptGuardSpec } from '#scripts
|
|
|
3
3
|
import { lane_guard_policy } from '#scripts/lane/lane-guard-policy'
|
|
4
4
|
import { time_batch_guard, type GuardedCall } from '#scripts/time-runtime/time-batch-guard'
|
|
5
5
|
import { bash_triggers } from './bash-triggers'
|
|
6
|
+
import { direct_filing } from './direct-filing'
|
|
6
7
|
import { early_heartbeat } from './early-heartbeat'
|
|
7
8
|
import { file_body } from './file-body'
|
|
8
9
|
import { filing_cap } from './filing-cap'
|
|
9
10
|
import { gh_api } from './gh-api'
|
|
10
11
|
import { git_force } from './git-force'
|
|
11
12
|
import { implementation_cut } from './implementation-cut'
|
|
12
|
-
import { issue_bug_label_rule } from './issue-bug-label'
|
|
13
13
|
import { issue_fold } from './issue-fold'
|
|
14
|
-
import { issue_scout } from './issue-scout'
|
|
15
14
|
import { josh_git_bare } from './josh-git-bare'
|
|
16
15
|
import { lane_background } from './lane-background'
|
|
17
16
|
import { lane_carry_conflict } from './lane-carry-conflict'
|
|
@@ -135,9 +134,9 @@ type MeasuredRule = Omit<DeliveredRule, 'is_trigger'> & {
|
|
|
135
134
|
const STAMP_PREFIX = 'josh-rule-guard-'
|
|
136
135
|
|
|
137
136
|
// **`is_issue_filing` and `on_bash_command` moved to `bash-triggers.ts`** (joshuafolkken/kit#2119):
|
|
138
|
-
// three rows
|
|
139
|
-
//
|
|
140
|
-
//
|
|
137
|
+
// three rows share the filing trigger — the WIP cap, the per-run filing cap and the fold — and the
|
|
138
|
+
// last two live in their own modules, which could not import the enumeration back to reach a private
|
|
139
|
+
// function of it. `on_bash_command` went with it because those modules build their own rows.
|
|
141
140
|
const { is_issue_filing, on_bash_command } = bash_triggers
|
|
142
141
|
|
|
143
142
|
// The whole of the WIP cap, in the shape a refusal can carry: the count, the refusal, the two
|
|
@@ -239,8 +238,8 @@ const ISSUE_COMMENTS_REASON =
|
|
|
239
238
|
"except for two answers that are not the run's to make: work a comment reassigns to another " +
|
|
240
239
|
'Issue is out of scope and is not implemented, and a comment saying the Issue no longer has a ' +
|
|
241
240
|
'reason to exist stops the run with a `confirmation` Telegram. The procedure is ' +
|
|
242
|
-
'`.claude/skills/workflow-commands/SKILL.md` → "An Issue\'s comments are part of the Issue".
|
|
243
|
-
'
|
|
241
|
+
'`.claude/skills/workflow-commands/SKILL.md` → "An Issue\'s comments are part of the Issue". ' +
|
|
242
|
+
'Every body-only read is refused until the comments are read.'
|
|
244
243
|
|
|
245
244
|
// The reading of the call itself — which spellings carry a body inline, and what the shell does to
|
|
246
245
|
// the value — is `shell-body-trigger.ts`, beside its own cases, as is the refusal text. A row states
|
|
@@ -295,26 +294,24 @@ const DELIVERED_RULES: ReadonlyArray<DeliveredRule> = [
|
|
|
295
294
|
// before any of those speak; on a first-party filing it is silent (the owners match), so `wip-cap`
|
|
296
295
|
// still speaks first, exactly as before. It fires on every occurrence (`git-force.ts`).
|
|
297
296
|
third_party_write.ROW,
|
|
297
|
+
// **A hand-built filing is refused on every occurrence** (joshuafolkken/kit#2808) and pointed at
|
|
298
|
+
// `josh issue:file`, which runs the scout, the lint, the labels, the `## Origin` check and
|
|
299
|
+
// `epic:bundle` itself. It claims the direct call only, so the rows below — which trigger on the
|
|
300
|
+
// `issue:file` call — never overlap it.
|
|
301
|
+
direct_filing.ROW,
|
|
298
302
|
{
|
|
299
303
|
id: 'wip-cap',
|
|
300
304
|
is_trigger: on_bash_command(is_issue_filing),
|
|
301
305
|
reason: WIP_CAP_REASON,
|
|
302
306
|
keeps: on_bash_command(counts_open_issues),
|
|
303
307
|
},
|
|
304
|
-
// **
|
|
305
|
-
// backlog count still speaks first: `
|
|
306
|
-
// run,
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
issue_scout.ROW,
|
|
308
|
+
// **Two more rows share the filing trigger** (joshuafolkken/kit#2119, joshuafolkken/kit#2213), listed
|
|
309
|
+
// after `wip-cap` so the backlog count still speaks first: `filing-cap` refuses every filing past
|
|
310
|
+
// the per-run ceiling, and `issue-fold` refuses a *second* filing the run has not folded. Each is a
|
|
311
|
+
// real instance of the admissible overlap the comment on `delivery` describes — the losing rule's
|
|
312
|
+
// delivery is still correct one reissue later — so an unfolded, over-cap second filing is delivered
|
|
313
|
+
// `wip-cap`, then `filing-cap`, then `issue-fold` across its reissues.
|
|
311
314
|
filing_cap.ROW,
|
|
312
|
-
// **`issue-fold` shares the same filing trigger** (joshuafolkken/kit#2213), listed after
|
|
313
|
-
// `filing-cap`: it refuses a *second* filing the run has not folded, once per run, stood down off
|
|
314
|
-
// the tail when the run has already folded or has filed nothing earlier. It is another instance of
|
|
315
|
-
// the admissible overlap — the losing rule's delivery is still correct one reissue later — so a
|
|
316
|
-
// scout-less, unfolded, over-cap second filing is delivered `wip-cap`, then `issue-scout`, then
|
|
317
|
-
// `filing-cap`, then `issue-fold` across its reissues.
|
|
318
315
|
issue_fold.ROW,
|
|
319
316
|
{
|
|
320
317
|
id: 'issue-comments',
|
|
@@ -541,13 +538,9 @@ const DELIVERED_RULES: ReadonlyArray<DeliveredRule> = [
|
|
|
541
538
|
rule_body_guard.ROW,
|
|
542
539
|
// **The generic oracle-consulted rows, one per oracle that declared a firing point**
|
|
543
540
|
// (joshuafolkken/kit#2324). Each refuses the action the oracle governs when the run has not run that
|
|
544
|
-
// oracle's command first, stood down once it has
|
|
545
|
-
//
|
|
546
|
-
// (`issue:lint`) delivers on the reissue, after `wip-cap` / `issue-scout` / `filing-cap` /
|
|
547
|
-
// `issue-fold` have each had their turn. The non-overlapping firing point (`pkg:scout` on a package
|
|
548
|
-
// add) claims a command no row above matches.
|
|
541
|
+
// oracle's command first, stood down once it has. Listed last; the one firing point left
|
|
542
|
+
// (`pkg:scout` on a package add) claims a command no row above matches.
|
|
549
543
|
...oracle_consulted.ROWS,
|
|
550
|
-
issue_bug_label_rule.ROW,
|
|
551
544
|
]
|
|
552
545
|
|
|
553
546
|
// A turn that issued more than this many calls is a turn that batched. The guard counts turns that
|
|
@@ -703,10 +696,13 @@ function is_first_delivery(
|
|
|
703
696
|
// **It is a trade, and the other side is named rather than hidden**: an arm allowed inside that window
|
|
704
697
|
// that really does run is not recorded either, so a second one before it fires goes uncaught. A missing
|
|
705
698
|
// record loses one detection; a wrong one blocks every arm until it expires.
|
|
706
|
-
// **Both extras
|
|
707
|
-
// recurring rule (joshuafolkken/kit#1570); `already_satisfied` is for a
|
|
708
|
-
//
|
|
709
|
-
//
|
|
699
|
+
// **Both extras replace `is_first_delivery`, and a row declares at most one.** `decide` is for a
|
|
700
|
+
// recurring rule (joshuafolkken/kit#1570); `already_satisfied` is for a precondition the run satisfies
|
|
701
|
+
// with an earlier act (joshuafolkken/kit#1905), and that act is the only way past it: the row refuses
|
|
702
|
+
// every candidate call until the act is on the tail (joshuafolkken/kit#2807). Once per run, the
|
|
703
|
+
// reissue went through with the precondition still unmet — a guard that passed precisely the run that
|
|
704
|
+
// skipped the procedure. Repeating cannot wedge a run that obeys, because the stand-down answers first.
|
|
705
|
+
// A row with neither takes `is_first_delivery` unchanged.
|
|
710
706
|
function delivery_decision(rule: DeliveredRule): TranscriptGuardSpec['should_block'] {
|
|
711
707
|
const { decide, already_satisfied } = rule
|
|
712
708
|
|
|
@@ -719,7 +715,7 @@ function delivery_decision(rule: DeliveredRule): TranscriptGuardSpec['should_blo
|
|
|
719
715
|
run: GuardRun,
|
|
720
716
|
): boolean {
|
|
721
717
|
if (already_satisfied?.(tail, call) === true) return false
|
|
722
|
-
if (decide === undefined) return
|
|
718
|
+
if (decide === undefined) return true
|
|
723
719
|
|
|
724
720
|
return decide(call, run, !will_batch_guard_refuse(tail, call, run), delivered_at_ms)
|
|
725
721
|
}
|
|
@@ -776,11 +772,11 @@ const delivered_rules = {
|
|
|
776
772
|
DELIVERED_RULES,
|
|
777
773
|
EARLY_HEARTBEAT_REASON: early_heartbeat.EARLY_HEARTBEAT_REASON,
|
|
778
774
|
FILE_BODY_REASON: file_body.FILE_BODY_REASON,
|
|
775
|
+
DIRECT_FILING_REASON: direct_filing.DIRECT_FILING_REASON,
|
|
779
776
|
FILING_CAP_REASON: filing_cap.FILING_CAP_REASON,
|
|
780
777
|
GIT_FORCE_REASON: git_force.GIT_FORCE_REASON,
|
|
781
778
|
ISSUE_COMMENTS_REASON,
|
|
782
779
|
ISSUE_FOLD_REASON: issue_fold.ISSUE_FOLD_REASON,
|
|
783
|
-
ISSUE_SCOUT_REASON: issue_scout.ISSUE_SCOUT_REASON,
|
|
784
780
|
LANE_INTERACTIVE_ASK_REASON: lane_interactive_ask.LANE_INTERACTIVE_ASK_REASON,
|
|
785
781
|
LANE_PARK_REASON: lane_park.LANE_PARK_REASON,
|
|
786
782
|
MEASURED_RULES,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { bash_triggers } from './bash-triggers'
|
|
2
|
+
|
|
3
|
+
// The `direct-filing` row of `delivered-rules.ts` (joshuafolkken/kit#2808). Filing an Issue owes a
|
|
4
|
+
// duplicate scout, a body lint, the classification / depth / `route:` labels, a `## Origin` on a
|
|
5
|
+
// cross-repository filing and an `epic:bundle` afterwards — steps spread over as many documents, each
|
|
6
|
+
// with its own guard, and joshuafolkken/kit#2805 ran two of them and skipped the rest. `josh issue:file`
|
|
7
|
+
// runs them all in one command, so a hand-built `gh api …/issues` / `gh issue create` filing is refused
|
|
8
|
+
// outright and pointed there; the step-by-step guards that used to sit on the direct call went with it.
|
|
9
|
+
|
|
10
|
+
const DIRECT_FILING_REASON =
|
|
11
|
+
'⛔ direct filing: an Issue is filed with `pnpm josh issue:file "<title>" --body-file <path> ' +
|
|
12
|
+
'--depth <0|1|2> [--route <route>] [--label <name>] [--repo <owner/repo>]`, never with `gh api …/issues` ' +
|
|
13
|
+
'or `gh issue create`. The command runs every filing step in order — the duplicate scout, the body ' +
|
|
14
|
+
'lint and its classification labels, the depth and `route:` labels, the `## Origin` check when filing ' +
|
|
15
|
+
'into another repository, and `epic:bundle` after the create — so none of them is a step to remember. ' +
|
|
16
|
+
'When the scout prints candidates it holds the filing: read each, then reissue with `--distinct <N,…>` ' +
|
|
17
|
+
'for the ones that are separate deliverables. Reference: `docs/josh-commands.md` → "`josh issue:file`". ' +
|
|
18
|
+
'**This rule fires on every occurrence, not once per run.**'
|
|
19
|
+
|
|
20
|
+
function is_any_filing(command: string): boolean {
|
|
21
|
+
return bash_triggers.is_direct_filing(command) || bash_triggers.is_issue_filing(command)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// `decide` returns true so it refuses every occurrence. Keeping the rule is filing through the command;
|
|
25
|
+
// the trigger is the violation itself, so the occasion it governs is a filing in either spelling
|
|
26
|
+
// (`reaches`, the `run-tail` shape).
|
|
27
|
+
const ROW = {
|
|
28
|
+
id: 'direct-filing',
|
|
29
|
+
is_trigger: bash_triggers.on_bash_command(bash_triggers.is_direct_filing),
|
|
30
|
+
reason: DIRECT_FILING_REASON,
|
|
31
|
+
decide: (): boolean => true,
|
|
32
|
+
keeps: bash_triggers.on_bash_command(bash_triggers.is_issue_filing),
|
|
33
|
+
reaches: bash_triggers.on_bash_command(is_any_filing),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const direct_filing = { DIRECT_FILING_REASON, ROW }
|
|
37
|
+
|
|
38
|
+
export { direct_filing }
|
|
@@ -21,7 +21,9 @@ import { tail_commands } from './tail-commands'
|
|
|
21
21
|
// **A guard-refused filing does not count.** Claude Code writes a denied call to the transcript as a
|
|
22
22
|
// `tool_use` block with an errored `tool_result`, so the WIP cap's own reissue would otherwise
|
|
23
23
|
// double-count one Issue — the same exclusion `investigation-reads.ts` makes for a refused read
|
|
24
|
-
// (joshuafolkken/kit#1764).
|
|
24
|
+
// (joshuafolkken/kit#1764). **Nor does one `issue:file` held itself** (joshuafolkken/kit#2808): a lint
|
|
25
|
+
// problem or an unacknowledged duplicate exits non-zero having filed nothing, and the reissue is the
|
|
26
|
+
// same Issue. The filing is counted only where its result did not fail.
|
|
25
27
|
|
|
26
28
|
const FILING_CAP = 10
|
|
27
29
|
// The current call is not yet on the tail, so the count read there is of filings *already* made. At
|
|
@@ -31,8 +33,9 @@ const CAP_REACHED = FILING_CAP
|
|
|
31
33
|
interface TailFilings {
|
|
32
34
|
// The `tool_use` id of every filing on the tail.
|
|
33
35
|
filing_ids: Array<string>
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
+
// The id of every result that came back as a failure — a guard refusal, or `issue:file` holding the
|
|
37
|
+
// filing itself (a lint problem, an unacknowledged duplicate), which files nothing either.
|
|
38
|
+
failures: Set<string>
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
function is_filing_use(block: Block): boolean {
|
|
@@ -42,7 +45,7 @@ function is_filing_use(block: Block): boolean {
|
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
function collect_block(block: Block, found: TailFilings): void {
|
|
45
|
-
if (block.result_id !== '') found.
|
|
48
|
+
if (block.result_id !== '' && block.is_error === true) found.failures.add(block.result_id)
|
|
46
49
|
if (is_filing_use(block)) found.filing_ids.push(block.id)
|
|
47
50
|
}
|
|
48
51
|
|
|
@@ -55,18 +58,18 @@ function collect_line(line: string, found: TailFilings): void {
|
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
function scan_tail(tail: string): TailFilings {
|
|
58
|
-
const found: TailFilings = { filing_ids: [],
|
|
61
|
+
const found: TailFilings = { filing_ids: [], failures: new Set<string>() }
|
|
59
62
|
|
|
60
63
|
for (const line of tail.split('\n')) collect_line(line, found)
|
|
61
64
|
|
|
62
65
|
return found
|
|
63
66
|
}
|
|
64
67
|
|
|
65
|
-
// How many Issues the run has already filed, counting only filings
|
|
68
|
+
// How many Issues the run has already filed, counting only filings whose result did not fail.
|
|
66
69
|
function prior_filing_count(tail: string): number {
|
|
67
|
-
const { filing_ids,
|
|
70
|
+
const { filing_ids, failures } = scan_tail(tail)
|
|
68
71
|
|
|
69
|
-
return filing_ids.filter((id) =>
|
|
72
|
+
return filing_ids.filter((id) => !failures.has(id)).length
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
// A person's prompt: a `user` line carrying no tool result. Tool results ride `user` lines too, so the
|
|
@@ -15,8 +15,8 @@ import { tail_commands } from './tail-commands'
|
|
|
15
15
|
// binds. The input is mechanical — has the run filed before, did it fold — so it is a delivered rule
|
|
16
16
|
// rather than resident prose.
|
|
17
17
|
//
|
|
18
|
-
// **It is `issue-scout`'s shape, not `filing-cap`'s.**
|
|
19
|
-
// stand-down read off the tail: a filing with no prior filing is stood down (nothing to fold), and so
|
|
18
|
+
// **It is `issue-scout`'s shape, not `filing-cap`'s.** Refused until an `already_satisfied`
|
|
19
|
+
// stand-down read off the tail answers: a filing with no prior filing is stood down (nothing to fold), and so
|
|
20
20
|
// is one the run has already folded. The refusal hands over the command rather than asking the run to
|
|
21
21
|
// "fold first". **No new predicate**: the filing trigger is `bash_triggers.is_issue_filing`, and the
|
|
22
22
|
// prior-filing count is `filing_cap.prior_filing_count`, both reused verbatim.
|
|
@@ -55,8 +55,8 @@ const ISSUE_FOLD_REASON =
|
|
|
55
55
|
'"<title>" …` — `fold` means file one Issue covering them, `separate` means file them apart ' +
|
|
56
56
|
'(separable findings whose combined size clears the split guide), and the default is `fold`. Add ' +
|
|
57
57
|
'`--not-separable` when they are really one deliverable. The procedure is ' +
|
|
58
|
-
'`.claude/skills/workflow-commands/split-assessment.md` → "The question".
|
|
59
|
-
'
|
|
58
|
+
'`.claude/skills/workflow-commands/split-assessment.md` → "The question". Every later filing is ' +
|
|
59
|
+
'refused until the fold is on the transcript.'
|
|
60
60
|
|
|
61
61
|
// The enumeration row itself, so `delivered-rules.ts` spreads one entry rather than restating the
|
|
62
62
|
// trigger and reason it already single-sources here. Once per run with the stand-down above.
|
|
@@ -11,8 +11,8 @@ import { tail_commands } from './tail-commands'
|
|
|
11
11
|
// `DeliveredRule` for each such oracle — refused when the run performs the governed action without
|
|
12
12
|
// having run the oracle's command earlier, stood down once it has.
|
|
13
13
|
//
|
|
14
|
-
// **It is `issue-scout`'s shape, generalized.** Each generated row
|
|
15
|
-
// `already_satisfied` stand-down
|
|
14
|
+
// **It is `issue-scout`'s shape, generalized.** Each generated row refuses until its
|
|
15
|
+
// `already_satisfied` stand-down reads the command off the tail — refuse the governed call, hand back the command,
|
|
16
16
|
// stand down once that command is on the tail. What `issue-scout` and `issue-fold` hand-wrote per
|
|
17
17
|
// oracle, this reads from the registry: the refusal is assembled from the oracle's decision, command,
|
|
18
18
|
// answer vocabulary and single source, so no oracle carries a hand-written refusal of its own. **No new
|
|
@@ -51,8 +51,8 @@ function reason_for(oracle: Oracle, firing_point: FiringPoint): string {
|
|
|
51
51
|
`⛔ decision oracle not consulted: this is ${firing_point.describes}, but the run has not run ` +
|
|
52
52
|
`\`${command_line(oracle)}\` first — the oracle that decides ${oracle.decision.toLowerCase()}. ` +
|
|
53
53
|
`It answers ${oracle.vocabulary.join(' | ')}; single source ${oracle.single_source}. Reissue ` +
|
|
54
|
-
`after running it and reading its verdict.
|
|
55
|
-
`
|
|
54
|
+
`after running it and reading its verdict. Every such call is refused until that command is on ` +
|
|
55
|
+
`the transcript.`
|
|
56
56
|
)
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { bash_triggers } from './bash-triggers'
|
|
2
1
|
import { decision_oracle } from './decision-oracle'
|
|
3
2
|
import { shell_segments } from './shell-segments'
|
|
4
3
|
|
|
@@ -32,9 +31,10 @@ function is_package_add(command: string): boolean {
|
|
|
32
31
|
return shell_segments.segments_of(command).some((segment) => PACKAGE_ADD.test(segment))
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
// The
|
|
36
|
-
//
|
|
37
|
-
//
|
|
34
|
+
// The firing point wired — the oracle whose governed call can be named without guessing: the package
|
|
35
|
+
// add. The Issue filing's `issue:lint` point left with joshuafolkken/kit#2808, when `josh issue:file`
|
|
36
|
+
// began running the lint itself. The rest declare why none can be named, so the unenforced oracles are
|
|
37
|
+
// visible rather than silently missing.
|
|
38
38
|
const FIRING: ReadonlyMap<string, FiringPoint> = new Map([
|
|
39
39
|
[
|
|
40
40
|
'pkg:scout',
|
|
@@ -43,13 +43,6 @@ const FIRING: ReadonlyMap<string, FiringPoint> = new Map([
|
|
|
43
43
|
governs: is_package_add,
|
|
44
44
|
},
|
|
45
45
|
],
|
|
46
|
-
[
|
|
47
|
-
'issue:lint',
|
|
48
|
-
{
|
|
49
|
-
describes: 'an Issue-filing call (`gh api …/issues`, `gh issue create`)',
|
|
50
|
-
governs: bash_triggers.is_issue_filing,
|
|
51
|
-
},
|
|
52
|
-
],
|
|
53
46
|
])
|
|
54
47
|
|
|
55
48
|
// The reason an oracle names no firing point, in three recurring shapes plus the one-off reasons for
|
|
@@ -66,9 +59,12 @@ const SELF = 'the command is itself the governed act; there is no earlier call t
|
|
|
66
59
|
const LATEST_SCOPE_REASON =
|
|
67
60
|
'the run it governs (`pnpm josh latest`) is already gated at its point of use by the latest-gate; ' +
|
|
68
61
|
'a firing here would double-gate it'
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
62
|
+
// `josh issue:file` runs the scout, the lint and `epic:bundle` itself, and a filing made any other way
|
|
63
|
+
// is refused by the `direct-filing` row (joshuafolkken/kit#2808) — so none of the three has a call left
|
|
64
|
+
// to gate before.
|
|
65
|
+
const FILING_STEP_REASON =
|
|
66
|
+
'`pnpm josh issue:file` runs it as one of its filing steps, and a direct filing is refused by the ' +
|
|
67
|
+
'`direct-filing` delivered rule; there is no separate call left to gate'
|
|
72
68
|
const RELEASE_SCOPE_REASON =
|
|
73
69
|
'it reads the release owed *after* `pnpm josh followup` has merged (`git_followup_pending`), so it ' +
|
|
74
70
|
'trails the merge rather than gating it; a firing on `followup` would gate a check that must run ' +
|
|
@@ -84,8 +80,9 @@ const NOT_NAMED: ReadonlyMap<string, string> = new Map([
|
|
|
84
80
|
['latest:scope', LATEST_SCOPE_REASON],
|
|
85
81
|
['run:hold', SELF],
|
|
86
82
|
['cost:cut', READ],
|
|
87
|
-
['epic:bundle',
|
|
88
|
-
['issue:scout',
|
|
83
|
+
['epic:bundle', FILING_STEP_REASON],
|
|
84
|
+
['issue:scout', FILING_STEP_REASON],
|
|
85
|
+
['issue:lint', FILING_STEP_REASON],
|
|
89
86
|
[
|
|
90
87
|
'issue:fold',
|
|
91
88
|
'the second filing it governs is already gated by the `issue-fold` delivered rule',
|
|
@@ -22,16 +22,13 @@ import { tail_commands } from './tail-commands'
|
|
|
22
22
|
// URL is stripped) all fail. What stays is the case the Issue names — writing rule prose into a rule
|
|
23
23
|
// document.
|
|
24
24
|
//
|
|
25
|
-
// **No `decide`, so once per run.** The refusal changes what the run *knows* — that a new rule owes
|
|
26
|
-
// question 0 and the ordering question — which is the shape `rule-delivery.md` says once-per-run is
|
|
27
|
-
// right for: told once, the run applies it to every later rule it writes.
|
|
28
|
-
//
|
|
29
25
|
// **The stand-down is what turned the reminder into a check** (joshuafolkken/kit#2324). Until now the
|
|
30
26
|
// row only *reminded* — it fired once and let the reissue through unconditionally, so "reissue and it
|
|
31
27
|
// goes" meant a computable rule could still be written into prose with nothing having answered the two
|
|
32
28
|
// questions. Now the pass condition is a *record*: the run has run `pnpm josh oracle:list` (question 0)
|
|
33
29
|
// and `pnpm josh run:step` (the ordering question) earlier in the transcript. Both present, the edit is
|
|
34
|
-
// stood down; either missing, it is refused
|
|
30
|
+
// stood down; either missing, it is refused — on the reissue too, since a row with a stand-down
|
|
31
|
+
// refuses until it is met (joshuafolkken/kit#2807). It passes on answering the questions, not on reading the
|
|
35
32
|
// reminder. Keeping the rule is therefore running those two commands, which `keeps` names.
|
|
36
33
|
|
|
37
34
|
// The two edit tools the `PreToolUse` matcher routes here. A `Write` carries the whole new file; an
|
|
@@ -133,7 +130,7 @@ function writes_rule_prose(
|
|
|
133
130
|
}
|
|
134
131
|
|
|
135
132
|
// The instruction in the shape a refusal can carry: what the edit is doing, the two questions it
|
|
136
|
-
// skips, the commands that answer them, and the reissue
|
|
133
|
+
// skips, the commands that answer them, and the reissue once they have run.
|
|
137
134
|
const RULE_BODY_REASON =
|
|
138
135
|
'⛔ rule written into prose: this edit adds rule text to CLAUDE.md, a `prompts/**` doc or a skill ' +
|
|
139
136
|
'body. Before it lands, run the residency questions the addition skips. A rule whose answer is ' +
|
|
@@ -144,9 +141,9 @@ const RULE_BODY_REASON =
|
|
|
144
141
|
'sequencing rule moves into. Only a rule that is neither — not computable, not ordering — stays ' +
|
|
145
142
|
'prose, and then as a trigger-plus-pointer line rather than a second copy of its procedure. The ' +
|
|
146
143
|
'criterion is `prompts/collaboration-workflow/residency.md` (question 0 and the ordering question); ' +
|
|
147
|
-
'the delivery enumeration is `prompts/collaboration-workflow/rule-delivery.md`.
|
|
148
|
-
'
|
|
149
|
-
'
|
|
144
|
+
'the delivery enumeration is `prompts/collaboration-workflow/rule-delivery.md`. Every such edit is ' +
|
|
145
|
+
'refused until both commands are on the transcript — run them, confirm the rule can be neither an ' +
|
|
146
|
+
'oracle nor an ordering step, then reissue this edit.'
|
|
150
147
|
|
|
151
148
|
// The two placement oracles the residency questions name: question 0 is `pnpm josh oracle:list`, the
|
|
152
149
|
// ordering question is `pnpm josh run:step` (its degenerate pre-implementation form `run:next` counts
|
|
@@ -136,8 +136,9 @@ const FILING_OFFER_REASON =
|
|
|
136
136
|
'⛔ filing offer: your reply offers to file an Issue instead of filing it. Filing into a ' +
|
|
137
137
|
'first-party repository is Tier A — the trigger is the judgement that it is worth filing, not the ' +
|
|
138
138
|
"run's progress (`.claude/skills/workflow-commands/SKILL.md` → §2i, `observation-filing.md`). Run " +
|
|
139
|
-
'`pnpm josh issue:
|
|
140
|
-
'one-line citation of what was filed
|
|
139
|
+
'`pnpm josh issue:file "<title>" --body-file <path> --depth <0|1|2>` — it scouts, lints, labels and ' +
|
|
140
|
+
'runs `epic:bundle` itself — then end with the one-line citation of what was filed; do not repeat ' +
|
|
141
|
+
'your previous reply. If it is not worth filing ' +
|
|
141
142
|
'after all, say so in one line instead.'
|
|
142
143
|
|
|
143
144
|
// **A headless parent's turn-end is its process's end** (joshuafolkken/kit#2437). Under `claude -p` the
|
|
@@ -4,6 +4,7 @@ import { josh_command } from '#scripts/josh/josh-run'
|
|
|
4
4
|
import { lane_child_marker } from '#scripts/lane/lane-child-marker'
|
|
5
5
|
import { run_cut_report } from './run-cut-report'
|
|
6
6
|
import { run_entry, type EntryParts } from './run-entry'
|
|
7
|
+
import { run_halfrun_resume } from './run-halfrun-resume'
|
|
7
8
|
import { run_hold_cli } from './run-hold-cli'
|
|
8
9
|
import { run_next } from './run-next'
|
|
9
10
|
import { run_prep } from './run-prep'
|
|
@@ -33,6 +34,7 @@ const NEWLINE = '\n'
|
|
|
33
34
|
const HOLD_STOP_NOTE =
|
|
34
35
|
'(tree not held — see the reason above; clean up and retry, or release with `pnpm josh run:release`)'
|
|
35
36
|
const COST_STOP_NOTE = '(session budget spent — see the figure above; resume in a fresh session)'
|
|
37
|
+
const HALFRUN_RESUME_TOKEN = 'halfrun'
|
|
36
38
|
|
|
37
39
|
// Exactly one issue number, or the call is refused — `run:entry` opens one run, and a second number
|
|
38
40
|
// would claim and read a second issue a caller reads as this one's.
|
|
@@ -126,11 +128,33 @@ function resume_report(issue_number: string, resume: Resume): number {
|
|
|
126
128
|
return resume.code
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
// **A `halfrun` stopped before its commit is resumed, not claimed** (joshuafolkken/kit#2796): its hold
|
|
132
|
+
// is kept over the verified diff, so the claim below would answer `busy` against it. The budget is asked
|
|
133
|
+
// first, as for any run; then the hold is adopted and the run goes to the gate, skipping everything up
|
|
134
|
+
// to and including implementation.
|
|
135
|
+
async function resume_halfrun(issue_number: string): Promise<number> {
|
|
136
|
+
const cost = await check_cost()
|
|
137
|
+
|
|
138
|
+
if (cost === run_entry.COST_OVER) {
|
|
139
|
+
return stop_report(issue_number, HALFRUN_RESUME_TOKEN, cost, COST_STOP_NOTE)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!(await run_halfrun_resume.adopt(issue_number))) {
|
|
143
|
+
return stop_report(issue_number, run_hold_cli.BUSY_VERDICT, cost, HOLD_STOP_NOTE)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.info(`entry #${issue_number} — resume: ${HALFRUN_RESUME_TOKEN}`)
|
|
147
|
+
|
|
148
|
+
return SUCCESS_EXIT_CODE
|
|
149
|
+
}
|
|
150
|
+
|
|
129
151
|
async function open_run(issue_number: string): Promise<number> {
|
|
130
152
|
const resume = await ask_resume(issue_number)
|
|
131
153
|
|
|
132
154
|
if (resume.token !== run_cut_report.FRESH_VERDICT) return resume_report(issue_number, resume)
|
|
133
155
|
|
|
156
|
+
if (await run_halfrun_resume.is_pending(issue_number)) return await resume_halfrun(issue_number)
|
|
157
|
+
|
|
134
158
|
const hold = await claim_hold(issue_number)
|
|
135
159
|
|
|
136
160
|
if (hold !== run_hold_cli.HOLD_VERDICT) {
|
|
@@ -162,7 +186,16 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
|
|
|
162
186
|
process.exitCode = await run(argv)
|
|
163
187
|
}
|
|
164
188
|
|
|
165
|
-
const run_entry_cli = {
|
|
189
|
+
const run_entry_cli = {
|
|
190
|
+
HALFRUN_RESUME_TOKEN,
|
|
191
|
+
SUCCESS_EXIT_CODE,
|
|
192
|
+
USAGE,
|
|
193
|
+
check_cost,
|
|
194
|
+
claim_hold,
|
|
195
|
+
main,
|
|
196
|
+
parse_number,
|
|
197
|
+
run,
|
|
198
|
+
}
|
|
166
199
|
|
|
167
200
|
if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
|
|
168
201
|
|