@joshuafolkken/kit 1.1370.0 → 1.1379.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.
Files changed (61) hide show
  1. package/.claude/skills/dependency-update/SKILL.md +6 -3
  2. package/.claude/skills/workflow-commands/backlogrun-steps.md +1 -1
  3. package/.claude/skills/workflow-commands/backlogrun.md +6 -4
  4. package/README.md +11 -36
  5. package/dist/commands/document-section-cli.js +1 -1
  6. package/dist/hooks/batch-guard.js +6 -6
  7. package/dist/hooks/{chunk-NZBHG6FW.js → chunk-4RWRP3FF.js} +5 -5
  8. package/dist/hooks/{chunk-R6CRQEAL.js → chunk-546X4PX3.js} +3 -3
  9. package/dist/hooks/{chunk-DZWZ3DKY.js → chunk-7ZM464OW.js} +3 -3
  10. package/dist/hooks/{chunk-GG72ANKI.js → chunk-DHSKBAQ2.js} +1 -1
  11. package/dist/hooks/{chunk-KPM4YIIX.js → chunk-E7LZL5AN.js} +3 -3
  12. package/dist/hooks/{chunk-4TW4MQUL.js → chunk-HGA4EG5S.js} +12 -12
  13. package/dist/hooks/{chunk-Z5TMJIPW.js → chunk-NBEAGPS4.js} +1 -1
  14. package/dist/hooks/{chunk-43G45XJF.js → chunk-PV3FWDSQ.js} +2 -2
  15. package/dist/hooks/{chunk-76RK6E3H.js → chunk-QARPP36N.js} +3 -3
  16. package/dist/hooks/{chunk-NZN2UFI6.js → chunk-RW4XMF47.js} +37 -1
  17. package/dist/hooks/{chunk-MXVQMLDP.js → chunk-TG2AB3W6.js} +50 -16
  18. package/dist/hooks/{chunk-TLJYAGZK.js → chunk-TORRSNDJ.js} +8 -5
  19. package/dist/hooks/{chunk-SYSRHRWU.js → chunk-YSDAH73S.js} +5 -33
  20. package/dist/hooks/codex-hook-adapter.js +13 -13
  21. package/dist/hooks/duplicate-read-guard.js +7 -7
  22. package/dist/hooks/format-edited.js +3 -3
  23. package/dist/hooks/inputs.json +227 -227
  24. package/dist/hooks/investigation-guard.js +7 -7
  25. package/dist/hooks/pretool-guard.js +12 -12
  26. package/dist/hooks/session-lang.js +3 -3
  27. package/dist/hooks/stop-guard.js +6 -6
  28. package/dist/josh.js +40 -5
  29. package/package.json +5 -5
  30. package/scripts/adopt/adopt-logic.ts +4 -1
  31. package/scripts/adopt/adopt.ts +7 -4
  32. package/scripts/cost-runtime/context-cut-payback.ts +9 -4
  33. package/scripts/cost-runtime/cost-pricing.ts +94 -33
  34. package/scripts/document/document-byte-budget.ts +1 -1
  35. package/scripts/document/document-scan.ts +4 -3
  36. package/scripts/document/read-set-pricing.ts +7 -5
  37. package/scripts/git/git-spawn-sync.ts +9 -1
  38. package/scripts/init/init-ai-copy.ts +10 -3
  39. package/scripts/init/init-copy-content.ts +8 -1
  40. package/scripts/init/security-policy.ts +79 -0
  41. package/scripts/josh/josh-commands-maintenance.ts +3 -0
  42. package/scripts/josh/josh-commands-project.ts +1 -1
  43. package/scripts/josh/josh-commands-run.ts +1 -1
  44. package/scripts/josh/josh-commands-versioning.ts +2 -1
  45. package/scripts/lint/cross-file-recheck.ts +155 -0
  46. package/scripts/lint/lint-parallel.ts +10 -9
  47. package/scripts/lint/lint-related-scope.ts +4 -4
  48. package/scripts/lint/lint-related.ts +1 -1
  49. package/scripts/propagate/propagate-run.ts +17 -1
  50. package/scripts/propagate/propagate-steps.ts +9 -1
  51. package/scripts/rules/decision-oracle-batch.ts +1 -1
  52. package/scripts/run/run-headless.ts +1 -0
  53. package/scripts/run/run-step-cli.ts +51 -3
  54. package/scripts/run/run-step.ts +49 -4
  55. package/scripts/sync/sync-ai-files.ts +2 -0
  56. package/scripts/version/latest-update.ts +20 -6
  57. package/scripts/version/peer-floor.ts +99 -0
  58. package/scripts/version/playwright-browsers.ts +109 -0
  59. package/scripts/version/publishable-range-check.ts +41 -4
  60. package/templates/SECURITY.md +23 -0
  61. package/scripts/lint/cycle-recheck.ts +0 -122
