@joshuafolkken/kit 1.947.0 → 1.957.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 (41) hide show
  1. package/README.md +4 -5
  2. package/dist/hooks/batch-guard.js +4 -4
  3. package/dist/hooks/{chunk-V7RH56HU.js → chunk-244WYOEO.js} +2 -2
  4. package/dist/hooks/{chunk-IA7WC4NS.js → chunk-3ON7HOYB.js} +1 -1
  5. package/dist/hooks/{chunk-EYFJ74MJ.js → chunk-BELI5XSS.js} +6 -2
  6. package/dist/hooks/{chunk-IBFC25IX.js → chunk-GNOITLRR.js} +4 -4
  7. package/dist/hooks/{chunk-M4U4YNRE.js → chunk-HJXQZ7V4.js} +3 -3
  8. package/dist/hooks/{chunk-3FWV6CFC.js → chunk-IJPBTDTB.js} +371 -178
  9. package/dist/hooks/{chunk-NKVLBB65.js → chunk-KOCK5FXQ.js} +3 -3
  10. package/dist/hooks/{chunk-O3PEDF3M.js → chunk-LDQDZYHX.js} +1 -1
  11. package/dist/hooks/{chunk-GIX5SAAB.js → chunk-M6K7EQDO.js} +8 -8
  12. package/dist/hooks/{chunk-OM5VDM6F.js → chunk-XSL4SRMA.js} +2 -2
  13. package/dist/hooks/codex-hook-adapter.js +10 -10
  14. package/dist/hooks/duplicate-read-guard.js +5 -5
  15. package/dist/hooks/format-edited.js +2 -2
  16. package/dist/hooks/investigation-guard.js +5 -5
  17. package/dist/hooks/pretool-guard.js +9 -9
  18. package/dist/hooks/stop-guard.js +3 -3
  19. package/dist/josh.js +6 -2
  20. package/dist/version/index.js +7 -4
  21. package/package.json +1 -1
  22. package/scripts/backlog/backlog-drive-cli.ts +5 -1
  23. package/scripts/backlog/backlog-drive-retrospective.ts +20 -0
  24. package/scripts/backlog/backlog-drive.ts +7 -3
  25. package/scripts/git/git-worktree.ts +35 -4
  26. package/scripts/git/issue-labels.ts +19 -0
  27. package/scripts/git/repository-lock.ts +138 -0
  28. package/scripts/git/stash-sweep-lock.ts +6 -117
  29. package/scripts/init/project-profile.ts +9 -5
  30. package/scripts/init/start-plan.ts +145 -0
  31. package/scripts/init/start-prompt.ts +55 -0
  32. package/scripts/init/start-steps.ts +167 -0
  33. package/scripts/init/start.ts +92 -16
  34. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
  35. package/scripts/josh/josh-commands-project.ts +6 -2
  36. package/scripts/run/run-retrospective.ts +34 -0
  37. package/scripts/run/run-step-cli.ts +3 -5
  38. package/scripts/run/run-step.ts +3 -15
  39. package/scripts/safe-chain/project-config.ts +55 -10
  40. package/scripts/sync/sync.ts +3 -1
  41. package/scripts/version/release-age.ts +11 -6
@@ -14,9 +14,13 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
14
14
  },
15
15
  start: {
16
16
  script: 'scripts/init/start.ts',
17
- description: 'Initialize a project for the GitHub Issue workflow',
17
+ description: 'Create a new project for the GitHub Issue workflow, from git init to labels',
18
18
  category: 'Project',
19
- reference: [PROFILE_ARGUMENTS, 'developer', ['files', 'git', 'network']],
19
+ reference: [
20
+ `${PROFILE_ARGUMENTS} [--yes] [--github] [--public]`,
21
+ 'developer',
22
+ ['files', 'git', 'network', 'processes'],
23
+ ],
20
24
  },
