@joshuafolkken/kit 1.978.0 → 1.998.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/backlogrun-child.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-steps.md +1 -1
- package/.claude/skills/workflow-commands/fullrun-steps.md +8 -8
- package/.claude/skills/workflow-commands/halfrun.md +4 -5
- 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/.github/workflows/sonar-qube.yml +12 -2
- package/CLAUDE.md +2 -2
- package/README.md +30 -21
- package/dist/CLAUDE.md +2 -2
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-ZYYSBNR6.js → chunk-4GDR2YTV.js} +4 -4
- package/dist/hooks/{chunk-2K52BRWV.js → chunk-56LGMJJV.js} +2 -2
- package/dist/hooks/{chunk-BNGGEY3Z.js → chunk-762BDXDU.js} +266 -327
- package/dist/hooks/{chunk-Q7LE77CO.js → chunk-7WAOP42A.js} +34 -30
- package/dist/hooks/{chunk-NXL3SBXY.js → chunk-A2H5I2VD.js} +2 -2
- package/dist/hooks/{chunk-CRGJTD6E.js → chunk-AWTFKKFW.js} +1 -1
- package/dist/hooks/{chunk-KQG4776P.js → chunk-GBJUERYK.js} +1 -1
- package/dist/hooks/{chunk-BVMSPG3M.js → chunk-KPZN42RT.js} +3 -3
- package/dist/hooks/{chunk-ZPLXLJ7O.js → chunk-PAXMLGSS.js} +42 -8
- package/dist/hooks/{chunk-F6OM6SIG.js → chunk-WMIR5SNX.js} +12 -12
- 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 +99 -27
- package/dist/josh.js +21 -6
- package/package.json +5 -1
- package/prettier/basic.d.ts +3 -0
- package/prettier/basic.js +10 -0
- package/prettier/index.js +2 -2
- package/prettier/static.js +3 -10
- package/prompts/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/prompts/sonar-hotspot-handling.md +6 -0
- package/prompts/testing-guide.md +1 -1
- package/scripts/agent/agent-diagnostics.ts +1 -0
- package/scripts/agent/agent-role-profile.ts +1 -1
- package/scripts/behavior/behavior-rules.ts +8 -1
- package/scripts/build/build-claude-md.ts +13 -4
- package/scripts/delegation/delegation-policy.ts +9 -9
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/gate/gate-steps.ts +1 -1
- package/scripts/gate/project-checks.ts +12 -11
- package/scripts/gate/type-check-command.ts +1 -1
- package/scripts/git/git-branch.ts +14 -3
- package/scripts/git/git-issue.ts +13 -5
- package/scripts/git/git-pr.ts +16 -10
- package/scripts/git/git-preflight.ts +138 -0
- package/scripts/git/telegram-notify.ts +21 -0
- package/scripts/hooks/stop-guard.ts +2 -0
- package/scripts/init/basic-path-migration.ts +24 -0
- package/scripts/init/distributed-paths.ts +22 -0
- package/scripts/init/init-actions.ts +27 -19
- package/scripts/init/init-ai-copy.ts +86 -28
- package/scripts/init/{init-static.ts → init-basic.ts} +6 -6
- package/scripts/init/init-bootstrap.ts +4 -3
- package/scripts/init/init-install.ts +1 -1
- package/scripts/init/init-logic-templates.ts +1 -1
- package/scripts/init/init-logic.ts +1 -1
- package/scripts/init/init.ts +41 -19
- package/scripts/init/kit-setup-state.ts +30 -0
- package/scripts/init/kit-written-paths.ts +33 -0
- package/scripts/init/project-profile-cli.ts +15 -1
- package/scripts/init/project-profile.ts +41 -12
- package/scripts/init/session-language-environment.ts +83 -0
- package/scripts/init/start-exec.ts +28 -0
- package/scripts/init/start-plan.ts +35 -13
- package/scripts/init/start-prompt.ts +22 -8
- package/scripts/init/start-setup-pr.ts +138 -0
- package/scripts/init/start-steps.ts +30 -30
- package/scripts/init/start.ts +7 -3
- package/scripts/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 +12 -4
- package/scripts/josh/josh-command-map.ts +1 -0
- package/scripts/josh/josh-commands-development.ts +3 -3
- package/scripts/josh/josh-commands-issue.ts +11 -0
- package/scripts/josh/josh-commands-project.ts +7 -3
- package/scripts/lint/cspell-cached.ts +3 -3
- package/scripts/lint/format.ts +1 -1
- package/scripts/lint/lint-parallel.ts +2 -2
- package/scripts/propagate/propagate-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/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 +12 -8
- package/scripts/rules/issue-citation.ts +30 -1
- package/scripts/rules/issue-fold.ts +4 -4
- package/scripts/rules/last-prompt.ts +79 -0
- 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 +8 -5
- package/scripts/safe-chain/ci-installer-pin.ts +19 -12
- package/scripts/safe-chain/project-config.ts +1 -1
- package/scripts/sync/sync-ai-files.ts +204 -0
- package/scripts/sync/sync.ts +48 -184
- package/scripts/test/josh-harness-environment.ts +1 -1
- package/scripts/test/pilot-files.ts +0 -2
- package/scripts/test/test-declared-changed.ts +1 -1
- package/scripts/test/test-declared-logic.ts +55 -14
- package/scripts/test/test-declared.ts +23 -11
- package/scripts/test/test-unit-guard.ts +6 -6
- package/scripts/time-runtime/time-shell.ts +36 -2
- package/scripts/version/fresh-metadata-cache.ts +36 -0
- package/scripts/version/latest-update.ts +2 -1
- package/scripts-ai/git-workflow.ts +10 -0
- package/templates/CLAUDE.basic.md +8 -0
- package/templates/workflows/ci.yml +27 -0
- 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
- /package/{templates/CLAUDE.static.md → dist/CLAUDE.basic.md} +0 -0
- /package/templates/{cursorrules.static → cursorrules.basic} +0 -0
- /package/templates/{pnpm-workspace.static.yaml → pnpm-workspace.basic.yaml} +0 -0
- /package/templates/{prettierignore.static → prettierignore.basic} +0 -0
|
@@ -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
|
|
@@ -126,6 +129,7 @@ const filing_cap = {
|
|
|
126
129
|
ROW,
|
|
127
130
|
current_turn,
|
|
128
131
|
decide,
|
|
132
|
+
is_prompt_line,
|
|
129
133
|
prior_filing_count,
|
|
130
134
|
turn_filing_count,
|
|
131
135
|
}
|
|
@@ -171,6 +171,28 @@ function bare_references(message: string): ReadonlyArray<string> {
|
|
|
171
171
|
return [...new Set(found)]
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// Every reference the prompt carries, each as written — `#N` or `owner/repo#N` — with none of the
|
|
175
|
+
// citation exclusions applied: a number pasted inside a fence or a quote is still the person's.
|
|
176
|
+
function prompt_references(prompt: string): ReadonlySet<string> {
|
|
177
|
+
const found = new Set<string>()
|
|
178
|
+
|
|
179
|
+
for (const match of prompt.matchAll(ISSUE_NUMBER)) {
|
|
180
|
+
found.add(repo_prefix(prompt, match.index) + match[0])
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return found
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// **A number the person's prompt already carried is quoted, not cited** (joshuafolkken/kit#2819). A
|
|
187
|
+
// pasted log from another repository holds its own `#1`…`#7`; `issue:cite` would link them to this
|
|
188
|
+
// repository's unrelated Issues, so following the refusal would make the reply wrong. Only the exact
|
|
189
|
+
// reference the prompt wrote is exempt, so a bare `#N` the run brought in itself is still refused.
|
|
190
|
+
function unquoted_references(message: string, prompt: string): ReadonlyArray<string> {
|
|
191
|
+
const quoted = prompt_references(prompt)
|
|
192
|
+
|
|
193
|
+
return bare_references(message).filter((reference) => !quoted.has(reference))
|
|
194
|
+
}
|
|
195
|
+
|
|
174
196
|
function has_bare_reference(message: string): boolean {
|
|
175
197
|
return bare_references(message).length > 0
|
|
176
198
|
}
|
|
@@ -237,6 +259,13 @@ function linkify(message: string, slug: string): string {
|
|
|
237
259
|
return out.join('\n')
|
|
238
260
|
}
|
|
239
261
|
|
|
240
|
-
const issue_citation = {
|
|
262
|
+
const issue_citation = {
|
|
263
|
+
bare_references,
|
|
264
|
+
cite_arguments,
|
|
265
|
+
has_bare_reference,
|
|
266
|
+
linkify,
|
|
267
|
+
prose_lines,
|
|
268
|
+
unquoted_references,
|
|
269
|
+
}
|
|
241
270
|
|
|
242
271
|
export { issue_citation }
|
|
@@ -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.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { json_value } from '#scripts/lib/json-value'
|
|
2
|
+
import { time_reported_failure } from '#scripts/time-runtime/time-reported-failure'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { filing_cap } from './filing-cap'
|
|
5
|
+
|
|
6
|
+
// The text of the person's last prompt on a transcript tail — what the stop guard's citation row
|
|
7
|
+
// reads to tell a `#N` the person quoted from one the run brought in itself (joshuafolkken/kit#2819).
|
|
8
|
+
//
|
|
9
|
+
// **A prompt is `filing_cap`'s prompt line, minus what the harness wrote there.** A skill's body is a
|
|
10
|
+
// `user` line with no tool result too, but it carries `isMeta`; a background task's completion notice
|
|
11
|
+
// and a subagent's hand-back carry an `origin` whose `kind` is not `human`. Each is the harness
|
|
12
|
+
// speaking, not the person — and a notice can name the very Issue the run filed — so only a line with
|
|
13
|
+
// no `origin` (an SDK prompt) or a `human` one is read.
|
|
14
|
+
//
|
|
15
|
+
// **A workflow invocation quotes nothing.** `fullrun #2819` names this repository's own Issue, so a
|
|
16
|
+
// bare `#2819` in that run's report is a citation slip like any other, and the prompt yields no text —
|
|
17
|
+
// a resumed lane child's prompt included, since it ends on the same invocation.
|
|
18
|
+
|
|
19
|
+
const WORKFLOW_KEYWORDS: ReadonlySet<string> = new Set([
|
|
20
|
+
'kickoff',
|
|
21
|
+
'fullrun',
|
|
22
|
+
'halfrun',
|
|
23
|
+
'backlogrun',
|
|
24
|
+
])
|
|
25
|
+
const NO_PROMPT = ''
|
|
26
|
+
const HUMAN_ORIGIN = 'human'
|
|
27
|
+
// The word before the prompt's last one — where a trailing `fullrun #N` puts its keyword.
|
|
28
|
+
const KEYWORD_BEFORE_ARGUMENT = -2
|
|
29
|
+
|
|
30
|
+
const PROMPT_LINE_SCHEMA = z.object({
|
|
31
|
+
isMeta: z.boolean().nullish(),
|
|
32
|
+
origin: z.object({ kind: z.string().nullish() }).nullish(),
|
|
33
|
+
message: z.object({ content: z.unknown() }),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
type PromptLine = z.infer<typeof PROMPT_LINE_SCHEMA>
|
|
37
|
+
|
|
38
|
+
function is_person(line: PromptLine): boolean {
|
|
39
|
+
if (line.isMeta === true) return false
|
|
40
|
+
|
|
41
|
+
return line.origin === null || line.origin === undefined || line.origin.kind === HUMAN_ORIGIN
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The prompt line's text, or `undefined` when the harness wrote it or it does not read as one.
|
|
45
|
+
function person_text(line: string): string | undefined {
|
|
46
|
+
const parsed = PROMPT_LINE_SCHEMA.safeParse(json_value.parse_or_undefined(line))
|
|
47
|
+
|
|
48
|
+
if (!parsed.success || !is_person(parsed.data)) return undefined
|
|
49
|
+
|
|
50
|
+
return time_reported_failure.result_text(parsed.data.message.content)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A keyword opening the prompt is what a person types; a keyword before its one trailing argument is a
|
|
54
|
+
// lane child's resume prompt, whose preamble ends on `fullrun #N` (`lane-child-invocation.ts`).
|
|
55
|
+
function is_invocation(prompt: string): boolean {
|
|
56
|
+
const words = prompt.trim().split(/\s+/u)
|
|
57
|
+
|
|
58
|
+
return [words.at(0), words.at(KEYWORD_BEFORE_ARGUMENT)].some((word) =>
|
|
59
|
+
WORKFLOW_KEYWORDS.has(word ?? NO_PROMPT),
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function prompt_line_text(line: string): string | undefined {
|
|
64
|
+
return filing_cap.is_prompt_line(line) ? person_text(line) : undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The last person prompt on the tail; '' when none is on it or it is an invocation.
|
|
68
|
+
function prompt_text(tail: string): string {
|
|
69
|
+
const text = tail
|
|
70
|
+
.split('\n')
|
|
71
|
+
.map((line) => prompt_line_text(line))
|
|
72
|
+
.findLast((one) => one !== undefined)
|
|
73
|
+
|
|
74
|
+
return text === undefined || is_invocation(text) ? NO_PROMPT : text
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const last_prompt = { prompt_text }
|
|
78
|
+
|
|
79
|
+
export { last_prompt }
|
|
@@ -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
|
|
@@ -49,6 +49,8 @@ interface StopContext {
|
|
|
49
49
|
notified: boolean
|
|
50
50
|
// The turn's session-facing reply text.
|
|
51
51
|
message: string
|
|
52
|
+
// The person's last prompt — a `#N` it already carried is quoted, not cited (joshuafolkken/kit#2819).
|
|
53
|
+
prompt: string
|
|
52
54
|
// This stop already forced a continuation earlier in the prompt — the loop-breaker Claude Code
|
|
53
55
|
// documents, so a run that will not comply is not blocked forever.
|
|
54
56
|
stop_hook_active: boolean
|
|
@@ -136,8 +138,9 @@ const FILING_OFFER_REASON =
|
|
|
136
138
|
'⛔ filing offer: your reply offers to file an Issue instead of filing it. Filing into a ' +
|
|
137
139
|
'first-party repository is Tier A — the trigger is the judgement that it is worth filing, not the ' +
|
|
138
140
|
"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
|
|
141
|
+
'`pnpm josh issue:file "<title>" --body-file <path> --depth <0|1|2>` — it scouts, lints, labels and ' +
|
|
142
|
+
'runs `epic:bundle` itself — then end with the one-line citation of what was filed; do not repeat ' +
|
|
143
|
+
'your previous reply. If it is not worth filing ' +
|
|
141
144
|
'after all, say so in one line instead.'
|
|
142
145
|
|
|
143
146
|
// **A headless parent's turn-end is its process's end** (joshuafolkken/kit#2437). Under `claude -p` the
|
|
@@ -241,8 +244,8 @@ function needs_release(context: StopContext): boolean {
|
|
|
241
244
|
return is_held_idle(context) && context.tree_clean
|
|
242
245
|
}
|
|
243
246
|
|
|
244
|
-
function citation_reason(
|
|
245
|
-
const references = issue_citation.
|
|
247
|
+
function citation_reason(context: StopContext): string | undefined {
|
|
248
|
+
const references = issue_citation.unquoted_references(context.message, context.prompt)
|
|
246
249
|
if (references.length === 0) return undefined
|
|
247
250
|
|
|
248
251
|
return build_citation_reason(references)
|
|
@@ -254,7 +257,7 @@ function citation_reason(message: string): string | undefined {
|
|
|
254
257
|
function reply_reason(context: StopContext): string | undefined {
|
|
255
258
|
if (needs_filing(context)) return FILING_OFFER_REASON
|
|
256
259
|
|
|
257
|
-
return citation_reason(context
|
|
260
|
+
return citation_reason(context) ?? language_reason(context)
|
|
258
261
|
}
|
|
259
262
|
|
|
260
263
|
// The headless wait holds past the loop-breaker until the spin bound is reached.
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { execaSync } from 'execa'
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
// SHA-256 as workflow-level env, so one pin per file moves every "Setup safe-chain" step
|
|
7
|
-
// (joshuafolkken/kit#2711, joshuafolkken/kit#2765).
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
'.github/workflows/pr-classification.yml',
|
|
13
|
-
]
|
|
5
|
+
// Where the workflows that install safe-chain on the runner live. Each carries the release and the
|
|
6
|
+
// installer's SHA-256 as workflow-level env, so one pin per file moves every "Setup safe-chain" step
|
|
7
|
+
// at once (joshuafolkken/kit#2711, joshuafolkken/kit#2765). The directories are scanned rather than
|
|
8
|
+
// a file list kept, so a workflow another package distributes — app-kit's `dast.yml` and `load.yml`
|
|
9
|
+
// — moves with the rest (joshuafolkken/kit#2830); a file without the pin is never a target.
|
|
10
|
+
const WORKFLOW_DIRECTORIES = ['.github/workflows', 'templates/workflows']
|
|
11
|
+
const WORKFLOW_FILE_RE = /\.ya?ml$/u
|
|
14
12
|
// `[ \t]` rather than `\s`: under the `m` flag `\s*` would run across line ends and swallow the
|
|
15
13
|
// blank line that follows the env block.
|
|
16
14
|
const VERSION_RE = /^(?<key>[ \t]*SAFE_CHAIN_INSTALLER_VERSION:[ \t]*)(?<value>\S+)[ \t]*$/mu
|
|
@@ -27,6 +25,15 @@ function installer_url(version: string): string {
|
|
|
27
25
|
return `https://github.com/AikidoSec/safe-chain/releases/download/${version}/install-safe-chain.sh`
|
|
28
26
|
}
|
|
29
27
|
|
|
28
|
+
function list_workflows(): Array<string> {
|
|
29
|
+
return WORKFLOW_DIRECTORIES.filter((directory) => existsSync(directory)).flatMap((directory) =>
|
|
30
|
+
readdirSync(directory)
|
|
31
|
+
.filter((file_name) => WORKFLOW_FILE_RE.test(file_name))
|
|
32
|
+
.toSorted((left, right) => left.localeCompare(right))
|
|
33
|
+
.map((file_name) => `${directory}/${file_name}`),
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
function extract_pinned_version(content: string): string | undefined {
|
|
31
38
|
return VERSION_RE.exec(content)?.groups?.[VALUE_GROUP]
|
|
32
39
|
}
|
|
@@ -66,7 +73,7 @@ function write_pin(workflow_path: string, pin: InstallerPin): void {
|
|
|
66
73
|
writeFileSync(workflow_path, rewrite_pin(readFileSync(workflow_path, 'utf8'), pin), 'utf8')
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
function sync(latest: string, workflow_paths: ReadonlyArray<string> =
|
|
76
|
+
function sync(latest: string, workflow_paths: ReadonlyArray<string> = list_workflows()): void {
|
|
70
77
|
const targets = stale_workflows(latest, workflow_paths)
|
|
71
78
|
if (targets.length === 0) return
|
|
72
79
|
|
|
@@ -82,7 +89,7 @@ function sync(latest: string, workflow_paths: ReadonlyArray<string> = WORKFLOW_P
|
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
const ci_installer_pin = {
|
|
85
|
-
|
|
92
|
+
list_workflows,
|
|
86
93
|
installer_url,
|
|
87
94
|
extract_pinned_version,
|
|
88
95
|
rewrite_pin,
|