@@ -0,0 +1,79 @@
1
+ import path from 'node:path'
2
+ import { repo_origin } from '#scripts/discovery/repo-origin'
3
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
4
+ import { file_reader } from '#scripts/lib/read-file'
5
+
6
+ // A consumer's `SECURITY.md` is its own policy, not kit's: the advisory and release links have to
7
+ // name the repository the file lands in, and kit's account of its own published package is not a
8
+ // fact about that repository. So the consumer copy is written from a template of its own, with the
9
+ // repository filled in, and kit's root `SECURITY.md` stays kit's.
10
+ const SECURITY_MD = 'SECURITY.md'
11
+ const SECURITY_TEMPLATE = 'templates/SECURITY.md'
12
+ const REPOSITORY_PLACEHOLDER = '{{REPOSITORY}}'
13
+ const KIT_ADVISORY_URL = 'https://github.com/joshuafolkken/kit/security/advisories/new'
14
+ const UNRESOLVED_REASON = '`origin` is not a GitHub repository `owner/repo` can be read from'
15
+ const LINK_DELIMITER = /[\s()<>[\]]+/u
16
+
17
+ // Read from the local `origin` rather than asked of GitHub: the basic profile decides whether to
18
+ // write the file from the same remote, and a policy file must not depend on `gh` being signed in.
19
+ function repository_at(root: string): string | undefined {
20
+ const url = git_spawn_sync.origin_url(root)
21
+ const identity = url === undefined ? undefined : repo_origin.parse_origin_url(url)
22
+
23
+ return identity === undefined ? undefined : `${identity.owner}/${identity.repo}`
24
+ }
25
+
26
+ function apply_repository(content: string, repository: string): string {
27
+ return content.replaceAll(REPOSITORY_PLACEHOLDER, () => repository)
28
+ }
29
+
30
+ // Throws rather than writing the placeholder through: both copy paths ask `did_skip_unresolved`
31
+ // first, so reaching here without a repository is a caller that skipped the question.
32
+ function apply_repository_for_destination(destination_path: string, content: string): string {
33
+ if (path.basename(destination_path) !== SECURITY_MD) return content
34
+ const repository = repository_at(path.dirname(destination_path))
35
+
36
+ if (repository === undefined) {
37
+ throw new Error(`${SECURITY_MD} needs a GitHub origin to name the repository it reports to`)
38
+ }
39
+
40
+ return apply_repository(content, repository)
41
+ }
42
+
43
+ // A full project synced before the template existed holds kit's own policy under this name, and the
44
+ // skip leaves that file on disk — still sending its readers to kit's advisory form. The form is
45
+ // matched as a whole link target, not as a substring: a longer address that merely starts with it
46
+ // is another address.
47
+ function does_report_to_kit(root: string): boolean {
48
+ const existing = file_reader.read_if_readable(path.join(root, SECURITY_MD)) ?? ''
49
+
50
+ return new Set(existing.split(LINK_DELIMITER)).has(KIT_ADVISORY_URL)
51
+ }
52
+
53
+ function unresolved_warning(root: string): string {
54
+ if (!does_report_to_kit(root)) return ` ⚠ skipped ${SECURITY_MD} (${UNRESOLVED_REASON})`
55
+
56
+ return ` ⚠ stale ${SECURITY_MD} still reports vulnerabilities to joshuafolkken/kit — replace it with your own policy or delete it (${UNRESOLVED_REASON})`
57
+ }
58
+
59
+ // Without a GitHub `origin` there is no repository for the advisory link to name, so the file is
60
+ // left unwritten — the next `josh sync` after the remote is added writes it.
61
+ function did_skip_unresolved(filename: string, root: string): boolean {
62
+ if (filename !== SECURITY_MD || repository_at(root) !== undefined) return false
63
+
64
+ console.warn(unresolved_warning(root))
65
+
66
+ return true
67
+ }
68
+
69
+ const security_policy = {
70
+ SECURITY_MD,
71
+ SECURITY_TEMPLATE,
72
+ REPOSITORY_PLACEHOLDER,
73
+ KIT_ADVISORY_URL,
74
+ repository_at,
75
+ apply_repository_for_destination,
76
+ did_skip_unresolved,
77
+ }
78
+
79
+ export { security_policy }
@@ -80,6 +80,9 @@ const MAINTENANCE_COMMANDS: Record<string, CommandEntry> = {
80
80
  description: 'Update all dependencies to latest',
81
81
  category: 'Maintenance',
82
82
  reference: ['', 'developer', ['files', 'network']],
83
+ // `JOSH_PLAYWRIGHT_BROWSERS` widens the browsers a Playwright bump installs, and a project
84
+ // keeps that setting in its `.env`.
85
+ tsx_arguments: OPTIONAL_ENV_FILE_FLAGS,
83
86
  },
84
87
  }
85
88
  /* eslint-enable @typescript-eslint/naming-convention */
@@ -88,7 +88,7 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
88
88
  },
89
89
  adopt: {
90
90
  script: 'scripts/adopt/adopt.ts',
91
- description: 'Upgrade every installed @joshuafolkken toolkit here and open the pull request',
91
+ description: 'Upgrade the @joshuafolkken toolkits and other dependencies here, open the PR',
92
92
  category: 'Project',
93
93
  reference: ['[--dry-run]', 'developer', ['files', 'git', 'network', 'processes']],
94
94
  },
@@ -125,7 +125,7 @@ const RUN_COMMANDS: Record<string, CommandEntry> = {
125
125
  description:
126
126
  'Print the run’s next single action, computed from the event stream, carry record and issue state',
127
127
  category: 'AI tools',
128
- reference: ['<issue>', 'automation', ['network', 'files']],
128
+ reference: ['[<issue>]', 'automation', ['network', 'files']],
129
129
  },
