@joshuafolkken/kit 1.1094.0 → 1.1136.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/agents/investigator.md +17 -0
- package/.claude/settings.json +1 -0
- package/.claude/skills/workflow-commands/SKILL.md +1 -1
- package/.claude/skills/workflow-commands/background-commands.md +36 -85
- package/.claude/skills/workflow-commands/backlogrun-progress.md +31 -34
- package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -2
- package/.claude/skills/workflow-commands/delegation.md +5 -1
- package/.claude/skills/workflow-commands/fullrun.md +5 -4
- package/.claude/skills/workflow-commands/halfrun.md +5 -4
- package/.claude/skills/workflow-commands/latest-gate.md +7 -15
- package/.claude/skills/workflow-commands/pre-gate-cut.md +4 -8
- package/.claude/skills/workflow-commands/split-assessment.md +4 -18
- package/.github/actions/setup-node/action.yml +33 -0
- package/.github/actions/setup-pnpm/action.yml +4 -4
- package/.github/workflows/auto-tag.yml +2 -3
- package/.github/workflows/ci.yml +7 -8
- package/.github/workflows/dependabot-auto-merge.yml +1 -0
- package/.github/workflows/pr-classification.yml +1 -3
- package/.github/workflows/production.yml +1 -0
- package/.github/workflows/publish.yml +10 -9
- package/.github/workflows/sonar-qube.yml +17 -13
- package/.pnpmfile.mjs +29 -0
- package/AGENTS.md +4 -11
- package/CLAUDE.md +24 -17
- package/GEMINI.md +4 -13
- package/README.md +8 -3
- package/SECURITY.md +2 -0
- package/cspell/index.yaml +4 -0
- package/dist/CLAUDE.basic.md +6 -6
- package/dist/CLAUDE.md +24 -17
- package/dist/CLAUDE.static.md +6 -6
- package/dist/config-merge/index.js +33 -9
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-OIZUHPJI.js → chunk-FO2I7UT7.js} +1 -1
- package/dist/hooks/{chunk-NKOG3XAV.js → chunk-GDU26K3J.js} +11 -3
- package/dist/hooks/{chunk-O4LL4HTI.js → chunk-HANSKHES.js} +2 -2
- package/dist/hooks/{chunk-3C4YCCEF.js → chunk-HBKZLITF.js} +4 -4
- package/dist/hooks/{chunk-UD24EAIL.js → chunk-JKKTVDAG.js} +1984 -146
- package/dist/hooks/{chunk-M2XDCUKD.js → chunk-KBXZLMRH.js} +89 -1803
- package/dist/hooks/{chunk-IL27YTPH.js → chunk-KOKOAV6D.js} +2 -2
- package/dist/hooks/{chunk-7ZPFAVR7.js → chunk-LJBLGHEH.js} +59 -19
- package/dist/hooks/{chunk-E54KGFEQ.js → chunk-NFZGFGOR.js} +1 -1
- package/dist/hooks/{chunk-KHWHKJOM.js → chunk-RFWWUI5F.js} +4 -4
- package/dist/hooks/codex-hook-adapter.js +12 -47
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/inputs.json +175 -173
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +17 -12
- package/dist/josh.js +87 -31
- package/dist/version/index.js +36 -20
- package/eslint/rules/unicorn.js +3 -2
- package/package.json +23 -5
- package/pnpm-workspace.yaml +5 -7
- package/prompts/collaboration-workflow/glossary.md +17 -0
- package/prompts/collaboration-workflow/issue-template.md +1 -1
- package/prompts/collaboration-workflow/operating-rules.md +1 -1
- package/prompts/collaboration-workflow/principles.md +9 -0
- package/prompts/collaboration-workflow.md +1 -0
- package/scripts/backlog/backlog-arrival.ts +30 -10
- package/scripts/backlog/backlog-budget-cli.ts +15 -16
- package/scripts/backlog/backlog-offer-cli.ts +6 -14
- package/scripts/backlog/backlog-plan.ts +29 -18
- package/scripts/backlog/backlog-ready.ts +20 -7
- package/scripts/config-merge/parse-jsonc.ts +33 -3
- package/scripts/config-merge/patch-json-key.ts +13 -6
- package/scripts/cost-runtime/cost-cli.ts +3 -7
- package/scripts/delegation/delegation-policy.ts +9 -1
- package/scripts/delegation/investigation-guard.ts +1 -1
- package/scripts/delegation/investigation-reads.ts +105 -19
- package/scripts/doctor/doctor-consumer.ts +3 -5
- package/scripts/doctor/doctor-io.ts +9 -13
- package/scripts/document/backlogrun-parent-read-set.ts +12 -1
- package/scripts/document/capped-print.ts +74 -0
- package/scripts/document/document-byte-budget.ts +1 -0
- package/scripts/document/document-section-cli.ts +4 -3
- package/scripts/document/entry-read-budget.ts +13 -7
- package/scripts/document/entry-read-set.ts +32 -8
- package/scripts/document/lane-child-read-set.ts +7 -0
- package/scripts/document/read-set-point-of-use.ts +8 -3
- package/scripts/document/read-set-trim.ts +3 -1
- package/scripts/epic/epic-audit.ts +3 -18
- package/scripts/gate/scoped-green.ts +25 -14
- package/scripts/gh/git-gh-issue-list.ts +12 -11
- package/scripts/gh/git-gh-issue-write.ts +4 -1
- package/scripts/gh/git-gh-pr-auto-merge.ts +4 -1
- package/scripts/git/git-command.ts +6 -1
- package/scripts/git/git-spawn-sync.ts +65 -0
- package/scripts/git/git-spawn.ts +5 -4
- package/scripts/git/stash/stash-pop-args.ts +1 -6
- package/scripts/init/claude-kit-only-settings.ts +29 -0
- package/scripts/init/init-copy-content.ts +8 -2
- package/scripts/init/init-file-action.ts +54 -0
- package/scripts/init/init-logic-migrate.ts +35 -17
- package/scripts/init/init-logic-secretlint.ts +1 -1
- package/scripts/init/init-logic.ts +6 -22
- package/scripts/init/init.ts +11 -68
- package/scripts/init/initial-commit.ts +24 -0
- package/scripts/init/kit-written-paths.ts +2 -0
- package/scripts/init/project-profile.ts +3 -6
- package/scripts/init/start-exec.ts +16 -3
- package/scripts/init/start-steps.ts +10 -13
- package/scripts/issue/defect-rate-cli.ts +61 -20
- package/scripts/issue/issue-backlinks.ts +7 -14
- package/scripts/issue/issue-cite.ts +12 -6
- package/scripts/issue/issue-file.ts +5 -12
- package/scripts/issue/issue-read-cli.ts +1 -0
- package/scripts/issue/issue-report-failures.ts +6 -2
- package/scripts/issue/issue-state-cli.ts +1 -1
- package/scripts/issue/markdown-section.ts +21 -6
- package/scripts/issue/measure-rerun-cli.ts +73 -14
- package/scripts/josh/josh-commands-lane.ts +1 -1
- package/scripts/josh/josh-commands-project.ts +8 -1
- package/scripts/josh/josh-commands-workflow.ts +1 -1
- package/scripts/josh/run-github-prerequisite.ts +3 -6
- package/scripts/lane/lane-await.ts +89 -15
- package/scripts/lane/lane-cli.ts +11 -13
- package/scripts/lane/lane-close.ts +4 -1
- package/scripts/lane/lane-launch-cli.ts +10 -16
- package/scripts/lane/openai-review-broker.ts +4 -1
- package/scripts/lib/cli-flags.ts +31 -2
- package/scripts/lib/error-message.ts +12 -1
- package/scripts/lines/line-targets.ts +0 -11
- package/scripts/lines/lines-command.ts +5 -3
- package/scripts/propagate/propagate-git.ts +3 -8
- package/scripts/refactor/refactor-scan.ts +2 -1
- package/scripts/registry-migration/migrate-logic.ts +28 -19
- package/scripts/registry-migration/migrate.ts +6 -5
- package/scripts/release/github-release-cli.ts +14 -3
- package/scripts/release/github-release-environment.ts +1 -0
- package/scripts/release/github-release.ts +103 -17
- package/scripts/release/release-publish.ts +36 -2
- package/scripts/release/release-worktree.ts +97 -2
- package/scripts/review/review-checkout.ts +3 -2
- package/scripts/review/review-record-cli.ts +9 -16
- package/scripts/rules/early-heartbeat.ts +4 -3
- package/scripts/rules/lane-park.ts +9 -0
- package/scripts/rules/stop-rules.ts +7 -4
- package/scripts/run/run-carry-args.ts +8 -12
- package/scripts/run/run-carry-cli.ts +3 -1
- package/scripts/run/run-carry-conversation.ts +94 -0
- package/scripts/run/run-carry-reclaim.ts +37 -0
- package/scripts/run/run-carry.ts +21 -5
- package/scripts/run/run-cut-args.ts +1 -6
- package/scripts/run/run-ending-cli.ts +1 -5
- package/scripts/run/run-entry-cli.ts +40 -9
- package/scripts/run/run-entry-stop.ts +104 -0
- package/scripts/run/run-event-stream-emit.ts +7 -2
- package/scripts/run/run-hold.ts +4 -1
- package/scripts/run/run-liveness-cli.ts +1 -5
- package/scripts/run/run-merge-cli.ts +2 -10
- package/scripts/run/run-merge-steps.ts +4 -1
- package/scripts/run/run-parent-cut-hook.ts +4 -1
- package/scripts/run/run-prep-cli.ts +4 -1
- package/scripts/run/run-progress-args.ts +20 -4
- package/scripts/run/run-progress-cli.ts +31 -29
- package/scripts/run/run-progress-clock.ts +4 -1
- package/scripts/run/run-ship-cli.ts +8 -16
- package/scripts/run/run-ship-probe.ts +4 -1
- package/scripts/run/run-status-cli.ts +2 -15
- package/scripts/run/run-tidy-lanes.ts +4 -1
- package/scripts/run/run-wake-cli.ts +2 -10
- package/scripts/run/run-watcher-guard.ts +9 -5
- package/scripts/safe-chain/pack-hook.ts +92 -0
- package/scripts/sync/sync-ai-files.ts +4 -0
- package/scripts/test/test-declared-changed.ts +13 -16
- package/scripts/test/test-declared.ts +3 -7
- package/scripts/test/test-related.ts +1 -6
- package/scripts/test/test-unit-guard.ts +21 -7
- package/scripts/time-runtime/time-spans.ts +4 -0
- package/scripts/version/version-commands.ts +11 -1
- package/templates/CLAUDE.basic.md +6 -6
- package/templates/workflows/ci.yml +16 -82
- package/templates/workflows/dependabot-auto-merge.yml +1 -0
- package/templates/workflows/github-release.yml +14 -28
- package/tsconfig.sonar.json +4 -22
|
@@ -55,10 +55,23 @@ const CONTENT_READ_LABELS: ReadonlySet<string> = new Set(
|
|
|
55
55
|
CONTENT_READ_COMMANDS.map((command) => time_shell.bash_label(command)),
|
|
56
56
|
)
|
|
57
57
|
|
|
58
|
+
// **The searches carry no file text, so they are counted in turns rather than files**
|
|
59
|
+
// (joshuafolkken/kit#3139). The file count above never saw a `grep`, yet searches were the commonest
|
|
60
|
+
// exploration turn a lane took — 457 of them in one day, each re-reading the main line's whole context.
|
|
61
|
+
// What a search costs is the turn, so a turn is the unit: parallel searches in one turn count once.
|
|
62
|
+
const SEARCH_COMMANDS: ReadonlyArray<string> = ['fd', 'find', 'grep', 'rg']
|
|
63
|
+
const SEARCH_LABELS: ReadonlySet<string> = new Set(
|
|
64
|
+
SEARCH_COMMANDS.map((command) => time_shell.bash_label(command)),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
// The unit the refusal sends the reading to (joshuafolkken/kit#3139): `.claude/agents/investigator.md`,
|
|
68
|
+
// the parent's model at a lowered effort. A consumer receives it through the plugin as `kit:investigator`.
|
|
69
|
+
const INVESTIGATOR_AGENT = 'investigator'
|
|
70
|
+
|
|
58
71
|
// What the refusal tells the model. It has to name the count, the command and the return shape,
|
|
59
72
|
// because the deny reason is the only text that reaches the model — a run that is refused and told
|
|
60
73
|
// nothing reads it as a broken tool.
|
|
61
|
-
const REASON = `⛔ investigation: ${String(delegation_policy.INVESTIGATION_FILE_THRESHOLD)} files read and not edited since the last delegated unit, so the reading from here goes to a unit of its own. Ask \`pnpm josh delegate investigation\`, then
|
|
74
|
+
const REASON = `⛔ investigation: ${String(delegation_policy.INVESTIGATION_FILE_THRESHOLD)} files read and not edited, or ${String(delegation_policy.INVESTIGATION_SEARCH_TURN_THRESHOLD)} search turns, since the last delegated unit, so the reading from here goes to a unit of its own (a successful write also starts the search count over). Ask \`pnpm josh delegate investigation\`, then dispatch the \`${INVESTIGATOR_AGENT}\` agent (\`kit:${INVESTIGATOR_AGENT}\` in a consumer) with a brief of what the main line has already concluded and what is left to find out; it returns the conclusion plus its \`file:line\` citations, never the file text. Keep a read in the main line only where this run will edit that file — an \`Edit\` cannot be issued against text you do not hold. This run's own instructions and the harness's own session files — a backgrounded call's output, a persisted tool result, this session's scratchpad — never count toward the files, so they are not what took the file count here; every read-only search turn counts, whatever it names. The rule is \`.claude/skills/workflow-commands/SKILL.md\` → §2b, "The pre-implementation reading".`
|
|
62
75
|
|
|
63
76
|
interface ReadTally {
|
|
64
77
|
// The files read and not since edited, resolved so the two ways a target reaches here compare.
|
|
@@ -74,6 +87,10 @@ interface ReadTally {
|
|
|
74
87
|
// The first instant the window covers. A refusal recorded before it cannot be checked against a
|
|
75
88
|
// delegation any more, because the delegation that would re-arm it has scrolled out.
|
|
76
89
|
window_start_ms: number
|
|
90
|
+
// The search turns since the last delegation or successful write, and how many of them came after
|
|
91
|
+
// the instant asked about — the search-side `pending` / `pending_since` (joshuafolkken/kit#3139).
|
|
92
|
+
searches: number
|
|
93
|
+
searches_since: number
|
|
77
94
|
// The files this run has successfully edited within the window, resolved (joshuafolkken/kit#1840).
|
|
78
95
|
// A re-read of one of them is a read of a file the run edited, which §2b keeps in the main line — so
|
|
79
96
|
// it is excluded from the count and from a call's projected growth rather than counted as fresh
|
|
@@ -279,18 +296,65 @@ function add_all(
|
|
|
279
296
|
// the edit's own span, so a read issued *after* the edit was added and stayed. `sed -i`, `MultiEdit`
|
|
280
297
|
// and `NotebookEdit` need no special case any more — each is a write, so its target is in `edited` and
|
|
281
298
|
// never enters the read count.
|
|
282
|
-
|
|
299
|
+
interface Accumulation {
|
|
300
|
+
// Read files, keyed by resolved path, valued by the instant each entered.
|
|
301
|
+
pending: Map<string, number>
|
|
302
|
+
// Search turns, keyed by message id so a turn's parallel searches count once, valued the same way.
|
|
303
|
+
searches: Map<string, number>
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function is_read_only_search(span: Span): boolean {
|
|
307
|
+
return SEARCH_LABELS.has(span.label) && span.is_bundleable && !span.may_write
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// **A span with no message id belongs to no known turn, so it is a turn of its own** — the reading every
|
|
311
|
+
// other turn counter gives `NO_MESSAGE_ID` (`time-round-trips.ts`, `time-bundles.ts`). Keyed by the
|
|
312
|
+
// id, every untagged search would collapse into one entry and the count would never reach its threshold.
|
|
313
|
+
function search_turn_key(searches: ReadonlyMap<string, number>, span: Span): string {
|
|
314
|
+
if (span.message_id !== time_spans.NO_MESSAGE_ID) return span.message_id
|
|
315
|
+
|
|
316
|
+
return `${time_spans.NO_MESSAGE_ID}#${String(searches.size)}`
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// **A successful write ends the streak.** The measurement behind the threshold counted searches since
|
|
320
|
+
// the last edit or delegation: a run that has started writing is implementing, and the searches that
|
|
321
|
+
// follow locate the next edit rather than continue an investigation.
|
|
322
|
+
//
|
|
323
|
+
// **Every read-only search counts, whatever it names** (joshuafolkken/kit#3139). The file count can
|
|
324
|
+
// exempt the run's own instructions because a read names each file it reads; a search does not — the
|
|
325
|
+
// targets drop a bare directory, so `grep -rn foo CLAUDE.md scripts` looked like a search of
|
|
326
|
+
// `CLAUDE.md` alone and an exemption let the whole sweep through. `is_bundleable` without `may_write`
|
|
327
|
+
// is the read-only test `is_search_call` asks at the call, so the transcript never counts a search the
|
|
328
|
+
// guard could not have refused.
|
|
329
|
+
function apply_search(searches: Map<string, number>, span: Span): void {
|
|
330
|
+
if (span.is_writing) {
|
|
331
|
+
searches.clear()
|
|
332
|
+
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const turn = search_turn_key(searches, span)
|
|
337
|
+
|
|
338
|
+
if (is_read_only_search(span) && !searches.has(turn)) searches.set(turn, span.ended_ms)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function apply_span(accumulation: Accumulation, span: Span, edited: ReadonlySet<string>): void {
|
|
283
342
|
if (DELEGATION_TOOLS.has(span.label)) {
|
|
284
|
-
pending.clear()
|
|
343
|
+
accumulation.pending.clear()
|
|
344
|
+
accumulation.searches.clear()
|
|
285
345
|
|
|
286
346
|
return
|
|
287
347
|
}
|
|
288
348
|
|
|
289
|
-
if (
|
|
349
|
+
if (is_failed_outcome(span)) return
|
|
350
|
+
|
|
351
|
+
apply_search(accumulation.searches, span)
|
|
352
|
+
|
|
353
|
+
if (!is_content_read(span.label)) return
|
|
290
354
|
|
|
291
355
|
const fresh = subject_targets(span.targets).filter((target) => !edited.has(target))
|
|
292
356
|
|
|
293
|
-
add_all(pending, fresh, span.ended_ms)
|
|
357
|
+
add_all(accumulation.pending, fresh, span.ended_ms)
|
|
294
358
|
}
|
|
295
359
|
|
|
296
360
|
function last_delegation_ms(spans: ReadonlyArray<Span>): number {
|
|
@@ -307,15 +371,17 @@ function pending_after(pending: ReadonlyMap<string, number>, since_ms: number):
|
|
|
307
371
|
function tally_of(text: string, since_ms: number = hook_decision.NEVER_MS): ReadTally {
|
|
308
372
|
const { spans, started_ms } = time_spans.parse_timeline(text)
|
|
309
373
|
const edited = edited_files(spans)
|
|
310
|
-
const
|
|
374
|
+
const accumulation: Accumulation = { pending: new Map(), searches: new Map() }
|
|
311
375
|
|
|
312
|
-
for (const span of spans) apply_span(
|
|
376
|
+
for (const span of spans) apply_span(accumulation, span, edited)
|
|
313
377
|
|
|
314
378
|
return {
|
|
315
|
-
pending: [...pending.keys()],
|
|
316
|
-
pending_since: pending_after(pending, since_ms),
|
|
379
|
+
pending: [...accumulation.pending.keys()],
|
|
380
|
+
pending_since: pending_after(accumulation.pending, since_ms),
|
|
317
381
|
reset_ms: last_delegation_ms(spans),
|
|
318
382
|
window_start_ms: started_ms,
|
|
383
|
+
searches: accumulation.searches.size,
|
|
384
|
+
searches_since: pending_after(accumulation.searches, since_ms),
|
|
319
385
|
edited,
|
|
320
386
|
}
|
|
321
387
|
}
|
|
@@ -347,6 +413,11 @@ function is_at_threshold(pending_count: number): boolean {
|
|
|
347
413
|
return pending_count + 1 >= delegation_policy.INVESTIGATION_FILE_THRESHOLD
|
|
348
414
|
}
|
|
349
415
|
|
|
416
|
+
// The search turn that takes the count up to its threshold is the boundary, as above.
|
|
417
|
+
function is_at_search_threshold(search_count: number): boolean {
|
|
418
|
+
return search_count + 1 >= delegation_policy.INVESTIGATION_SEARCH_TURN_THRESHOLD
|
|
419
|
+
}
|
|
420
|
+
|
|
350
421
|
// **One refusal per accumulation, re-armed by a delegation and by nothing else.** A run that reads on
|
|
351
422
|
// regardless is not refused again, so a false positive — three files the run was about to edit — costs
|
|
352
423
|
// one round trip rather than wedging the run. And a run that *does* delegate is refused again as soon
|
|
@@ -386,7 +457,7 @@ function is_rearmed(tally: ReadTally, refused_at_ms: number): boolean {
|
|
|
386
457
|
return true
|
|
387
458
|
}
|
|
388
459
|
|
|
389
|
-
return is_at_threshold(tally.pending_since)
|
|
460
|
+
return is_at_threshold(tally.pending_since) || is_at_search_threshold(tally.searches_since)
|
|
390
461
|
}
|
|
391
462
|
|
|
392
463
|
// A transcript under a session's `subagents/` directory is a delegated unit's. The segment is
|
|
@@ -403,10 +474,19 @@ function is_unit_run(run: GuardRun | undefined): boolean {
|
|
|
403
474
|
return run !== undefined && is_unit_transcript(run.transcript)
|
|
404
475
|
}
|
|
405
476
|
|
|
406
|
-
function
|
|
477
|
+
function is_read_only_command(call: GuardedCall, labels: ReadonlySet<string>): boolean {
|
|
478
|
+
if (call.name !== cost_blocks.BASH_TOOL) return false
|
|
479
|
+
|
|
407
480
|
const label = time_shell.bash_label(time_shell.bash_command(call.input))
|
|
408
481
|
|
|
409
|
-
return
|
|
482
|
+
return labels.has(label) && time_batch_guard.is_read_only_call(call)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// **A search is refusable without a subject target**, unlike a read: `grep -rn foo scripts` names a
|
|
486
|
+
// bare directory, which `call_facts` deliberately does not take as a target, and such a sweep is exactly
|
|
487
|
+
// the search this count exists for.
|
|
488
|
+
function is_search_call(call: GuardedCall): boolean {
|
|
489
|
+
return is_read_only_command(call, SEARCH_LABELS)
|
|
410
490
|
}
|
|
411
491
|
|
|
412
492
|
// **A `Read` is always safe to refuse; a `Bash` line is refused only where it writes nothing.** This
|
|
@@ -427,10 +507,20 @@ function is_refusable_command(call: GuardedCall): boolean {
|
|
|
427
507
|
// rule being enforced — and a call whose targets could not be read at all, which cannot move the count
|
|
428
508
|
// and so has nothing to be refused for.
|
|
429
509
|
function is_refusable_call(call: GuardedCall): boolean {
|
|
510
|
+
if (is_search_call(call)) return true
|
|
430
511
|
if (call_targets(call).length === 0) return false
|
|
431
512
|
if (READ_TOOLS.has(call.name)) return true
|
|
432
513
|
|
|
433
|
-
return call
|
|
514
|
+
return is_read_only_command(call, CONTENT_READ_LABELS)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// A search call grows the search count by its own turn; a read grows the file count only by a file the
|
|
518
|
+
// count does not already hold.
|
|
519
|
+
function is_growing_past_threshold(tally: ReadTally, call: GuardedCall): boolean {
|
|
520
|
+
if (is_search_call(call)) return is_at_search_threshold(tally.searches)
|
|
521
|
+
if (!is_at_threshold(tally.pending.length)) return false
|
|
522
|
+
|
|
523
|
+
return projected_count(tally.pending, call, tally.edited) > tally.pending.length
|
|
434
524
|
}
|
|
435
525
|
|
|
436
526
|
function should_block(
|
|
@@ -446,15 +536,11 @@ function should_block(
|
|
|
446
536
|
|
|
447
537
|
const tally = tally_of(text, refused_at_ms)
|
|
448
538
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
return (
|
|
452
|
-
projected_count(tally.pending, call, tally.edited) > tally.pending.length &&
|
|
453
|
-
is_rearmed(tally, refused_at_ms)
|
|
454
|
-
)
|
|
539
|
+
return is_growing_past_threshold(tally, call) && is_rearmed(tally, refused_at_ms)
|
|
455
540
|
}
|
|
456
541
|
|
|
457
542
|
const investigation_reads = {
|
|
543
|
+
INVESTIGATOR_AGENT,
|
|
458
544
|
READ_TOOLS,
|
|
459
545
|
REASON,
|
|
460
546
|
is_at_threshold,
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
+
import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
|
|
3
4
|
import { json_value } from '#scripts/lib/json-value'
|
|
4
5
|
import { file_reader } from '#scripts/lib/read-file'
|
|
5
|
-
import { read_spawn_stdout } from '#scripts/lib/spawn-exit'
|
|
6
6
|
import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
|
|
7
|
-
import { execaSync } from 'execa'
|
|
8
7
|
import { z } from 'zod'
|
|
9
8
|
|
|
10
9
|
// The consumer-setup checks `josh doctor` runs in a repository that installs kit (joshuafolkken/kit#1930).
|
|
@@ -77,11 +76,9 @@ function is_plugin_declared(root: string): boolean {
|
|
|
77
76
|
// The value of `core.hooksPath`, or undefined when it is unset. A set value is what stops lefthook
|
|
78
77
|
// from installing kit's git hooks — the failure is silent but for one stderr line.
|
|
79
78
|
function configured_hooks_path(root: string): string | undefined {
|
|
80
|
-
const
|
|
81
|
-
reject: false,
|
|
79
|
+
const value = git_spawn_sync.read(['-C', root, 'config', '--get', 'core.hooksPath'], {
|
|
82
80
|
timeout: GIT_TIMEOUT_MS,
|
|
83
81
|
})
|
|
84
|
-
const value = read_spawn_stdout(result).trim()
|
|
85
82
|
|
|
86
83
|
return value === '' ? undefined : value
|
|
87
84
|
}
|
|
@@ -159,6 +156,7 @@ function report_consumer_setup(root: string): void {
|
|
|
159
156
|
const doctor_consumer = {
|
|
160
157
|
claude_md_line,
|
|
161
158
|
claude_md_state,
|
|
159
|
+
configured_hooks_path,
|
|
162
160
|
hooks_path_line,
|
|
163
161
|
is_kit_consumer,
|
|
164
162
|
is_plugin_declared,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
|
|
4
|
+
import { read_spawn_stdout } from '#scripts/lib/spawn-exit'
|
|
4
5
|
import { execaSync } from 'execa'
|
|
5
6
|
import { doctor_logic } from './doctor-logic'
|
|
6
7
|
|
|
@@ -69,18 +70,13 @@ function classify_git_failure(exit_code: number | undefined, stderr: string): Gi
|
|
|
69
70
|
// caller's check, because reporting an unknown as a clean result is the false all-clear
|
|
70
71
|
// joshuafolkken/kit#805 exists to remove. See the two classifiers above for what proves what.
|
|
71
72
|
function resolve_git_top_level(): GitTopLevel {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
if (result.exitCode === 0) return classify_top_level(read_spawn_stdout(result).trim())
|
|
82
|
-
|
|
83
|
-
return classify_git_failure(result.exitCode, read_spawn_stderr(result))
|
|
73
|
+
// The C locale `toplevel` asks under is what keeps the stderr match below from failing on a
|
|
74
|
+
// translated message and bringing the spurious warning back.
|
|
75
|
+
const result = git_spawn_sync.toplevel(GIT_TIMEOUT_MS)
|
|
76
|
+
|
|
77
|
+
if (result.exit_code === 0) return classify_top_level(result.stdout.trim())
|
|
78
|
+
|
|
79
|
+
return classify_git_failure(result.exit_code, result.stderr)
|
|
84
80
|
}
|
|
85
81
|
|
|
86
82
|
// Whether one line is the setting itself rather than prose about it. kit's own template explains the
|
|
@@ -34,15 +34,26 @@ const UNUSED_SKILL_SECTIONS: ReadonlyArray<string> = [
|
|
|
34
34
|
// the park — so it drops none. Named empty so the shared trim reads the same shape as the worker's.
|
|
35
35
|
const SKIPPED_POINT_OF_USE: ReadonlySet<string> = new Set<string>()
|
|
36
36
|
|
|
37
|
+
// **The parent runs the end-of-run retrospective** (`SKILL.md` §2j) when `run:step` prints it, but its
|
|
38
|
+
// manifest names `retrospective.md` only through `backlogrun-steps.md` — one hop further than the
|
|
39
|
+
// derivation follows — so the parent's reach names it (joshuafolkken/kit#3078).
|
|
40
|
+
const REACHED_POINT_OF_USE: ReadonlySet<string> = new Set(['retrospective.md'])
|
|
41
|
+
|
|
37
42
|
function costed(root: string): ReadSetCost {
|
|
38
43
|
return read_set_trim.costed(root, {
|
|
39
44
|
base_entry: BACKLOGRUN,
|
|
40
45
|
label: BACKLOGRUN,
|
|
41
46
|
unused_skill_sections: UNUSED_SKILL_SECTIONS,
|
|
42
47
|
skipped_point_of_use: SKIPPED_POINT_OF_USE,
|
|
48
|
+
reached_point_of_use: REACHED_POINT_OF_USE,
|
|
43
49
|
})
|
|
44
50
|
}
|
|
45
51
|
|
|
46
|
-
const backlogrun_parent_read_set = {
|
|
52
|
+
const backlogrun_parent_read_set = {
|
|
53
|
+
BACKLOGRUN,
|
|
54
|
+
REACHED_POINT_OF_USE,
|
|
55
|
+
UNUSED_SKILL_SECTIONS,
|
|
56
|
+
costed,
|
|
57
|
+
}
|
|
47
58
|
|
|
48
59
|
export { backlogrun_parent_read_set }
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { PLATFORM_TEMP_ROOT } from '#scripts/josh/platform-temporary'
|
|
4
|
+
import { review_diff_parts } from '#scripts/review/review-diff-parts'
|
|
5
|
+
import { bash_output_cap_reader } from './bash-output-cap'
|
|
6
|
+
|
|
7
|
+
// Prints a command's output whole when it fits under the Bash output cap, and as part files when it
|
|
8
|
+
// does not (joshuafolkken/kit#3143). Past `BASH_MAX_OUTPUT_LENGTH` Claude Code saves the output and
|
|
9
|
+
// hands back a 2 KB preview, and the run spends a turn reading the saved file — 224 overflows and 132
|
|
10
|
+
// re-reads over one day's sessions, `doc:section` and `issue:read` the two most frequent `josh` sources.
|
|
11
|
+
//
|
|
12
|
+
// **The cut is #2963's, not a second one.** The parts come from `review_diff_parts.part_texts`, so each
|
|
13
|
+
// file — header included — fits under the cap and the bodies concatenate back to exactly the output.
|
|
14
|
+
// A reader opens them all with the Read tool in one turn, which is the turn the preview used to cost.
|
|
15
|
+
//
|
|
16
|
+
// **A fresh directory per call**, unlike the review's per-checkout one: a run fetches several sections
|
|
17
|
+
// in one turn, and a shared directory replaced on every call would delete one call's parts while
|
|
18
|
+
// another call's listing still names them.
|
|
19
|
+
|
|
20
|
+
const DIRECTORY_PREFIX = 'josh-capped-print-'
|
|
21
|
+
const FILE_MODE = 0o600
|
|
22
|
+
const PART_SUFFIX = '.md'
|
|
23
|
+
const LINE_BREAK = '\n'
|
|
24
|
+
// What else shares the Bash result with this output: the launcher line, a script banner, a few
|
|
25
|
+
// stderr lines. Measured at about 80 characters for `pnpm josh doc:section` in kit, so 512 holds the
|
|
26
|
+
// longest heading or issue list with room for a consumer's banner.
|
|
27
|
+
const HEADROOM_CHARS = 512
|
|
28
|
+
|
|
29
|
+
function write_parts(label: string, text: string, cap: number): Array<string> {
|
|
30
|
+
const directory = mkdtempSync(path.join(PLATFORM_TEMP_ROOT, DIRECTORY_PREFIX))
|
|
31
|
+
|
|
32
|
+
return review_diff_parts.part_texts(label, text, cap).map((part, index) => {
|
|
33
|
+
const file = path.join(directory, `part-${String(index + 1)}${PART_SUFFIX}`)
|
|
34
|
+
|
|
35
|
+
writeFileSync(file, part, { flag: 'wx', mode: FILE_MODE })
|
|
36
|
+
|
|
37
|
+
return file
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function part_count(count: number): string {
|
|
42
|
+
return `written as ${String(count)} part file${count === 1 ? '' : 's'} under it`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function listing(text: string, cap: number, files: ReadonlyArray<string>): string {
|
|
46
|
+
const figures = `${text.length.toLocaleString('en-US')} > ${cap.toLocaleString('en-US')} characters`
|
|
47
|
+
const lead = `Over the Bash cap (${figures}) — ${part_count(files.length)}, together exactly the output. Read every part with the Read tool, in one turn, not cat:`
|
|
48
|
+
|
|
49
|
+
return [lead, ...files.map((file) => ` ${file}`)].join(LINE_BREAK)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// **The budget is the cap less a headroom, not the cap.** The Bash result carries more than this
|
|
53
|
+
// output — `pnpm josh`'s own launcher line, a consumer's script banner, the stderr lines naming a
|
|
54
|
+
// number that did not resolve — so a text measured against the bare cap would overflow by exactly
|
|
55
|
+
// those lines, and cost the re-read this module exists to remove.
|
|
56
|
+
function output_budget(root: string): number {
|
|
57
|
+
return Math.max(1, bash_output_cap_reader.bash_output_cap(root) - HEADROOM_CHARS)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function capped_print(text: string, label: string, root: string = process.cwd()): void {
|
|
61
|
+
const budget = output_budget(root)
|
|
62
|
+
|
|
63
|
+
if (text.length < budget) {
|
|
64
|
+
console.info(text)
|
|
65
|
+
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.info(listing(text, budget, write_parts(label, text, budget)))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const capped_output = { HEADROOM_CHARS, capped_print }
|
|
73
|
+
|
|
74
|
+
export { capped_output }
|
|
@@ -88,6 +88,7 @@ const DOCUMENT_BYTE_BUDGET: ReadonlyArray<DocumentBudget> = [
|
|
|
88
88
|
{ path: 'prompts/collaboration-workflow.md', bytes: 8192 },
|
|
89
89
|
{ path: 'prompts/collaboration-workflow/file-edits.md', bytes: 12_288 },
|
|
90
90
|
{ path: 'prompts/collaboration-workflow/gh-rest.md', bytes: 8192 },
|
|
91
|
+
{ path: 'prompts/collaboration-workflow/glossary.md', bytes: 4096 },
|
|
91
92
|
{ path: 'prompts/collaboration-workflow/issue-citation.md', bytes: 8192 },
|
|
92
93
|
// #2662 adds the feature and compatibility declarations to the canonical filing procedure.
|
|
93
94
|
{ path: 'prompts/collaboration-workflow/issue-template.md', bytes: 28_672 },
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { capped_output } from './capped-print'
|
|
5
6
|
import { document_section } from './document-section'
|
|
6
7
|
import { entry_read_set } from './entry-read-set'
|
|
7
8
|
|
|
@@ -56,12 +57,12 @@ function report_missing(markdown: string, heading: string): number {
|
|
|
56
57
|
return FAILURE_EXIT_CODE
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
function print_section(markdown: string, heading: string): number {
|
|
60
|
+
function print_section(markdown: string, heading: string, root: string): number {
|
|
60
61
|
const found = document_section.section(markdown, heading)
|
|
61
62
|
|
|
62
63
|
if (found === undefined) return report_missing(markdown, heading)
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
capped_output.capped_print(found.text, heading, root)
|
|
65
66
|
|
|
66
67
|
return 0
|
|
67
68
|
}
|
|
@@ -87,7 +88,7 @@ function run(argv: ReadonlyArray<string>, root: string = process.cwd()): number
|
|
|
87
88
|
|
|
88
89
|
const markdown = read_named(name, root)
|
|
89
90
|
|
|
90
|
-
return markdown === undefined ? FAILURE_EXIT_CODE : print_section(markdown, heading)
|
|
91
|
+
return markdown === undefined ? FAILURE_EXIT_CODE : print_section(markdown, heading, root)
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
function main(argv: ReadonlyArray<string>): void {
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
//
|
|
4
4
|
// **The per-document ceiling was a proxy for a run's cost, not the cost.** A run does not pay "one
|
|
5
5
|
// document's size"; it pays the total its entry reads — the mandated skill files, the sections they
|
|
6
|
-
// point out to, the point-of-use documents
|
|
7
|
-
// carries. `read:set`
|
|
6
|
+
// point out to, the point-of-use documents that entry reaches later, and the resident `CLAUDE.md` every
|
|
7
|
+
// request carries. `read:set`'s `total read` is that sum less `CLAUDE.md`, which `entry_total_bytes`
|
|
8
|
+
// adds back (`RESIDENT_BASE`), so this budgets the whole directly: the entry total is
|
|
8
9
|
// the primary ceiling, and the per-document ceiling (`document-byte-budget.ts`) falls back to the
|
|
9
10
|
// documents no entry reads (`document-reachability.ts` draws the line). A document an entry reads is
|
|
10
11
|
// held by the entry total and carries no per-document ceiling, so neither budget holds it twice.
|
|
@@ -86,11 +87,16 @@ const ENTRY_READ_BUDGET: ReadonlyArray<EntryBudget> = [
|
|
|
86
87
|
// `wip-cap.md`, so its ceiling stays.
|
|
87
88
|
// joshuafolkken/kit#3023 added the `prrun` keyword to `SKILL.md` and `CLAUDE.md`, tipping backlogrun
|
|
88
89
|
// one block over; `prrun` reads `fullrun`'s set plus its own short manifest.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
// Lowered in joshuafolkken/kit#3078 — a correction of an over-count, not a trim: every entry was
|
|
91
|
+
// charged for every global point-of-use document, so `kickoff` paid for `followup.md` and the
|
|
92
|
+
// `backlogrun-*` phase documents it never opens. Each total now counts only what its path reaches.
|
|
93
|
+
// joshuafolkken/kit#3077 trimmed non-resident rule bodies from `CLAUDE.md`, lowering fullrun, prrun
|
|
94
|
+
// and backlogrun one block each.
|
|
95
|
+
{ entry: 'kickoff', bytes: 65_536 },
|
|
96
|
+
{ entry: 'fullrun', bytes: 126_976 },
|
|
97
|
+
{ entry: 'halfrun', bytes: 106_496 },
|
|
98
|
+
{ entry: 'prrun', bytes: 131_072 },
|
|
99
|
+
{ entry: 'backlogrun', bytes: 212_992 },
|
|
94
100
|
{ entry: LANE_CHILD, bytes: 77_824 },
|
|
95
101
|
]
|
|
96
102
|
|
|
@@ -420,16 +420,40 @@ function point_of_use_cost(
|
|
|
420
420
|
return { file, heading: '', cost: cost_of(read_document(root, file)), is_resolved: true }
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
423
|
+
// **What an entry's path names: its own manifests, and the §1 trigger rows that name the entry**
|
|
424
|
+
// (joshuafolkken/kit#3078). `SKILL.md`'s other text is left out for the reason `sections_for` gives —
|
|
425
|
+
// §2 names every document, so counting it would charge a `kickoff` for `followup.md`. One hop only:
|
|
426
|
+
// the point-of-use documents cite one another, and following them would reach the whole set again.
|
|
427
|
+
// `also` is what a role reaches beside its base entry's path (`read-set-trim.ts`).
|
|
428
|
+
function cited_by(
|
|
429
|
+
root: string,
|
|
430
|
+
entry: string,
|
|
431
|
+
table_text: string,
|
|
432
|
+
also: ReadonlySet<string>,
|
|
433
|
+
): Set<string> {
|
|
434
|
+
const manifests = files_for(root, entry).filter((file) => file !== SKILL_FILE)
|
|
435
|
+
const rows = table_text
|
|
436
|
+
.split('\n')
|
|
437
|
+
.filter((line) => line.startsWith('|') && line.includes(`\`${entry}\``))
|
|
438
|
+
const texts = [...manifests.map((file) => read_document(root, file)), ...rows]
|
|
439
|
+
|
|
440
|
+
return new Set([...also, ...texts.flatMap((text) => names_in(text))])
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function point_of_use_costs(
|
|
444
|
+
root: string,
|
|
445
|
+
entry: string,
|
|
446
|
+
also: ReadonlySet<string>,
|
|
447
|
+
): Array<SectionCost> {
|
|
448
|
+
const table_text =
|
|
449
|
+
document_section.section(read_document(root, SKILL_FILE), TABLE_SECTION)?.text ?? ''
|
|
450
|
+
const references = all_matches(table_text, SECTION_REFERENCE).map((match) => to_reference(match))
|
|
451
|
+
const cited = cited_by(root, entry, table_text, also)
|
|
428
452
|
|
|
429
|
-
return point_of_use_files(entry).map((file) => point_of_use_cost(root, file, references))
|
|
453
|
+
return point_of_use_files(entry, cited).map((file) => point_of_use_cost(root, file, references))
|
|
430
454
|
}
|
|
431
455
|
|
|
432
|
-
function costed(root: string, entry: string): ReadSetCost {
|
|
456
|
+
function costed(root: string, entry: string, also: ReadonlySet<string> = new Set()): ReadSetCost {
|
|
433
457
|
const { files, sections } = read_set(root, entry)
|
|
434
458
|
const own_costs = file_costs(root, files)
|
|
435
459
|
const section_costs = sections.map((reference) => section_cost(root, reference))
|
|
@@ -439,7 +463,7 @@ function costed(root: string, entry: string): ReadSetCost {
|
|
|
439
463
|
entry,
|
|
440
464
|
files: own_costs,
|
|
441
465
|
sections: section_costs,
|
|
442
|
-
point_of_use: point_of_use_costs(root, entry),
|
|
466
|
+
point_of_use: point_of_use_costs(root, entry, also),
|
|
443
467
|
bash_output_cap: bash_output_cap_reader.bash_output_cap(root),
|
|
444
468
|
whole: total([own, referenced_cost(root, sections)]),
|
|
445
469
|
scoped: total([own, scoped_cost(root, sections)]),
|
|
@@ -64,17 +64,24 @@ const SKIPPED_POINT_OF_USE: ReadonlySet<string> = new Set([
|
|
|
64
64
|
'retrospective.md',
|
|
65
65
|
])
|
|
66
66
|
|
|
67
|
+
// **A child that parks reads `backlogrun-park.md`** (`SKILL.md` §1, "A lane child that parks"), a
|
|
68
|
+
// document a standalone `fullrun`'s path never names — so the child's reach adds it to its base
|
|
69
|
+
// entry's (joshuafolkken/kit#3078).
|
|
70
|
+
const REACHED_POINT_OF_USE: ReadonlySet<string> = new Set(['backlogrun-park.md'])
|
|
71
|
+
|
|
67
72
|
function costed(root: string): ReadSetCost {
|
|
68
73
|
return read_set_trim.costed(root, {
|
|
69
74
|
base_entry: FULLRUN,
|
|
70
75
|
label: LANE_CHILD,
|
|
71
76
|
unused_skill_sections: UNUSED_SKILL_SECTIONS,
|
|
72
77
|
skipped_point_of_use: SKIPPED_POINT_OF_USE,
|
|
78
|
+
reached_point_of_use: REACHED_POINT_OF_USE,
|
|
73
79
|
})
|
|
74
80
|
}
|
|
75
81
|
|
|
76
82
|
const lane_child_read_set = {
|
|
77
83
|
LANE_CHILD,
|
|
84
|
+
REACHED_POINT_OF_USE,
|
|
78
85
|
SKIPPED_POINT_OF_USE,
|
|
79
86
|
UNUSED_SKILL_SECTIONS,
|
|
80
87
|
costed,
|
|
@@ -75,9 +75,14 @@ function is_point_of_use(entry: string, file: string): boolean {
|
|
|
75
75
|
return (POINT_OF_USE_BY_ENTRY.get(entry) ?? NO_PER_ENTRY_FILES).has(file)
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
// **Only the global files the entry reaches, plus the ones this entry names in its own row**
|
|
79
|
+
// (joshuafolkken/kit#3078). `cited` is every document the entry's path names; a global file outside it
|
|
80
|
+
// is one this entry never opens — a `kickoff` never issues `followup` — so charging it made every
|
|
81
|
+
// entry's total the same figure and the per-entry ratchet measured nothing.
|
|
82
|
+
function point_of_use_files(entry: string, cited: ReadonlySet<string>): Array<string> {
|
|
83
|
+
const reached = [...POINT_OF_USE_FILES].filter((file) => cited.has(file))
|
|
84
|
+
|
|
85
|
+
return [...reached, ...(POINT_OF_USE_BY_ENTRY.get(entry) ?? NO_PER_ENTRY_FILES)]
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
const read_set_point_of_use = {
|
|
@@ -24,6 +24,8 @@ interface TrimSpec {
|
|
|
24
24
|
unused_skill_sections: ReadonlyArray<string>
|
|
25
25
|
// The point-of-use documents this role's parent owns; empty when the role owns them all.
|
|
26
26
|
skipped_point_of_use: ReadonlySet<string>
|
|
27
|
+
// The point-of-use documents this role reaches that its base entry's path does not name.
|
|
28
|
+
reached_point_of_use: ReadonlySet<string>
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
function subtract(left: Cost, right: Cost): Cost {
|
|
@@ -59,7 +61,7 @@ function reduce_skill(file: FileCost, saving: Cost): FileCost {
|
|
|
59
61
|
// **`whole` and `scoped` both carry the own-files cost identically**, so the `SKILL.md` saving lands
|
|
60
62
|
// on each by the same amount — there is no need to re-derive the referenced-section split.
|
|
61
63
|
function costed(root: string, spec: TrimSpec): ReadSetCost {
|
|
62
|
-
const base = entry_read_set.costed(root, spec.base_entry)
|
|
64
|
+
const base = entry_read_set.costed(root, spec.base_entry, spec.reached_point_of_use)
|
|
63
65
|
const saving = unused_skill_cost(root, spec.unused_skill_sections)
|
|
64
66
|
|
|
65
67
|
return {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { markdown_section } from '#scripts/issue/markdown-section'
|
|
1
2
|
import { epic_graph, type EpicChild, type IssueReference } from './epic-graph'
|
|
2
3
|
|
|
3
4
|
// Reading an epic's children against each other.
|
|
@@ -43,7 +44,6 @@ const ACCEPTANCE_HEADINGS: ReadonlySet<string> = new Set([
|
|
|
43
44
|
'## 受け入れ条件',
|
|
44
45
|
'## Acceptance criteria',
|
|
45
46
|
])
|
|
46
|
-
const HEADING_PREFIX = '## '
|
|
47
47
|
|
|
48
48
|
type FindingLevel = 'error' | 'warning'
|
|
49
49
|
|
|
@@ -169,26 +169,11 @@ function is_acceptance_heading(line: string): boolean {
|
|
|
169
169
|
return ACCEPTANCE_HEADINGS.has(line.trim())
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
return line.trimStart().startsWith(HEADING_PREFIX)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// The lines under the acceptance-criteria heading, up to the next `##`. Shaped like the other
|
|
177
|
-
// section readers in this repository, for the same reason: a regex spanning the block backtracks.
|
|
178
|
-
function section_lines(lines: ReadonlyArray<string>): Array<string> {
|
|
179
|
-
const start = lines.findIndex((line) => is_acceptance_heading(line))
|
|
180
|
-
if (start === -1) return []
|
|
181
|
-
const rest = lines.slice(start + 1)
|
|
182
|
-
const end = rest.findIndex((line) => is_heading(line))
|
|
183
|
-
|
|
184
|
-
return end === -1 ? rest : rest.slice(0, end)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// The acceptance-criteria section of a child's body, or an empty string.
|
|
172
|
+
// The acceptance-criteria section of a child's body, up to the next unfenced `##`, or an empty string.
|
|
188
173
|
function acceptance_section(body: string | undefined): string {
|
|
189
174
|
if (body === undefined) return ''
|
|
190
175
|
|
|
191
|
-
return
|
|
176
|
+
return markdown_section.section_lines_matching(body, is_acceptance_heading).join('\n')
|
|
192
177
|
}
|
|
193
178
|
|
|
194
179
|
// Everything `node` depends on, directly or through a chain. Walked iteratively so a graph that
|