@joshuafolkken/kit 1.957.0 → 1.978.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/workflow-commands/background-commands.md +3 -0
- package/.claude/skills/workflow-commands/backlogrun-child.md +75 -120
- package/.claude/skills/workflow-commands/backlogrun-lanes.md +78 -115
- package/.claude/skills/workflow-commands/backlogrun-progress.md +82 -146
- package/.claude/skills/workflow-commands/backlogrun-steps.md +173 -229
- package/.claude/skills/workflow-commands/backlogrun.md +3 -1
- package/.claude/skills/workflow-commands/followup.md +3 -0
- package/.claude/skills/workflow-commands/fullrun.md +4 -4
- package/.claude/skills/workflow-commands/halfrun.md +12 -7
- package/.claude/skills/workflow-commands/observation-filing.md +31 -25
- package/.claude/skills/workflow-commands/pre-gate-cut.md +96 -161
- package/.claude/skills/workflow-commands/working-tree-hold.md +16 -1
- package/.github/workflows/pr-classification.yml +12 -1
- package/CLAUDE.md +3 -3
- package/README.md +25 -21
- package/dist/CLAUDE.md +3 -3
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-XSL4SRMA.js → chunk-2K52BRWV.js} +2 -2
- package/dist/hooks/{chunk-M6K7EQDO.js → chunk-BNGGEY3Z.js} +38 -32
- package/dist/hooks/{chunk-KOCK5FXQ.js → chunk-BVMSPG3M.js} +3 -3
- package/dist/hooks/{chunk-3ON7HOYB.js → chunk-CRGJTD6E.js} +1 -1
- package/dist/hooks/{chunk-HJXQZ7V4.js → chunk-F6OM6SIG.js} +3 -3
- package/dist/hooks/{chunk-LDQDZYHX.js → chunk-KQG4776P.js} +1 -1
- package/dist/hooks/{chunk-244WYOEO.js → chunk-NXL3SBXY.js} +2 -2
- package/dist/hooks/{chunk-IJPBTDTB.js → chunk-Q7LE77CO.js} +88 -18
- package/dist/hooks/{chunk-BELI5XSS.js → chunk-ZPLXLJ7O.js} +15 -5
- package/dist/hooks/{chunk-GNOITLRR.js → chunk-ZYYSBNR6.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 +206 -176
- package/dist/josh.js +8 -4
- package/package.json +4 -4
- package/prompts/collaboration-workflow/wip-cap.md +1 -1
- package/scripts/backlog/backlog-arrival.ts +11 -1
- package/scripts/backlog/backlog-budget.ts +25 -8
- package/scripts/backlog/backlog-next.ts +45 -2
- package/scripts/backlog/backlog-offer.ts +1 -0
- package/scripts/backlog/backlog-plan-cli.ts +66 -27
- package/scripts/backlog/backlog-plan.ts +30 -2
- package/scripts/backlog/backlog-stalled.ts +9 -0
- package/scripts/backlog/backlog-waves.ts +169 -0
- package/scripts/ci/pr-classification.ts +13 -12
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/document/entry-read-budget.ts +7 -5
- package/scripts/epic/epic-lane-offer.ts +69 -4
- package/scripts/epic/epic-next.ts +2 -2
- package/scripts/epic/epic-solo.ts +147 -0
- package/scripts/epic/epic-triage.ts +47 -0
- package/scripts/gate/hook-gate-reuse.ts +7 -8
- package/scripts/git/git-closes-keyword.ts +15 -0
- package/scripts/git/git-followup-flush.ts +5 -6
- package/scripts/git/git-followup-issue-close.ts +75 -0
- package/scripts/git/git-pr-followup-wrapup.ts +36 -6
- package/scripts/git/git-pr-followup.ts +9 -16
- package/scripts/git/git-staging.ts +33 -20
- package/scripts/git/issue-labels.ts +75 -5
- package/scripts/hooks/stop-guard.ts +1 -0
- package/scripts/init/init-bootstrap.ts +92 -0
- package/scripts/init/init-install.ts +67 -0
- package/scripts/init/init-logic-workspace.ts +1 -1
- package/scripts/init/init.ts +58 -13
- package/scripts/init/start-plan.ts +1 -1
- package/scripts/init/start-steps.ts +5 -28
- package/scripts/issue/issue-closing-pr.ts +93 -0
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
- package/scripts/josh/josh-commands-project.ts +5 -1
- package/scripts/josh/josh-commands-workflow.ts +3 -3
- package/scripts/observations/observation-ledger-prepare.ts +31 -0
- package/scripts/observations/observation-ledger.ts +5 -5
- package/scripts/observations/observations-flush.ts +11 -30
- package/scripts/repo/repository-labels.ts +91 -0
- package/scripts/rules/implementation-cut.ts +49 -16
- package/scripts/rules/lane-background.ts +25 -5
- package/scripts/rules/stop-rules.ts +16 -4
- package/scripts/run/run-carry-cli.ts +21 -0
- package/scripts/run/run-cut-cli.ts +92 -110
- package/scripts/run/run-cut-report.ts +167 -0
- package/scripts/run/run-cut.ts +23 -7
- package/scripts/run/run-entry-cli.ts +70 -3
- package/scripts/run/run-halfrun-resume.ts +104 -0
- package/scripts/run/run-headless.ts +1 -0
- package/scripts/run/run-hold-cli.ts +57 -13
- package/scripts/run/run-hold.ts +26 -4
- package/scripts/run/run-merge-cli.ts +28 -14
- package/scripts/run/run-merge-steps.ts +38 -2
- package/scripts/run/run-merge.ts +41 -3
- package/scripts/run/run-prep-cli.ts +8 -2
- package/scripts/run/run-prep-locate.ts +165 -0
- package/scripts/run/run-prep.ts +12 -1
- package/scripts/run/run-step-cli.ts +16 -0
- package/scripts/run/run-step.ts +28 -4
- package/scripts/run/run-tail-cli.ts +4 -2
- package/scripts/run/run-wake-work.ts +4 -0
- package/scripts/safe-chain/ci-installer-pin.ts +8 -4
- package/scripts/security/security-audit.ts +12 -1
- package/scripts/sync/sync.ts +6 -0
- package/scripts/time-runtime/time-background.ts +11 -0
- package/scripts/time-runtime/time-transcript-line.ts +4 -0
- package/templates/pnpm-workspace.static.yaml +2 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { git_closes_keyword } from '#scripts/git/git-closes-keyword'
|
|
2
|
+
import { git_gh_api_path } from '#scripts/git/git-gh-api-path'
|
|
3
|
+
import { git_gh_exec } from '#scripts/git/git-gh-exec'
|
|
4
|
+
import { git_gh_repo } from '#scripts/git/git-gh-repo'
|
|
5
|
+
import { parse_json_object_safe } from '#scripts/git/parse-json-array'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
|
|
8
|
+
// **The merged pull request that closes an issue GitHub left open** (joshuafolkken/kit#2769). A
|
|
9
|
+
// `backlogrun` child whose pull request merged while GitHub did not apply its `closes #N`
|
|
10
|
+
// (joshuafolkken/kit#2770) came back OPEN, and `run:merge` counted it a failure and parked it — the
|
|
11
|
+
// work had landed, only the issue's close had not.
|
|
12
|
+
//
|
|
13
|
+
// **A merged reference is not enough; the body must close this issue.** A merged pull request that
|
|
14
|
+
// merely mentions the issue — a follow-up, a sibling in the same epic — cross-references it too, and
|
|
15
|
+
// closing on that would close unfinished work. So the pull request counts only when its body's
|
|
16
|
+
// `closes #N` names this issue, read through the one parser every `closes #N` reader shares.
|
|
17
|
+
//
|
|
18
|
+
// **Only references after the last reopen count.** A reopened issue whose earlier attempt merged
|
|
19
|
+
// still carries that pull request's reference, and reading it would close the reopened work — the
|
|
20
|
+
// same reason `issue-merged.ts` reads the last closed/reopened event.
|
|
21
|
+
//
|
|
22
|
+
// **Only a pull request in this repository counts.** A bare `closes #N` names an issue in the pull
|
|
23
|
+
// request's own repository, so a merged pull request elsewhere whose `closes #N` carries the same
|
|
24
|
+
// number closes a different issue — its cross-reference here is a mention, not a close.
|
|
25
|
+
|
|
26
|
+
const LINE_SEPARATOR = '\n'
|
|
27
|
+
// One compact JSON object per merged pull request that cross-references the issue, and a
|
|
28
|
+
// `{"reopened":true}` marker per reopen, in timeline order.
|
|
29
|
+
const TIMELINE_FILTER = [
|
|
30
|
+
'.[]',
|
|
31
|
+
'| if .event == "cross-referenced" and .source.issue.pull_request.merged_at != null',
|
|
32
|
+
'then {url: .source.issue.html_url, repo: .source.issue.repository.full_name, body: (.source.issue.body // "")}',
|
|
33
|
+
'elif .event == "reopened" then {reopened: true}',
|
|
34
|
+
'else empty end',
|
|
35
|
+
].join(' ')
|
|
36
|
+
|
|
37
|
+
const reference_schema = z.object({ url: z.string(), repo: z.string(), body: z.string() })
|
|
38
|
+
const reopen_schema = z.object({ reopened: z.literal(true) })
|
|
39
|
+
const row_schema = z.union([reference_schema, reopen_schema])
|
|
40
|
+
|
|
41
|
+
type MergedReference = z.infer<typeof reference_schema>
|
|
42
|
+
type TimelineRow = z.infer<typeof row_schema>
|
|
43
|
+
|
|
44
|
+
function is_reference(row: TimelineRow): row is MergedReference {
|
|
45
|
+
return 'url' in row
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parse_references(raw: string): Array<MergedReference> {
|
|
49
|
+
const rows = raw
|
|
50
|
+
.split(LINE_SEPARATOR)
|
|
51
|
+
.map((line) => parse_json_object_safe(line, row_schema))
|
|
52
|
+
.filter((row) => row !== undefined)
|
|
53
|
+
const last_reopen = rows.findLastIndex((row) => !is_reference(row))
|
|
54
|
+
|
|
55
|
+
return rows.slice(last_reopen + 1).filter((row) => is_reference(row))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pick_closing_pr(
|
|
59
|
+
references: ReadonlyArray<MergedReference>,
|
|
60
|
+
issue: string,
|
|
61
|
+
repo: string,
|
|
62
|
+
): string | undefined {
|
|
63
|
+
return references.find(
|
|
64
|
+
(reference) =>
|
|
65
|
+
reference.repo === repo &&
|
|
66
|
+
git_closes_keyword.parse_closes_issue_number(reference.body) === issue,
|
|
67
|
+
)?.url
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Every doubt falls to `undefined` — an unreadable timeline or repository name leaves the child
|
|
71
|
+
// judged as it was, never closed on a guess.
|
|
72
|
+
async function read_closing_pr(issue: string): Promise<string | undefined> {
|
|
73
|
+
try {
|
|
74
|
+
const [raw, repo] = await Promise.all([
|
|
75
|
+
git_gh_exec.exec_gh_api({
|
|
76
|
+
path: `${git_gh_api_path.issue_api_path(issue)}/timeline?per_page=100`,
|
|
77
|
+
should_paginate: true,
|
|
78
|
+
jq_filter: TIMELINE_FILTER,
|
|
79
|
+
}),
|
|
80
|
+
git_gh_repo.repo_get_name_with_owner(),
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
if (repo === undefined) return undefined
|
|
84
|
+
|
|
85
|
+
return pick_closing_pr(parse_references(raw), issue, repo)
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const issue_closing_pr = { parse_references, pick_closing_pr, read_closing_pr }
|
|
92
|
+
|
|
93
|
+
export { issue_closing_pr }
|
|
@@ -449,10 +449,11 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
|
|
|
449
449
|
],
|
|
450
450
|
],
|
|
451
451
|
"init": [
|
|
452
|
-
"[--profile static|node]",
|
|
452
|
+
"[--profile static|node] [--no-install]",
|
|
453
453
|
"developer",
|
|
454
454
|
[
|
|
455
455
|
"files",
|
|
456
|
+
"network",
|
|
456
457
|
"processes",
|
|
457
458
|
],
|
|
458
459
|
],
|
|
@@ -26,7 +26,11 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
|
|
|
26
26
|
script: 'scripts/init/init.ts',
|
|
27
27
|
description: 'Initialize config in a new project',
|
|
28
28
|
category: 'Project',
|
|
29
|
-
reference: [
|
|
29
|
+
reference: [
|
|
30
|
+
`${PROFILE_ARGUMENTS} [--no-install]`,
|
|
31
|
+
'developer',
|
|
32
|
+
['files', 'network', 'processes'],
|
|
33
|
+
],
|
|
30
34
|
},
|
|
31
35
|
sync: {
|
|
32
36
|
script: 'scripts/sync/sync.ts',
|
|
@@ -52,9 +52,9 @@ const WORKFLOW_COMMANDS: Record<string, CommandEntry> = {
|
|
|
52
52
|
reference: ['--task-type <type> --body <text>', 'automation', ['notifications']],
|
|
53
53
|
tsx_arguments: OPTIONAL_ENV_FILE_FLAGS,
|
|
54
54
|
},
|
|
55
|
-
// The observation ledger's
|
|
56
|
-
//
|
|
57
|
-
//
|
|
55
|
+
// The observation ledger's commit path for the lines no run's own commit carried
|
|
56
|
+
// (joshuafolkken/kit#1756, joshuafolkken/kit#2763): a lane's lines sit in the primary checkout, which
|
|
57
|
+
// its commit cannot see, and the recurrence count the promotion rule reads is a count of main.
|
|
58
58
|
'observations:flush': {
|
|
59
59
|
script: 'scripts/observations/observations-flush-cli.ts',
|
|
60
60
|
description: 'Commit the observation ledger as a pull request of its own, and merge it',
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { OBSERVATION_LEDGER_PATH } from './observation-ledger'
|
|
4
|
+
import { observation_ledger_line, type BrokenLine } from './observation-ledger-line'
|
|
5
|
+
import { observation_ledger_migrate } from './observation-ledger-migrate'
|
|
6
|
+
|
|
7
|
+
// **What has to hold before a ledger line is committed, asked by both of the paths that commit one**
|
|
8
|
+
// (joshuafolkken/kit#2763): `pnpm josh observations:flush` and the run's own commit in
|
|
9
|
+
// `scripts/git/git-staging.ts`. Lines still on the old path are moved first (joshuafolkken/kit#2724),
|
|
10
|
+
// so the commit carries the move and the lines together, and every entry line is then parsed against
|
|
11
|
+
// the grammar (joshuafolkken/kit#2123) — each caller decides what a broken line costs it.
|
|
12
|
+
//
|
|
13
|
+
// **A ledger that is not there has no line to break** — the change a status saw is then its deletion,
|
|
14
|
+
// and a commit carries that like any other change to it.
|
|
15
|
+
async function read_ledger(root: string): Promise<string> {
|
|
16
|
+
try {
|
|
17
|
+
return await readFile(path.join(root, OBSERVATION_LEDGER_PATH), 'utf8')
|
|
18
|
+
} catch {
|
|
19
|
+
return ''
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function prepare(root: string): Promise<ReadonlyArray<BrokenLine>> {
|
|
24
|
+
observation_ledger_migrate.migrate(root)
|
|
25
|
+
|
|
26
|
+
return observation_ledger_line.broken_ledger_lines(await read_ledger(root))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const observation_ledger_prepare = { prepare }
|
|
30
|
+
|
|
31
|
+
export { observation_ledger_prepare }
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// The observation ledger's one path, named here rather than inside any of the places that act on it
|
|
2
|
-
// (joshuafolkken/kit#1756). `git-staging.ts`
|
|
3
|
-
// `hook-gate-reuse.ts` excludes it from what a hook calls a carried
|
|
4
|
-
// `observations-flush.ts`
|
|
5
|
-
// answers to one question, and a rename reaching only some of them would silently
|
|
6
|
-
// contamination the exclusion exists to prevent.
|
|
2
|
+
// (joshuafolkken/kit#1756). `git-staging.ts` stages it with the run's own commit when its grammar
|
|
3
|
+
// holds (joshuafolkken/kit#2763), `hook-gate-reuse.ts` excludes it from what a hook calls a carried
|
|
4
|
+
// tree, and `observations-flush.ts` commits what no run carried — so a literal in each would be three
|
|
5
|
+
// answers to one question, and a rename reaching only some of them would silently split them.
|
|
7
6
|
//
|
|
8
7
|
// **It sits under `docs/maintainers/`, apart from the reader-facing documents** (joshuafolkken/kit#2724).
|
|
9
8
|
const OBSERVATION_LEDGER_PATH = 'docs/maintainers/observations.md'
|
|
@@ -70,6 +69,7 @@ const observation_ledger = {
|
|
|
70
69
|
has_pending_append,
|
|
71
70
|
is_ledger_line,
|
|
72
71
|
is_ledger_path,
|
|
72
|
+
is_migration_claim,
|
|
73
73
|
ledger_paths,
|
|
74
74
|
status_path,
|
|
75
75
|
}
|
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises'
|
|
2
1
|
import { git_command } from '#scripts/git/git-command'
|
|
3
2
|
import { git_gh_command } from '#scripts/git/git-gh-command'
|
|
4
3
|
import { main_sync } from '#scripts/git/main-sync'
|
|
5
4
|
import { observation_ledger, OBSERVATION_LEDGER_PATH } from './observation-ledger'
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import type { BrokenLine } from './observation-ledger-line'
|
|
6
|
+
import { observation_ledger_prepare } from './observation-ledger-prepare'
|
|
8
7
|
import { observations_flush_landing } from './observations-flush-landing'
|
|
9
8
|
|
|
10
|
-
// The commit path the
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// staging (`scripts/git/git-staging.ts`) and this is the one thing that stages it.
|
|
9
|
+
// The commit path for the ledger lines no run's own commit carried (joshuafolkken/kit#1756). A run in
|
|
10
|
+
// the primary checkout commits its appended lines with its own pull request (`scripts/git/
|
|
11
|
+
// git-staging.ts`, joshuafolkken/kit#2763), so what is left here is a lane's lines — they sit in the
|
|
12
|
+
// primary checkout, which a lane's commit cannot see — and whatever was appended after a commit.
|
|
15
13
|
//
|
|
16
14
|
// **The shape is `scripts/release/release-publish.ts`'s, deliberately.** Both open a pull request of
|
|
17
15
|
// their own over one path, wait on the same required checks every other pull request waits on, and
|
|
@@ -57,7 +55,7 @@ function branch_name_for(stamp: string): string {
|
|
|
57
55
|
// single issue, and the ledger's own lines say where each one was seen.
|
|
58
56
|
function pull_request_body(): string {
|
|
59
57
|
return [
|
|
60
|
-
`Appended observations from \`${OBSERVATION_LEDGER_PATH}
|
|
58
|
+
`Appended observations from \`${OBSERVATION_LEDGER_PATH}\` that no run's own commit carried.`,
|
|
61
59
|
'',
|
|
62
60
|
'The ledger is append-only, and a second line under one key is what promotes an observation to an issue — so this pull request adds lines and changes none.',
|
|
63
61
|
'',
|
|
@@ -373,31 +371,14 @@ function broken_lines_message(broken: ReadonlyArray<BrokenLine>): string {
|
|
|
373
371
|
|
|
374
372
|
// **A malformed line is refused before a branch is cut, not after.** The ledger path was collected
|
|
375
373
|
// without a line ever being parsed (joshuafolkken/kit#2123), so a broken append could ride a flush
|
|
376
|
-
// through; validating here keeps the committed ledger to its grammar.
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
async function read_ledger(): Promise<string> {
|
|
381
|
-
try {
|
|
382
|
-
return await readFile(OBSERVATION_LEDGER_PATH, 'utf8')
|
|
383
|
-
} catch {
|
|
384
|
-
return ''
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async function refuse_broken_ledger(): Promise<void> {
|
|
389
|
-
const broken = observation_ledger_line.broken_ledger_lines(await read_ledger())
|
|
374
|
+
// through; validating here keeps the committed ledger to its grammar. The migration and the parse are
|
|
375
|
+
// `observation-ledger-prepare.ts`'s, shared with the run's own commit (joshuafolkken/kit#2763).
|
|
376
|
+
async function prepare_ledger(): Promise<void> {
|
|
377
|
+
const broken = await observation_ledger_prepare.prepare(process.cwd())
|
|
390
378
|
|
|
391
379
|
if (broken.length > 0) throw new Error(broken_lines_message(broken))
|
|
392
380
|
}
|
|
393
381
|
|
|
394
|
-
// Lines still on the ledger's old path are moved before anything is validated or staged
|
|
395
|
-
// (joshuafolkken/kit#2724), so the flush commits the move and the lines together.
|
|
396
|
-
async function prepare_ledger(): Promise<void> {
|
|
397
|
-
observation_ledger_migrate.migrate(process.cwd())
|
|
398
|
-
await refuse_broken_ledger()
|
|
399
|
-
}
|
|
400
|
-
|
|
401
382
|
// **Nothing pulls in front of the branch, and that is deliberate.** The ledger is dirty by
|
|
402
383
|
// definition at this point, so `git pull --ff-only` aborts on it in exactly the case a pull would
|
|
403
384
|
// have been for — an upstream flush that already advanced the ledger — and reports a failure about
|
|
@@ -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 }
|
|
@@ -63,6 +63,9 @@ interface LaneCostState {
|
|
|
63
63
|
directory: string
|
|
64
64
|
source: MarkerSource
|
|
65
65
|
carried: (now?: Date) => RunCut | undefined
|
|
66
|
+
// The issue whose run hold stands over this checkout (joshuafolkken/kit#2760) — how a `fullrun`
|
|
67
|
+
// held outside a lane is told apart from a person's tree.
|
|
68
|
+
held_issue: () => string | undefined
|
|
66
69
|
verdict: () => CostVerdict
|
|
67
70
|
}
|
|
68
71
|
|
|
@@ -71,6 +74,7 @@ function current_state(): LaneCostState {
|
|
|
71
74
|
directory: process.cwd(),
|
|
72
75
|
source: process.env,
|
|
73
76
|
carried: run_cut.carried_cut_sync,
|
|
77
|
+
held_issue: run_cut.held_issue_sync,
|
|
74
78
|
// **Read through the short reuse window** (joshuafolkken/kit#2385). The row now fires per
|
|
75
79
|
// threshold crossing rather than once per run, so this predicate is a candidate on every edit;
|
|
76
80
|
// the window collapses one turn's burst of edits to a single whole-transcript price.
|
|
@@ -79,22 +83,49 @@ function current_state(): LaneCostState {
|
|
|
79
83
|
}
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
// A dispatched lane child for this checkout
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
// this issue means a cut is already in flight, and `begin_cut`'s exclusive create would refuse a second
|
|
87
|
-
// one anyway.
|
|
88
|
-
function uncut_lane_child_issue(state: LaneCostState): string | undefined {
|
|
86
|
+
// A dispatched lane child's issue for this checkout. The dispatch mark is what tells a child apart from
|
|
87
|
+
// a person (joshuafolkken/kit#1904), read from the environment against this lane's own issue so a
|
|
88
|
+
// leaked mark for another issue reads as a person.
|
|
89
|
+
function lane_child_issue(state: LaneCostState): string | undefined {
|
|
89
90
|
if (!lane_child_marker.is_child_of(state.directory, state.source)) return undefined
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
return lane_paths.lane_issue_of(state.directory, { ...state.source })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The issue a run holds this checkout for, read only outside a lane: a lane's hold is its dispatched
|
|
96
|
+
// child's, so a person finishing that lane's work carries no mark and still sees no refusal.
|
|
97
|
+
function held_run_issue(state: LaneCostState): string | undefined {
|
|
98
|
+
if (lane_paths.lane_issue_of(state.directory, { ...state.source }) !== undefined) return undefined
|
|
99
|
+
|
|
100
|
+
return state.held_issue()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The run this checkout's edits belong to, with no cut already carried — a dispatched lane child, or
|
|
104
|
+
// **a `fullrun` held in its own checkout** (joshuafolkken/kit#2760): the cut was lane-only, so a run a
|
|
105
|
+
// person started grew without a bound mid-implementation. The run hold naming an issue is what marks
|
|
106
|
+
// such a run, and a person's tree holds nothing, so it still sees no refusal. **The carried-cut half
|
|
107
|
+
// keeps the guard silent between a cut and its resume**, exactly as `pre-gate-cut.ts`'s does: a record
|
|
108
|
+
// naming this issue means a cut is already in flight, and `begin_cut`'s exclusive create would refuse
|
|
109
|
+
// a second one anyway.
|
|
110
|
+
function uncut_run_issue(state: LaneCostState): string | undefined {
|
|
111
|
+
const issue = lane_child_issue(state) ?? held_run_issue(state)
|
|
92
112
|
|
|
93
113
|
if (issue === undefined) return undefined
|
|
94
114
|
|
|
95
115
|
return state.carried()?.issue === issue ? undefined : issue
|
|
96
116
|
}
|
|
97
117
|
|
|
118
|
+
// **An unmeasurable session is cut in a lane and left alone outside one.** A lane child's safety net is
|
|
119
|
+
// the pre-gate direction below; a held run outside a lane is a person's own session, so only a measured
|
|
120
|
+
// `over` interrupts it — a provider with no transcript would otherwise refuse every edit it makes.
|
|
121
|
+
function warrants_the_cut(state: LaneCostState): boolean {
|
|
122
|
+
const verdict = state.verdict()
|
|
123
|
+
|
|
124
|
+
if (lane_child_issue(state) === undefined) return verdict === cost_verdict.OVER_VERDICT
|
|
125
|
+
|
|
126
|
+
return verdict !== cost_verdict.UNDER_VERDICT
|
|
127
|
+
}
|
|
128
|
+
|
|
98
129
|
// **The tool-name test comes first and the world is consulted second, the cost measurement last.** This
|
|
99
130
|
// predicate is asked of every call in the run, so the `Edit` / `Write` string match keeps the lane read
|
|
100
131
|
// off all but the edits, and the lane read keeps the transcript-priced verdict off every edit but those
|
|
@@ -109,9 +140,9 @@ function is_over_threshold_edit(
|
|
|
109
140
|
call: GuardedCall,
|
|
110
141
|
state: LaneCostState = current_state(),
|
|
111
142
|
): boolean {
|
|
112
|
-
if (!is_edit_tool(call.name) ||
|
|
143
|
+
if (!is_edit_tool(call.name) || uncut_run_issue(state) === undefined) return false
|
|
113
144
|
|
|
114
|
-
return state
|
|
145
|
+
return warrants_the_cut(state)
|
|
115
146
|
}
|
|
116
147
|
|
|
117
148
|
// **The threshold in the refusal text is assembled from the constant, never spelled** (joshuafolkken/kit#2385).
|
|
@@ -125,7 +156,8 @@ const THRESHOLD_TEXT = `${cost_format.format_tokens(CONTEXT_CUT_THRESHOLD)}-toke
|
|
|
125
156
|
// because `cut` is the only one that ends the turn and a run told merely to "take the cut" would have to
|
|
126
157
|
// read which of the others leave it implementing — the same reason `pre-gate-cut.ts` spells them out.
|
|
127
158
|
const IMPLEMENTATION_CUT_REASON =
|
|
128
|
-
'⛔ implementation-phase cut: this checkout is a lane dispatched for this issue
|
|
159
|
+
'⛔ implementation-phase cut: this checkout is a lane dispatched for this issue, or a run holds it for ' +
|
|
160
|
+
'this issue (joshuafolkken/kit#2760), and its recent-context ' +
|
|
129
161
|
`cost has crossed the shared ${THRESHOLD_TEXT} threshold mid-implementation, so the thinking accumulated ` +
|
|
130
162
|
'so far is now re-read on every later request. Take the cut before this edit. ' +
|
|
131
163
|
'First write a handoff file with the Write tool — the user’s instruction verbatim, what you have ' +
|
|
@@ -139,11 +171,12 @@ const IMPLEMENTATION_CUT_REASON =
|
|
|
139
171
|
'because the verdict was only ever read at session entry, where the context has not yet grown. The ' +
|
|
140
172
|
"verdict is `pnpm josh cost --cut`'s exactly — the same per-request billed-input statistic against the " +
|
|
141
173
|
'same threshold, never a second measurement. Issue `pnpm josh run:cut --impl <N> --handoff <path>` now and read the ' +
|
|
142
|
-
'verdict: on `cut`, **end the turn immediately** — the fresh process owns the run and
|
|
143
|
-
'implementing, so it must not be waited for;
|
|
174
|
+
'verdict: on `cut`, **end the turn immediately** — in a lane the fresh process owns the run and ' +
|
|
175
|
+
'continues implementing, so it must not be waited for; outside a lane nothing is relaunched, so first ' +
|
|
176
|
+
'send the `confirmation` Telegram naming the resume command `fullrun #<N>` and keep the hold; on `not-a-lane`, `unready`, `busy`, `failed` or ' +
|
|
144
177
|
'`unknown`, this process carries the run on and the edit is simply the next call. Never relaunch a ' +
|
|
145
|
-
'second process after `busy`. This fired because the dispatch mark names this lane
|
|
146
|
-
'here carries no mark and sees no refusal, so there is no human-or-child judgement left to make. The ' +
|
|
178
|
+
'second process after `busy`. This fired because the dispatch mark names this lane or the run hold ' +
|
|
179
|
+
'names this issue; a person working here carries no mark and holds no run, so sees no refusal, so there is no human-or-child judgement left to make. The ' +
|
|
147
180
|
'procedure is `.claude/skills/workflow-commands/pre-gate-cut.md`. Reissue this edit once the cut has ' +
|
|
148
181
|
'answered — an edit reissued right after this refusal passes, so `busy` / `failed` cannot wedge the ' +
|
|
149
182
|
'run edit after edit; this fires again on the next threshold crossing rather than once per run.'
|
|
@@ -195,7 +228,7 @@ const implementation_cut = {
|
|
|
195
228
|
is_over_threshold_edit,
|
|
196
229
|
is_reissued_refusal,
|
|
197
230
|
takes_the_impl_cut,
|
|
198
|
-
|
|
231
|
+
uncut_run_issue,
|
|
199
232
|
}
|
|
200
233
|
|
|
201
234
|
export type { LaneCostState }
|
|
@@ -3,7 +3,10 @@ import { lane_child_marker } from '#scripts/lane/lane-child-marker'
|
|
|
3
3
|
import { json_value } from '#scripts/lib/json-value'
|
|
4
4
|
import type { GuardedCall } from '#scripts/time-runtime/time-batch-guard'
|
|
5
5
|
import { time_shell } from '#scripts/time-runtime/time-shell'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
time_transcript_line,
|
|
8
|
+
type Block as TranscriptBlock,
|
|
9
|
+
} from '#scripts/time-runtime/time-transcript-line'
|
|
7
10
|
import { run_tail } from './run-tail'
|
|
8
11
|
|
|
9
12
|
// A dispatched lane child is stopped from backgrounding a long-running josh command (joshuafolkken/kit#2704).
|
|
@@ -60,16 +63,32 @@ function is_background_long_run(
|
|
|
60
63
|
return is_candidate(call) && is_lane_child()
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
// The
|
|
64
|
-
// tool result and a finish off the harness notice, both by `time_transcript_line`.
|
|
65
|
-
function
|
|
66
|
+
// The launches `launched_ids` reads off the transcript tail that never saw their finish. A launch is
|
|
67
|
+
// read off its own tool result and a finish off the harness notice, both by `time_transcript_line`.
|
|
68
|
+
function unfinished_ids(
|
|
69
|
+
tail: string,
|
|
70
|
+
launched_ids: (block: TranscriptBlock) => ReadonlyArray<string>,
|
|
71
|
+
): ReadonlyArray<string> {
|
|
66
72
|
const lines = tail.split('\n').map((line) => time_transcript_line.parse_line(line))
|
|
67
73
|
const finished = new Set(lines.map((line) => line?.finished_background ?? ''))
|
|
68
|
-
const
|
|
74
|
+
const blocks = lines.flatMap((line) => line?.blocks ?? [])
|
|
75
|
+
const launched = blocks.flatMap((block) => launched_ids(block))
|
|
69
76
|
|
|
70
77
|
return launched.filter((id) => id !== '' && !finished.has(id))
|
|
71
78
|
}
|
|
72
79
|
|
|
80
|
+
// The background tasks the tail launched and never saw finish — commands and subagents alike
|
|
81
|
+
// (joshuafolkken/kit#2774), since a lane child's turn-end kills either.
|
|
82
|
+
function pending_background_ids(tail: string): ReadonlyArray<string> {
|
|
83
|
+
return unfinished_ids(tail, (block) => [block.background_id, block.agent_id])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The backgrounded subagents alone. A subagent always ends and its notice re-invokes the session; a
|
|
87
|
+
// command may be a server that never exits, so only this set says the run is certain to resume.
|
|
88
|
+
function pending_agent_ids(tail: string): ReadonlyArray<string> {
|
|
89
|
+
return unfinished_ids(tail, (block) => [block.agent_id])
|
|
90
|
+
}
|
|
91
|
+
|
|
73
92
|
const LANE_BACKGROUND_REASON =
|
|
74
93
|
'⛔ lane background: this session is a dispatched lane child (`JOSH_LANE_CHILD`), a headless ' +
|
|
75
94
|
'`claude -p` process whose background Bash tasks are killed when the turn ends — so a backgrounded ' +
|
|
@@ -105,6 +124,7 @@ const lane_background = {
|
|
|
105
124
|
LANE_BACKGROUND_STOP_REASON,
|
|
106
125
|
ROW,
|
|
107
126
|
is_background_long_run,
|
|
127
|
+
pending_agent_ids,
|
|
108
128
|
pending_background_ids,
|
|
109
129
|
}
|
|
110
130
|
|
|
@@ -67,9 +67,12 @@ interface StopContext {
|
|
|
67
67
|
headless_refusals: number
|
|
68
68
|
// This session is a dispatched lane child for this checkout — `lane_child_marker.is_child_of`.
|
|
69
69
|
lane_child: boolean
|
|
70
|
-
// A background task this run launched has not finished —
|
|
71
|
-
// over the transcript tail (joshuafolkken/kit#2704).
|
|
70
|
+
// A background task this run launched — a command or a subagent — has not finished —
|
|
71
|
+
// `lane_background.pending_background_ids` over the transcript tail (joshuafolkken/kit#2704).
|
|
72
72
|
background_pending: boolean
|
|
73
|
+
// A backgrounded subagent this run launched has not finished — `lane_background.pending_agent_ids`
|
|
74
|
+
// (joshuafolkken/kit#2774). Apart from `background_pending`, since only a subagent is sure to end.
|
|
75
|
+
agent_pending: boolean
|
|
73
76
|
// The resolved `JOSH_SESSION_LANG` — `session_language.resolve_session_lang` (joshuafolkken/kit#2470).
|
|
74
77
|
session_lang: string
|
|
75
78
|
}
|
|
@@ -221,12 +224,21 @@ function needs_filing(context: StopContext): boolean {
|
|
|
221
224
|
return filing_offer.is_first_party_target(context.message, context.session_owner)
|
|
222
225
|
}
|
|
223
226
|
|
|
227
|
+
// **A running background subagent stands both hold rules down** (joshuafolkken/kit#2774). Its completion
|
|
228
|
+
// notice re-invokes the session, so the turn-end is a wait on the run's own work, not on a person — a
|
|
229
|
+
// notify there says "nothing needed" and dilutes the ones that do. A backgrounded command does not
|
|
230
|
+
// count: a dev server never ends, and a stop behind it would leave a person silently waiting. A lane
|
|
231
|
+
// child never reaches this with a task running: `needs_background_wait` answers it first.
|
|
232
|
+
function is_held_idle(context: StopContext): boolean {
|
|
233
|
+
return context.hold_present && !context.agent_pending
|
|
234
|
+
}
|
|
235
|
+
|
|
224
236
|
function needs_notify(context: StopContext): boolean {
|
|
225
|
-
return context
|
|
237
|
+
return is_held_idle(context) && !context.notified
|
|
226
238
|
}
|
|
227
239
|
|
|
228
240
|
function needs_release(context: StopContext): boolean {
|
|
229
|
-
return context
|
|
241
|
+
return is_held_idle(context) && context.tree_clean
|
|
230
242
|
}
|
|
231
243
|
|
|
232
244
|
function citation_reason(message: string): string | undefined {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { cost_cli } from '#scripts/cost-runtime/cost-cli'
|
|
4
|
+
import { cost_verdict } from '#scripts/cost-runtime/cost-verdict'
|
|
3
5
|
import {
|
|
4
6
|
run_carry,
|
|
5
7
|
type CarryClaim,
|
|
@@ -44,6 +46,9 @@ const UNKNOWN_VERDICT = 'unknown'
|
|
|
44
46
|
// the run carries on uncut rather than paying a cold preamble the accumulation it would shed no longer
|
|
45
47
|
// covers, exactly as an under-threshold pre-gate cut carries on to the gate.
|
|
46
48
|
const CAPPED_VERDICT = 'capped'
|
|
49
|
+
// The asking session is over the shared context-cut threshold, so nothing was claimed
|
|
50
|
+
// (joshuafolkken/kit#2760).
|
|
51
|
+
const OVER_VERDICT = 'over'
|
|
47
52
|
const NO_CUTS = 0
|
|
48
53
|
|
|
49
54
|
const CLAIM_VERDICTS: Record<CarryClaim, string> = {
|
|
@@ -235,7 +240,22 @@ function adopt(target: string, request: CarryClaimRequest, is_json: boolean): nu
|
|
|
235
240
|
return report_claim(target, read.carry, request, is_json)
|
|
236
241
|
}
|
|
237
242
|
|
|
243
|
+
// **A session already over the shared cut threshold claims nothing** (joshuafolkken/kit#2760). A claim
|
|
244
|
+
// assumed the asking session was fresh, so a `backlogrun` parent that had taken its cut at 233k could
|
|
245
|
+
// retype the keyword in the same conversation and be answered `resumed` by its own hand-off — measured
|
|
246
|
+
// twice, at 232k and 258k. The verdict is `pnpm josh cost --cut`'s, so no second threshold exists; only a
|
|
247
|
+
// measured `over` refuses, because a provider with no transcript cannot be priced at all.
|
|
248
|
+
function report_over(is_json: boolean): number {
|
|
249
|
+
console.error(
|
|
250
|
+
'This session is over the shared context-cut threshold (`pnpm josh cost --cut` answers `over`), so no run was begun or resumed here. End this conversation and type the invocation again in a fresh session.',
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
return report(OVER_VERDICT, undefined, is_json, FAILURE_EXIT_CODE)
|
|
254
|
+
}
|
|
255
|
+
|
|
238
256
|
function claim_record(target: string, request: CarryClaimRequest, is_json: boolean): number {
|
|
257
|
+
if (cost_cli.session_verdict() === cost_verdict.OVER_VERDICT) return report_over(is_json)
|
|
258
|
+
|
|
239
259
|
return request.is_adoption ? adopt(target, request, is_json) : begin(target, request, is_json)
|
|
240
260
|
}
|
|
241
261
|
|
|
@@ -425,6 +445,7 @@ const run_carry_cli = {
|
|
|
425
445
|
EXPIRED_VERDICT,
|
|
426
446
|
MISMATCH_VERDICT,
|
|
427
447
|
NONE_VERDICT,
|
|
448
|
+
OVER_VERDICT,
|
|
428
449
|
RESUMED_VERDICT,
|
|
429
450
|
STANDING_VERDICT,
|
|
430
451
|
UNKNOWN_VERDICT,
|