21
25
  init: {
22
26
  script: 'scripts/init/init.ts',
@@ -0,0 +1,34 @@
1
+ import { hook_decision } from '#scripts/josh/hook-decision'
2
+
3
+ // Whether a run owes its end-of-run retrospective, single-sourced so `run:step` and `backlog:drive`
4
+ // never disagree on it (joshuafolkken/kit#2750): a drive that read "owed" where `run:step` read "not
5
+ // owed" handed back at every drain to a session that had nothing to run.
6
+
7
+ // The opt-in switch that gates the retrospective (joshuafolkken/kit#2370). It defaults off, so an unset
8
+ // variable owes no retrospective.
9
+ const RETROSPECTIVE_ENV_KEY = 'JOSH_RETROSPECTIVE'
10
+
11
+ interface RetrospectiveFacts {
12
+ is_retrospective_enabled: boolean
13
+ is_lane_child: boolean
14
+ is_retrospective_done: boolean
15
+ is_consumer: boolean
16
+ }
17
+
18
+ function is_enabled(): boolean {
19
+ return hook_decision.is_switch_opt_in(RETROSPECTIVE_ENV_KEY)
20
+ }
21
+
22
+ // False when the switch is off, for a dispatched lane child (the batch runs one at its own end, never a
23
+ // child's), once it has run this invocation, and in a consumer checkout, where the kit-only command is
24
+ // refused. The switch is read first, so a run that has not opted in never consults the other three.
25
+ function is_owed(facts: RetrospectiveFacts): boolean {
26
+ if (!facts.is_retrospective_enabled) return false
27
+
28
+ return !facts.is_lane_child && !facts.is_retrospective_done && !facts.is_consumer
29
+ }
30
+
31
+ const run_retrospective = { RETROSPECTIVE_ENV_KEY, is_enabled, is_owed }
32
+
33
+ export type { RetrospectiveFacts }
34
+ export { run_retrospective }
@@ -9,6 +9,7 @@ import { run_carry, type CarryRead } from './run-carry'
9
9
  import { run_event_scope } from './run-event-scope'
10
10
  import { run_event_stream } from './run-event-stream'
11
11
  import { run_prep_cli } from './run-prep-cli'
12
+ import { run_retrospective } from './run-retrospective'
12
13
  import { run_step, type StepInput } from './run-step'
13
14
 
14
15
  // `josh run:step <N>` — print the run's next single action, computed from the event stream, the carry
@@ -22,10 +23,6 @@ const SUCCESS_EXIT_CODE = 0
22
23
  const FAILURE_EXIT_CODE = 1
23
24
  const ARGV_OFFSET = 2
24
25
  const USAGE = 'Usage: josh run:step <issue-number>'
25
- // The opt-in switch that gates the end-of-run retrospective (joshuafolkken/kit#2370). Read here rather
26
- // than in `run-step.ts` so the position logic stays a pure function of its input; it defaults off, so an
27
- // unset variable prints no retrospective step.
28
- const RETROSPECTIVE_ENV_KEY = 'JOSH_RETROSPECTIVE'
29
26
 
30
27
  interface RunReads {
31
28
  carry_kind: CarryRead['kind']
@@ -83,7 +80,8 @@ async function gather(issue_number: string): Promise<StepInput> {
83
80
  is_at_cut_cap: run_reads.is_at_cut_cap,
84
81
  is_lane_child: lane_child_marker.is_child_of(process.cwd()),
85
82
  is_consumer: doctor_consumer.is_kit_consumer(find_package_directory(process.cwd())),
86
- is_retrospective_enabled: hook_decision.is_switch_opt_in(RETROSPECTIVE_ENV_KEY),
83
+ // Read here rather than in `run-step.ts` so the position logic stays a pure function of its input.
84
+ is_retrospective_enabled: run_retrospective.is_enabled(),
87
85
  has_changes: parts.has_changes,
88
86
  has_completion_callback: agent_role_profile.has_completion_callback(),
89
87
  }
@@ -2,6 +2,7 @@ import { git_stash } from '#scripts/git/git-stash'
2
2
  import { latest_scope_cli } from '#scripts/version/latest-scope-cli'
3
3
  import type { CarryRead } from './run-carry'
4
4
  import { run_event_stream } from './run-event-stream'
5
+ import { run_retrospective } from './run-retrospective'
5
6
 
6
7
  // `josh run:step <N>` — the run's next single action, computed from three mechanical inputs and nothing
7
8
  // else (joshuafolkken/kit#2248). It is the child of epic #2166 that lifts the fold from an *event* to a
@@ -174,23 +175,10 @@ const EVENT_ACTIONS: Record<string, (issue_number: string) => StepAction> = {
174
175
  // (joshuafolkken/kit#2297).
175
176
  const PARENT_ONLY_EVENTS: ReadonlySet<string> = new Set([KIND.MERGE, KIND.OUTAGE])
176
177
 
177
- // Whether a run at a winding-down position still owes its end-of-run retrospective
178
- // (joshuafolkken/kit#2328) — false when the switch is off (the opt-in default, joshuafolkken/kit#2370),
179
- // for a dispatched lane child, where the batch runs one at its own end and never a child's (the
180
- // `release:scope` precedent), for a run whose retrospective has already run this invocation, and in a
181
- // consumer checkout, where the kit-only command is refused. The switch is read first, so a run that has
182
- // not opted in never consults the other three. The two positions that consult it are the drain and the
183
- // stop; both read exactly this, so the exclusion set is single-sourced.
184
- function is_retrospective_owed(input: StepInput): boolean {
185
- if (!input.is_retrospective_enabled) return false
186
-
187
- return !input.is_lane_child && !input.is_retrospective_done && !input.is_consumer
188
- }
189
-
190
178
  // A stopped run's last owed step is the end-of-run retrospective. When it is not owed the position stops
191
179
  // with no command to run, which is what the position meant before the retrospective existed.
192
180
  function stop_action(input: StepInput): StepAction {
193
- return is_retrospective_owed(input) ? command(RETROSPECTIVE_COMMAND) : verdict(STOP)
181
+ return run_retrospective.is_owed(input) ? command(RETROSPECTIVE_COMMAND) : verdict(STOP)
194
182
  }
195
183
 
196
184
  // The drain fires the retrospective *before* the idle watch, not after it (joshuafolkken/kit#2335): the
@@ -199,7 +187,7 @@ function stop_action(input: StepInput): StepAction {
199
187
  // run proceeds to that watch, which is a `WAIT` rather than a `STOP`: the backlog is empty but the run is
200
188
  // not ending, and the real `STOP` follows when the watch expires with the retrospective already done.
201
189
  function drain_action(input: StepInput): StepAction {
202
- return is_retrospective_owed(input) ? command(RETROSPECTIVE_COMMAND) : verdict(WAIT)
190
+ return run_retrospective.is_owed(input) ? command(RETROSPECTIVE_COMMAND) : verdict(WAIT)
203
191
  }
204
192
 
205
193
  // The hand-off a `backlogrun` parent without a completion callback takes right after dispatching
@@ -1,12 +1,16 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { release_age } from '#scripts/version/release-age'
3
4
  import { yaml_document } from '#scripts/yaml/yaml-document'
4
5
  import { dump } from 'js-yaml'
5
6
  import { z } from 'zod'
6
7
 
7
8
  const WORKSPACE_FILE = 'pnpm-workspace.yaml'
8
9
  const PROJECT_CONFIG_FILE = '.aikido'
10
+ const NPMRC_FILE = '.npmrc'
9
11
  const EXCLUSIONS_FIELD = 'minimumPackageAgeExclusions'
12
+ const AGE_FIELD = 'minimumPackageAgeHours'
13
+ const MINUTES_PER_HOUR = 60
10
14
  const workspace_schema = z.object({ minimumReleaseAgeExclude: z.array(z.string()).optional() })
11
15
 
12
16
  function read_mapping(value: unknown, label: string): Record<string, unknown> {
@@ -72,27 +76,68 @@ function replace_safe_chain_block(existing: string, safe_chain: Record<string, u
72
76
  return `${prefix}${prefix ? '\n' : ''}${replacement}${suffix}`
73
77
  }
74
78
 
75
- function merge_project_config(existing: string, workspace: string): string {
76
- const source = workspace_schema.parse(yaml_document.parse_yaml(workspace))
77
- const exclusions = source.minimumReleaseAgeExclude ?? []
78
- const aikido = yaml_document.parse_yaml(existing)
79
- const safe_chain = read_mapping(aikido['safe-chain'], 'safe-chain')
79
+ // Safe Chain enforces its own minimum package age (48 hours by default), so CI refused lockfiles pnpm
80
+ // had resolved under `.npmrc`'s shorter window (kit #2743). `.npmrc` is the source: its minutes are
81
+ // floored to whole hours so Safe Chain is never stricter than pnpm, and an undeclared window writes
82
+ // nothing; an explicit `0` opt-out is written as 0 so an earlier synchronized age cannot outlive it.
83
+ function package_age_hours(npmrc: string): number | undefined {
84
+ const minutes = release_age.parse_declared_minimum_release_age(npmrc)
85
+ if (minutes === undefined) return undefined
86
+
87
+ return Math.floor(minutes / MINUTES_PER_HOUR)
88
+ }
89
+
90
+ function merge_age(safe_chain: Record<string, unknown>, npmrc: string): Record<string, unknown> {
91
+ const hours = package_age_hours(npmrc)
92
+ if (hours === undefined) return safe_chain
93
+
94
+ return { ...safe_chain, [AGE_FIELD]: hours }
95
+ }
96
+
97
+ function merge_exclusions(
98
+ safe_chain: Record<string, unknown>,
99
+ exclusions: ReadonlyArray<string>,
100
+ ): Record<string, unknown> {
80
101
  const npm = read_mapping(safe_chain['npm'], 'safe-chain.npm')
81
- if (JSON.stringify(npm[EXCLUSIONS_FIELD]) === JSON.stringify(exclusions)) return existing
82
- if (exclusions.length === 0 && existing.length === 0) return existing
83
102
 
84
- const merged = { ...safe_chain, npm: { ...npm, [EXCLUSIONS_FIELD]: exclusions } }
103
+ return { ...safe_chain, npm: { ...npm, [EXCLUSIONS_FIELD]: exclusions } }
104
+ }
105
+
106
+ function has_nothing_to_write(
107
+ existing: string,
108
+ exclusions: ReadonlyArray<string>,
109
+ npmrc: string,
110
+ ): boolean {
111
+ return existing.length === 0 && exclusions.length === 0 && package_age_hours(npmrc) === undefined
112
+ }
113
+
114
+ function read_exclusions(workspace: string): ReadonlyArray<string> {
115
+ return workspace_schema.parse(yaml_document.parse_yaml(workspace)).minimumReleaseAgeExclude ?? []
116
+ }
117
+
118
+ function merge_project_config(existing: string, workspace: string, npmrc = ''): string {
119
+ const exclusions = read_exclusions(workspace)
120
+ const aikido = yaml_document.parse_yaml(existing)
121
+ const safe_chain = read_mapping(aikido['safe-chain'], 'safe-chain')
122
+ const merged = merge_age(merge_exclusions(safe_chain, exclusions), npmrc)
123
+ if (JSON.stringify(merged) === JSON.stringify(safe_chain)) return existing
124
+ if (has_nothing_to_write(existing, exclusions, npmrc)) return existing
85
125
 
86
126
  return replace_safe_chain_block(existing, merged)
87
127
  }
88
128
 
129
+ function read_optional(file_path: string): string {
130
+ return existsSync(file_path) ? readFileSync(file_path, 'utf8') : ''
131
+ }
132
+
89
133
  function sync_project_config(root: string): boolean {
90
134
  const workspace_path = path.join(root, WORKSPACE_FILE)
91
135
  if (!existsSync(workspace_path)) return false
92
136
  const config_path = path.join(root, PROJECT_CONFIG_FILE)
93
- const existing = existsSync(config_path) ? readFileSync(config_path, 'utf8') : ''
137
+ const existing = read_optional(config_path)
94
138
  const workspace = readFileSync(workspace_path, 'utf8')
95
- const merged = merge_project_config(existing, workspace)
139
+ const npmrc = read_optional(path.join(root, NPMRC_FILE))
140
+ const merged = merge_project_config(existing, workspace, npmrc)
96
141
  if (merged === existing) return false
97
142
  writeFileSync(config_path, merged)
98
143
 
@@ -371,7 +371,6 @@ function report_repository_settings(name_with_owner: string | undefined): void {
371
371
 
372
372
  function sync_project_artifacts(is_force: boolean): void {
373
373
  sync_ai_copy_all(is_force)
374
- project_config.sync_project_config(PROJECT_ROOT)
375
374
  sync_prettier_config(path.join(PROJECT_ROOT, 'prettier.config.js'))
376
375
  sync_playwright_config(path.join(PROJECT_ROOT, 'playwright.config.ts'))
377
376
  sync_deploy_vps(path.join(PROJECT_ROOT, '.github/workflows/deploy-vps.yml'))
@@ -382,6 +381,9 @@ function sync_project_artifacts(is_force: boolean): void {
382
381
 
383
382
  sync_sonar_with_template(name_with_owner, is_force)
384
383
  sync_config_files()
384
+ // After `sync_config_files`, which may add the `.npmrc` window `.aikido`'s age is derived from
385
+ // (joshuafolkken/kit#2743).
386
+ project_config.sync_project_config(PROJECT_ROOT)
385
387
  sync_package_json_migrations(path.join(PROJECT_ROOT, PACKAGE_JSON))
386
388
  report_repository_settings(name_with_owner)
387
389
  }
@@ -19,12 +19,18 @@ const NPMRC_PATH = '.npmrc'
19
19
  const release_times_schema = z.record(z.string(), z.string())
20
20
  const MS_PER_MINUTE = 60_000
21
21
 
22
+ // The window `.npmrc` content declares, or nothing when it does not declare one — an explicit
23
+ // `minimum-release-age=0` stays 0 rather than collapsing into "not declared".
24
+ function parse_declared_minimum_release_age(npmrc_content: string): number | undefined {
25
+ const raw = MINIMUM_RELEASE_AGE_RE.exec(npmrc_content)?.[1]
26
+
27
+ return raw === undefined ? undefined : Number(raw)
28
+ }
29
+
22
30
  // The quarantine window in minutes from `.npmrc`; a missing or malformed entry means no
23
31
  // quarantine, matching pnpm's own default for the setting.
24
32
  function parse_minimum_release_age(npmrc_content: string): number {
25
- const raw = MINIMUM_RELEASE_AGE_RE.exec(npmrc_content)?.[1]
26
-
27
- return raw === undefined ? NO_QUARANTINE_MINUTES : Number(raw)
33
+ return parse_declared_minimum_release_age(npmrc_content) ?? NO_QUARANTINE_MINUTES
28
34
  }
29
35
 
30
36
  // Stable releases only, on the requested major, published at or before the cutoff. The
@@ -82,9 +88,7 @@ function select_aged_version(
82
88
  // `minimum-release-age=0` opt-out stop the walk instead of falling through to an ancestor's policy.
83
89
  function read_declared_minimum_release_age(npmrc_path: string): number | undefined {
84
90
  try {
85
- const raw = MINIMUM_RELEASE_AGE_RE.exec(readFileSync(npmrc_path, 'utf8'))?.[1]
86
-
87
- return raw === undefined ? undefined : Number(raw)
91
+ return parse_declared_minimum_release_age(readFileSync(npmrc_path, 'utf8'))
88
92
  } catch {
89
93
  return undefined
90
94
  }
@@ -138,6 +142,7 @@ function read_nearest_minimum_release_age(start: string, boundary?: string): num
138
142
  const release_age = {
139
143
  release_times_schema,
140
144
  parse_minimum_release_age,
145
+ parse_declared_minimum_release_age,
141
146
  read_minimum_release_age,
142
147
  read_nearest_minimum_release_age,
143
148
  select_aged_version,