130
130
  }
131
131
  /* eslint-enable @typescript-eslint/naming-convention */
@@ -20,7 +20,8 @@ const VERSIONING_COMMANDS: Record<string, CommandEntry> = {
20
20
  // before chaining here, exactly as it already does for `latest:update`.
21
21
  ranges: {
22
22
  script: 'scripts/version/publishable-range-check.ts',
23
- description: 'Check that every published dependency range still resolves for a consumer',
23
+ description:
24
+ 'Check that every published dependency range still resolves for a consumer and no peer range falls below an installed toolkit',
24
25
  category: 'Versioning',
25
26
  reference: ['', 'developer', ['network']],
26
27
  },
@@ -0,0 +1,155 @@
1
+ import { FAIL_EXIT_CODE, type BufferedProcessResult } from '#scripts/lib/buffered-process'
2
+ import type { ESLint } from 'eslint'
3
+
4
+ // Some rules read other files, and the lint cache keys a verdict on the linted file's own content.
5
+ // So once the other file is fixed, the untouched one keeps its cached error until its own content
6
+ // changes. `import/no-cycle` did it first (joshuafolkken/kit#3641): a cycle closed by editing one
7
+ // side stayed reported on the other. The type-aware rules do it too (joshuafolkken/kit#3667): a call
8
+ // to a function its module did not export yet stays `no-unsafe-call` after the export is added.
9
+ //
10
+ // A red lint run therefore re-reads its cached report: the files reported for a rule that reads
11
+ // other files are linted once more without the cache, and their fresh results replace the cached
12
+ // ones. A green run never reaches this, so the green path costs what it did.
13
+ //
14
+ // Which rules those are is the rule's own statement, never a list kept here: a type-aware rule
15
+ // declares `requiresTypeChecking`, and the `import` plugin's rules resolve the modules they name.
16
+ // That plugin answers to two names: the base config registers it as `import`, and its own preset —
17
+ // whose rules a hand-authored `.js` file keeps — as `import-x`.
18
+ //
19
+ // Only a stale red is re-read. A stale green — the other file broke, the untouched one still
20
+ // answers clean — is not something a report can point at; the type check is what catches it.
21
+ //
22
+ // That holds for a run narrowed to one change's files as much as for the whole tree: the change can
23
+ // hold both files, and the one a later edit leaves alone keeps its cached report.
24
+ //
25
+ // The cached run is asked again through the Node API rather than read off the first run's report:
26
+ // that report is the stylish text, and taking a file out of it means re-deriving its problem
27
+ // counts. The second asking is answered from the cache the first one just wrote.
28
+ const IMPORT_RULE_PATTERN = /^import(?:-x)?\//u
29
+ const FORMATTER = 'stylish'
30
+ const UNCACHED_SUFFIX = '.uncached'
31
+
32
+ type RulesMeta = ESLint.LintResultData['rulesMeta']
33
+
34
+ interface Linters {
35
+ cached: ESLint
36
+ fresh: ESLint
37
+ }
38
+
39
+ interface CrossFileRecheckOptions {
40
+ cache_file: string
41
+ // What the run linted — the whole tree, or one change's files — so the cached run is asked the
42
+ // question it was asked the first time.
43
+ patterns: ReadonlyArray<string>
44
+ // What the fixture test varies: the directory linted and the configuration it is linted with.
45
+ eslint_options?: ESLint.Options
46
+ }
47
+
48
+ // The exit code is eslint's "finished and reported problems", not any failure: a run that could not
49
+ // finish — a configuration that does not load, a rule that threw — exits 2, and asking it again
50
+ // throws the same error here, ahead of both buffered reports.
51
+ function is_red(result: BufferedProcessResult): boolean {
52
+ return result.exit_code === FAIL_EXIT_CODE
53
+ }
54
+
55
+ // `requiresTypeChecking` is typescript-eslint's convention, which other type-aware plugins follow;
56
+ // eslint's own type for a rule's docs does not name it.
57
+ function is_type_aware(documentation: object | undefined): boolean {
58
+ if (documentation === undefined) return false
59
+
60
+ return 'requiresTypeChecking' in documentation && documentation.requiresTypeChecking === true
61
+ }
62
+
63
+ // A problem with no rule is the parser's, about the file itself.
64
+ function reads_other_files(rule_id: string | null, rules_meta: RulesMeta): boolean {
65
+ if (rule_id === null) return false
66
+
67
+ return IMPORT_RULE_PATTERN.test(rule_id) || is_type_aware(rules_meta[rule_id]?.docs)
68
+ }
69
+
70
+ function suspects_of(linter: ESLint, cached: Array<ESLint.LintResult>): Array<string> {
71
+ const rules_meta = linter.getRulesMetaForResults(cached)
72
+
73
+ return cached
74
+ .filter((result) =>
75
+ result.messages.some((message) => reads_other_files(message.ruleId, rules_meta)),
76
+ )
77
+ .map((result) => result.filePath)
78
+ }
79
+
80
+ function cached_options(options: CrossFileRecheckOptions): ESLint.Options {
81
+ return {
82
+ ...options.eslint_options,
83
+ cache: true,
84
+ cacheStrategy: 'content',
85
+ cacheLocation: options.cache_file,
86
+ }
87
+ }
88
+
89
+ // A linter that runs without a cache deletes the cache file it would have used, and that defaults to
90
+ // the very file the lint run keeps. So the uncached linter is pointed at a sibling path nothing
91
+ // ever writes: the re-reading must not cost the next run its whole cache.
92
+ function fresh_options(options: CrossFileRecheckOptions): ESLint.Options {
93
+ return {
94
+ ...options.eslint_options,
95
+ cache: false,
96
+ cacheLocation: `${options.cache_file}${UNCACHED_SUFFIX}`,
97
+ }
98
+ }
99
+
100
+ // `eslint` is loaded here, only past a red report: it is an optional peer a basic-profile
101
+ // consumer may not have, and this module is imported by the entry of every `josh lint`, which has to
102
+ // reach its skip notice there.
103
+ async function create_linters(options: CrossFileRecheckOptions): Promise<Linters> {
104
+ const eslint = await import('eslint')
105
+
106
+ return {
107
+ cached: new eslint.ESLint(cached_options(options)),
108
+ fresh: new eslint.ESLint(fresh_options(options)),
109
+ }
110
+ }
111
+
112
+ async function lint_fresh(
113
+ linter: ESLint,
114
+ suspects: Array<string>,
115
+ ): Promise<Map<string, ESLint.LintResult>> {
116
+ const fresh = await linter.lintFiles(suspects)
117
+
118
+ return new Map(fresh.map((result) => [result.filePath, result]))
119
+ }
120
+
121
+ async function report(
122
+ linter: ESLint,
123
+ results: Array<ESLint.LintResult>,
124
+ ): Promise<Pick<BufferedProcessResult, 'output' | 'exit_code'>> {
125
+ const formatter = await linter.loadFormatter(FORMATTER)
126
+ const is_failed = results.some((result) => result.errorCount > 0)
127
+
128
+ return { output: await formatter.format(results), exit_code: is_failed ? FAIL_EXIT_CODE : 0 }
129
+ }
130
+
131
+ // A red about rules that read nothing else is handed back as the run reported it: its cached
132
+ // verdicts are about the files they are keyed on.
133
+ async function verify(
134
+ result: BufferedProcessResult,
135
+ options: CrossFileRecheckOptions,
136
+ ): Promise<BufferedProcessResult> {
137
+ if (!is_red(result)) return result
138
+ const started_at = performance.now()
139
+ const linters = await create_linters(options)
140
+ const cached = await linters.cached.lintFiles([...options.patterns])
141
+ const suspects = suspects_of(linters.cached, cached)
142
+ if (suspects.length === 0) return result
143
+ const fresh = await lint_fresh(linters.fresh, suspects)
144
+ const verified = await report(
145
+ linters.fresh,
146
+ cached.map((entry) => fresh.get(entry.filePath) ?? entry),
147
+ )
148
+
149
+ return { ...verified, elapsed_ms: result.elapsed_ms + performance.now() - started_at }
150
+ }
151
+
152
+ const cross_file_recheck = { verify }
153
+
154
+ export type { CrossFileRecheckOptions }
155
+ export { cross_file_recheck }
@@ -8,7 +8,7 @@ import {
8
8
  FAIL_EXIT_CODE,
9
9
  type BufferedProcessResult,
10
10
  } from '#scripts/lib/buffered-process'
11
- import { cycle_recheck, type CycleRecheckOptions } from './cycle-recheck'
11
+ import { cross_file_recheck, type CrossFileRecheckOptions } from './cross-file-recheck'
12
12
 
13
13
  const PRETTIER_ARGS = ['exec', 'prettier', '--check', '.'] as const
14
14
  // This — not the `lint:eslint` map entry — is the eslint invocation `josh gate` reaches, so the
@@ -17,16 +17,16 @@ const PRETTIER_ARGS = ['exec', 'prettier', '--check', '.'] as const
17
17
  // moves, with every test still green.
18
18
  const WHOLE_TREE = '.'
19
19
  const ESLINT_ARGS = ['exec', 'eslint', WHOLE_TREE, ...ESLINT_CACHE_FLAGS] as const
20
- const WHOLE_TREE_RECHECK: CycleRecheckOptions = {
20
+ const WHOLE_TREE_RECHECK: CrossFileRecheckOptions = {
21
21
  cache_file: ESLINT_CACHE_FILE,
22
22
  patterns: [WHOLE_TREE],
23
23
  }
24
24
 
25
- // What one eslint run is made of: the child's arguments, and what re-reading a cached cycle report
26
- // (joshuafolkken/kit#3641) needs to ask the same question again — the cache file and the targets.
25
+ // What one eslint run is made of: the child's arguments, and what re-reading a cached cross-file report
26
+ // (`cross-file-recheck.ts`) needs to ask the same question again — the cache file and the targets.
27
27
  interface EslintRun {
28
28
  args: ReadonlyArray<string>
29
- recheck: CycleRecheckOptions
29
+ recheck: CrossFileRecheckOptions
30
30
  }
31
31
 
32
32
  // The finished report is re-read before it is returned, inside the lane's cache sharing, because
@@ -35,7 +35,7 @@ async function run_eslint(eslint: EslintRun): Promise<BufferedProcessResult> {
35
35
  return await lane_cache_run.run(eslint.recheck.cache_file, async () => {
36
36
  const result = await buffered_process.run_buffered_process(eslint.args)
37
37
 
38
- return await cycle_recheck.verify(result, eslint.recheck)
38
+ return await cross_file_recheck.verify(result, eslint.recheck)
39
39
  })
40
40
  }
41
41
 
@@ -80,12 +80,13 @@ function lint_exit_code(prettier: BufferedProcessResult, eslint: BufferedProcess
80
80
  // run would have skipped. The skip reasons answer nothing for a full
81
81
  // project, so the one path serves both profiles.
82
82
  //
83
- // A narrowed run re-reads a cached cycle report too (joshuafolkken/kit#3641): one change's files can
84
- // hold both sides of a cycle, and closing it by editing one leaves the other's cached report standing.
83
+ // A narrowed run re-reads a cached cross-file report too (`cross-file-recheck.ts`): one change's
84
+ // files can hold both the reported file and the one it reads, and fixing the second leaves the
85
+ // first's cached report standing.
85
86
  async function run_lint_checks(
86
87
  prettier_args: ReadonlyArray<string>,
87
88
  eslint_args: ReadonlyArray<string>,
88
- recheck: CycleRecheckOptions,
89
+ recheck: CrossFileRecheckOptions,
89
90
  ): Promise<number> {
90
91
  const directory = process.cwd()
91
92
  const [prettier, eslint] = await Promise.all([
@@ -9,7 +9,7 @@ import {
9
9
  ESLINT_RELATED_CACHE_FILE,
10
10
  ESLINT_RELATED_CACHE_FLAGS,
11
11
  } from '#scripts/josh/josh-command-types'
12
- import type { CycleRecheckOptions } from './cycle-recheck'
12
+ import type { CrossFileRecheckOptions } from './cross-file-recheck'
13
13
 
14
14
  // `josh lint` reads the whole repository on every call, and an implementation loop calls it after
15
15
  // every few edits — re-reading files the change never touched.
@@ -113,9 +113,9 @@ function eslint_arguments(files: ReadonlyArray<string>): ReadonlyArray<string> {
113
113
  return [...ESLINT_ARGS, ...files, ...ESLINT_RELATED_CACHE_FLAGS, ...ESLINT_SCOPED_FLAGS]
114
114
  }
115
115
 
116
- // What re-reading a cached cycle report (`cycle-recheck.ts`) needs to ask the narrowed run's question
116
+ // What re-reading a cached cross-file report (`cross-file-recheck.ts`) needs to ask the narrowed run's question
117
117
  // again: the same targets, the same cache file, and the Node API's spelling of `--no-warn-ignored`.
118
- function cycle_recheck_options(files: ReadonlyArray<string>): CycleRecheckOptions {
118
+ function cross_file_recheck_options(files: ReadonlyArray<string>): CrossFileRecheckOptions {
119
119
  return {
120
120
  cache_file: ESLINT_RELATED_CACHE_FILE,
121
121
  patterns: files,
@@ -125,7 +125,7 @@ function cycle_recheck_options(files: ReadonlyArray<string>): CycleRecheckOption
125
125
 
126
126
  const lint_related_scope = {
127
127
  COMMAND_LABEL,
128
- cycle_recheck_options,
128
+ cross_file_recheck_options,
129
129
  NOTHING_LINTABLE_REASON,
130
130
  describe_scope,
131
131
  eslint_arguments,
@@ -36,7 +36,7 @@ async function lint_exit_code(scope: ChangedFileScope): Promise<number> {
36
36
  return await lint_parallel.run_lint_checks(
37
37
  lint_related_scope.prettier_arguments(scope.files),
38
38
  lint_related_scope.eslint_arguments(scope.files),
39
- lint_related_scope.cycle_recheck_options(scope.files),
39
+ lint_related_scope.cross_file_recheck_options(scope.files),
40
40
  )
41
41
  }
42
42
 
@@ -13,6 +13,7 @@ import type { PropagateTarget } from './propagate-targets'
13
13
  const STEP_PRECHECK = 'working tree check'
14
14
  const STEP_UPGRADE = 'josh vu'
15
15
  const STEP_SYNC = 'josh sync'
16
+ const STEP_LATEST = 'josh latest'
16
17
  const STEP_LINT_RELATED = 'related lint check'
17
18
  const STEP_TEST_RELATED = 'related test check'
18
19
  const STEP_VERIFY = 'verification gate'
@@ -32,6 +33,18 @@ const STEP_ORDER: ReadonlyArray<string> = [
32
33
  STEP_PR,
33
34
  STEP_RETURN,
34
35
  ]
36
+ // The same sequence with the consumer's other dependencies, its audit and its pnpm pin brought up
37
+ // to date after the sync. Only `josh adopt` runs it: `josh latest` moves every dependency to the
38
+ // registry's newest, which is what adoption is for and exactly what propagation's exact-version wait
39
+ // exists to prevent — and an unrelated dependency's breakage would stop a release reaching a
40
+ // consumer. Derived from `STEP_ORDER` rather than listed, so the two cannot drift apart; behind the
41
+ // sync, because the sync can add devDependencies to `package.json` without installing them, and a
42
+ // pull request opened over that fails CI's frozen install — the step installs before it updates
43
+ // (`propagate_steps`), so the lockfile is re-resolved even when `josh latest` rolls its update back.
44
+ // The related checks and the gate that follow then verify the tree as it will be committed.
45
+ const ADOPT_STEP_ORDER: ReadonlyArray<string> = STEP_ORDER.flatMap((step) =>
46
+ step === STEP_SYNC ? [step, STEP_LATEST] : step,
47
+ )
35
48
  const PROPAGATED_REASON = 'opened a pull request'
36
49
 
37
50
  type RunOutcome = 'propagated' | 'failed' | 'skipped'
@@ -130,10 +143,11 @@ function run_target(
130
143
  target: PropagateTarget,
131
144
  run_step: RunStep,
132
145
  success_reason: string = PROPAGATED_REASON,
146
+ order: ReadonlyArray<string> = STEP_ORDER,
133
147
  ): TargetResult {
134
148
  const steps: Array<StepResult> = []
135
149
 
136
- for (const step of STEP_ORDER) {
150
+ for (const step of order) {
137
151
  const result = run_step(target, step)
138
152
 
139
153
  steps.push(result)
@@ -192,11 +206,13 @@ const propagate_run = {
192
206
  STEP_ISSUE,
193
207
  STEP_UPGRADE,
194
208
  STEP_SYNC,
209
+ STEP_LATEST,
195
210
  STEP_LINT_RELATED,
196
211
  STEP_TEST_RELATED,
197
212
  STEP_VERIFY,
198
213
  STEP_PR,
199
214
  STEP_ORDER,
215
+ ADOPT_STEP_ORDER,
200
216
  skip_reason,
201
217
  run_target,
202
218
  run_targets,
@@ -27,10 +27,17 @@ const SUCCESS_EXIT_CODE = 0
27
27
  // pass — so the chain is not repeated here. It resolves in the consumer's directory, which by this
28
28
  // point has already been upgraded to the version being propagated, so the command is present.
29
29
  const VERIFY_SCRIPT = `pnpm josh ${GATE_COMMAND}`
30
+ // `josh latest` behind an install of its own. The sync can add devDependencies to `package.json`
31
+ // without installing them, and `josh latest` does not always re-resolve the lockfile: when the age
32
+ // gate would downgrade a dependency it restores the files it found and exits zero. Installing first
33
+ // makes the tree it finds a consistent one, so the lockfile matches `package.json` on either path.
34
+ // `--no-frozen-lockfile` because the install exists to write the lockfile, which pnpm refuses under CI.
35
+ const LATEST_SCRIPT = 'pnpm install --no-frozen-lockfile && pnpm josh latest'
30
36
 
31
37
  // The commands the release-independent spawning steps run inside the target's own directory. The
32
38
  // upgrade and the sync are not here: both are per toolkit, so they are built from the plan instead.
33
39
  const STEP_COMMANDS: Readonly<Record<string, ReadonlyArray<string>>> = {
40
+ [propagate_run.STEP_LATEST]: ['sh', '-c', LATEST_SCRIPT],
34
41
  [propagate_run.STEP_LINT_RELATED]: ['pnpm', 'josh', 'lint:related'],
35
42
  [propagate_run.STEP_TEST_RELATED]: ['pnpm', 'josh', 'test:related'],
36
43
  [propagate_run.STEP_VERIFY]: ['sh', '-c', VERIFY_SCRIPT],
@@ -353,7 +360,8 @@ interface Release {
353
360
  // `josh propagate` always passes exactly one release; `josh adopt` passes every toolkit installed in
354
361
  // the repository it runs in. Only the upgrade and the sync repeat per
355
362
  // release — the pre-check, the verification gate, the issue, the pull request and the return to the
356
- // default branch happen once, which is why the step order itself is unchanged.
363
+ // default branch happen once, which is why the step order does not depend on the plan — `josh adopt`
364
+ // runs `propagate_run.ADOPT_STEP_ORDER`, the same order with `josh latest` added.
357
365
  interface ReleasePlan {
358
366
  releases: ReadonlyArray<Release>
359
367
  origin: string
@@ -106,7 +106,7 @@ const BATCH_ORACLES: ReadonlyArray<DecisionOracle> = [
106
106
  {
107
107
  name: 'run:step',
108
108
  decision: 'The run’s next single action, computed from the event stream, carry and issue state',
109
- args: ISSUE_N_ARG,
109
+ args: `[${ISSUE_N_ARG}]`,
110
110
  // The non-command answers: `run:step` prints a runnable command for a phase with one to run, and
111
111
  // one of these verdicts otherwise. A command line is not a vocabulary token, exactly as an issue
112
112
  // number is not one for `backlog:next`.
@@ -123,6 +123,7 @@ async function is_backlog_parent(
123
123
  const run_headless = {
124
124
  HEADLESS_ENV_KEY,
125
125
  current_carry,
126
+ driven_here,
126
127
  driving_carry,
127
128
  environment,
128
129
  is_backlog_parent,
@@ -13,9 +13,9 @@ import { run_headless } from './run-headless'
13
13
  import { run_prep, type PrepParts } from './run-prep'
14
14
  import { run_prep_cli } from './run-prep-cli'
15
15
  import { run_retrospective } from './run-retrospective'
16
- import { run_step, type StepInput } from './run-step'
16
+ import { run_step, type ParentInput, type StepInput } from './run-step'
17
17
 
18
- // `josh run:step <N>` — print the run's next single action, computed from the event stream, the carry
18
+ // `josh run:step [<N>]` — print the run's next single action, computed from the event stream, the carry
19
19
  // record and the issue state. It reads exactly those three, never the
20
20
  // conversation: the issue facts through `run:prep`'s own gather (so this and `run:next` never answer
21
21
  // from different reads), the newest event off the stream, and the carry record's kind. The reader runs
@@ -25,7 +25,8 @@ import { run_step, type StepInput } from './run-step'
25
25
  const SUCCESS_EXIT_CODE = 0
26
26
  const FAILURE_EXIT_CODE = 1
27
27
  const ARGV_OFFSET = 2
28
- const USAGE = 'Usage: josh run:step <issue-number>'
28
+ const USAGE =
29
+ 'Usage: josh run:step [<issue-number>] — without a number it answers for the `backlogrun` carry record this session drives'
29
30
 
30
31
  interface RunReads {
31
32
  carry_kind: CarryRead['kind']
@@ -118,7 +119,54 @@ async function gather(issue_number: string): Promise<Gathered> {
118
119
  return { input: step_input(parts, run_reads, is_lane_child), ship_problems: parts.ship_problems }
119
120
  }
120
121
 
122
+ const UNREADABLE_CARRY: CarryRead = { kind: 'unreadable' }
123
+
124
+ // The parent's own position — a run asked about with no issue. The last event is the run's newest in
125
+ // scope, whichever issue it names: a parent is positioned by the whole batch, never by one child.
126
+ function parent_reads(directory: string): Pick<ParentInput, 'carry_kind' | 'last_event'> & {
127
+ carry: CarryRead
128
+ } {
129
+ const carry = run_carry.read_carry(run_carry.carry_path(directory))
130
+ const events = run_event_stream.read_events(run_event_stream.target_of(directory))
131
+ const last = run_event_scope.last_scoped_event(events, run_event_scope.scope_of(carry))
132
+
133
+ return { carry, carry_kind: carry.kind, last_event: last?.kind }
134
+ }
135
+
136
+ function parent_input(directory: string | undefined): ParentInput {
137
+ const { carry, ...position } =
138
+ directory === undefined
139
+ ? { carry: UNREADABLE_CARRY, carry_kind: UNREADABLE_CARRY.kind, last_event: undefined }
140
+ : parent_reads(directory)
141
+
142
+ return {
143
+ ...position,
144
+ is_driving: run_headless.driven_here(carry, lane_reap.own_ancestry) !== undefined,
145
+ is_handed_off: is_handed_off_here(carry),
146
+ is_at_cut_cap: carry.kind === 'carried' && run_headless.is_cut_capped(carry.carry),
147
+ is_lane_child: lane_child_marker.is_child_of(process.cwd()),
148
+ is_retrospective_done: run_carry.retrospective_done_of(carry),
149
+ is_consumer: doctor_consumer.is_kit_consumer(find_package_directory(process.cwd())),
150
+ is_retrospective_enabled: run_retrospective.is_enabled(),
151
+ }
152
+ }
153
+
154
+ // `unknown` is the one answer with nothing to run — no record this session drives — so it exits non-zero
155
+ // with the usage, exactly as a position missing its issue state does.
156
+ async function run_parent(): Promise<number> {
157
+ const { line } = run_step.parent_action(parent_input(await run_carry.repository_directory()))
158
+
159
+ console.info(line)
160
+ if (line !== run_step.UNKNOWN) return SUCCESS_EXIT_CODE
161
+
162
+ console.error(USAGE)
163
+
164
+ return FAILURE_EXIT_CODE
165
+ }
166
+
121
167
  async function run(argv: ReadonlyArray<string>): Promise<number> {
168
+ if (argv.length === 0) return await run_parent()
169
+
122
170
  const issue_number = run_prep_cli.parse_number(argv)
123
171
 
124
172
  if (issue_number === undefined) {
@@ -3,7 +3,7 @@ import { session_cite } from '#scripts/issue/session-cite'
3
3
  import type { CarryRead } from '#scripts/run/carry/run-carry'
4
4
  import { run_event_stream } from '#scripts/run/event/run-event-stream'
5
5
  import { latest_scope_cli } from '#scripts/version/latest-scope-cli'
6
- import { run_retrospective } from './run-retrospective'
6
+ import { run_retrospective, type RetrospectiveFacts } from './run-retrospective'
7
7
 
8
8
  // `josh run:step <N>` — the run's next single action, computed from three mechanical inputs and nothing
9
9
  // else, so the run's loop is not a state machine written in English that the reader rebuilds every
@@ -278,16 +278,19 @@ function closed_action(input: StepInput): StepAction {
278
278
  }
279
279
  }
280
280
 
281
+ // What the carry record alone says — the part an issue's position and the parent's own share.
282
+ type CarryFacts = Pick<StepInput, 'carry_kind' | 'is_handed_off' | 'is_lane_child'>
283
+
281
284
  // **A cut hands the run on, so the session that took it stops** — otherwise it would go on answering
282
285
  // `wait` or the next offer after `run:carry --cut`, launching children in the same conversation. A
283
286
  // lane child never holds the carry record, so its own position is not decided here.
284
- function is_handed_off_parent(input: StepInput): boolean {
287
+ function is_handed_off_parent(input: CarryFacts): boolean {
285
288
  return input.is_handed_off && input.carry_kind === 'carried' && !input.is_lane_child
286
289
  }
287
290
 
288
291
  // The carry record's terminal answers: an unreadable record leaves the position unknowable, a handed-off
289
292
  // run is its successor's, and a spent budget is the person's call.
290
- function carry_terminal_action(input: StepInput): StepAction | undefined {
293
+ function carry_terminal_action(input: CarryFacts): StepAction | undefined {
291
294
  if (input.carry_kind === 'unreadable') return verdict(UNKNOWN)
292
295
  if (is_handed_off_parent(input)) return verdict(STOP)
293
296
  if (input.carry_kind === 'expired') return { kind: 'decide', line: EXPIRED_DECISION }
@@ -308,6 +311,47 @@ function terminal_action(input: StepInput): StepAction | undefined {
308
311
  return undefined
309
312
  }
310
313
 
314
+ // **A run with no issue still has a next action** — the `backlogrun` parent's own position, read off the
315
+ // carry record alone. Without it a run that dispatched nothing — one begun on an empty backlog — has no
316
+ // position to ask about, and "the backlog is empty, so the run is over" is decided in the conversation
317
+ // instead of by the idle watch.
318
+ interface ParentInput extends RetrospectiveFacts {
319
+ carry_kind: CarryRead['kind']
320
+ // Whether this session declared the live record and no cut has handed it off yet.
321
+ is_driving: boolean
322
+ is_handed_off: boolean
323
+ is_at_cut_cap: boolean
324
+ last_event: string | undefined
325
+ }
326
+
327
+ // The positions the end-of-run retrospective fires at, whoever asks.
328
+ const RETROSPECTIVE_POSITIONS: ReadonlySet<string> = new Set([KIND.DRAIN, KIND.STOP])
329
+
330
+ function is_retrospective_position(input: ParentInput): boolean {
331
+ return RETROSPECTIVE_POSITIONS.has(input.last_event ?? '') && run_retrospective.is_owed(input)
332
+ }
333
+
334
+ // **The loop belongs to the supervisor, so a driving parent's next action is the hand-off whatever the
335
+ // plan held.** The supervisor starts no driver while a live owner holds the record, and only the driver
336
+ // runs the idle watch. A record at the cut cap has no cut left to take, so its session carries on.
337
+ function driving_action(input: ParentInput): StepAction {
338
+ if (is_retrospective_position(input)) return command(RETROSPECTIVE_COMMAND)
339
+
340
+ return input.is_at_cut_cap ? verdict(WAIT) : command(HAND_OFF_COMMAND)
341
+ }
342
+
343
+ // A lane child's position is its issue's, and a record this session does not drive — absent, or another
344
+ // session's — is not its to hand off: both are `unknown`, never a guess at someone else's run.
345
+ function parent_action(input: ParentInput): StepAction {
346
+ if (input.is_lane_child) return verdict(UNKNOWN)
347
+
348
+ const terminal = carry_terminal_action(input)
349
+
350
+ if (terminal !== undefined) return terminal
351
+
352
+ return input.is_driving ? driving_action(input) : verdict(UNKNOWN)
353
+ }
354
+
311
355
  // The one entry point: the run's next action, from the three inputs and nothing else. A terminal answer
312
356
  // wins first; otherwise a run with nothing emitted yet is at its pre-implementation position, and a run
313
357
  // that has begun is placed by its newest event.
@@ -339,8 +383,9 @@ const run_step = {
339
383
  VOCABULARY,
340
384
  WAIT,
341
385
  next_action,
386
+ parent_action,
342
387
  pre_verdict,
343
388
  }
344
389
 
345
- export type { PreInput, PreVerdict, StepAction, StepInput }
390
+ export type { ParentInput, PreInput, PreVerdict, StepAction, StepInput }
346
391
  export { run_step }
@@ -5,6 +5,7 @@ import { transform_copied_content } from '#scripts/init/init-copy-content'
5
5
  import { init_logic } from '#scripts/init/init-logic'
6
6
  import { PACKAGE_DIR, PROJECT_ROOT } from '#scripts/init/init-paths'
7
7
  import type { ProjectShape } from '#scripts/init/project-profile'
8
+ import { security_policy } from '#scripts/init/security-policy'
8
9
  import { file_reader } from '#scripts/lib/read-file'
9
10
  import { pack_hook } from '#scripts/safe-chain/pack-hook'
10
11
  import { copy_directory_failure, directory_copy_blocker } from './directory-copy-guard'
@@ -80,6 +81,7 @@ function sync_workspace_yaml(
80
81
 
81
82
  // The source is the profile's: a basic project syncs from its own templates, as `josh init` wrote it.
82
83
  function sync_ai_copy_file(filename: string, is_force: boolean, shape?: ProjectShape): void {
84
+ if (security_policy.did_skip_unresolved(filename, PROJECT_ROOT)) return
83
85
  const source = init_ai_copy.ai_file_source(filename, shape)
84
86
 
85
87
  if (filename === WORKSPACE_YAML) {