jackherd 1.13.0 → 1.14.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/src/check/plan.ts CHANGED
@@ -3,9 +3,10 @@ import { STACKS, type Stack, type StackProfile } from '../config/detect'
3
3
  import { agentTextReviewers } from './agent-text-reviewers'
4
4
  import { type ChangedFile, generatorOwnedFiles, stagedBuildArtifacts } from './changes'
5
5
  import { tokenize } from './command-line'
6
+ import { hasNamedTest, isSourceFile, isTestFile, testStemsOf } from './file-kinds'
6
7
  import { matchesAnyGlob } from './glob'
7
8
  import { CHECK_KIND, type CheckKind } from './result'
8
- import { isUnder, looseDirectories, packageRootsFor } from './roots'
9
+ import { isUnder, looseDirectories, nearestRootOf, packageRootOf, packageRootsFor } from './roots'
9
10
  import { projectChecksFor, publicContentReviewers, reviewersFor } from './routing'
10
11
  import type { CheckConfig, ProjectCheck } from './schema'
11
12
 
@@ -21,7 +22,7 @@ export type PlannedCheck = {
21
22
  kind: CheckKind
22
23
  }
23
24
 
24
- export type PackageCommands = Pick<JackherdConfig['commands'], 'test' | 'typecheck'>
25
+ export type PackageCommands = Pick<JackherdConfig['commands'], 'test' | 'typecheck' | 'lint'>
25
26
 
26
27
  /** What the plan asks of the disk, by the markers of the stack (`checkStackOf`). */
27
28
  export type Filesystem = {
@@ -32,9 +33,11 @@ export type Filesystem = {
32
33
  /** Whether the directory holds a type marker of the stack (`tsconfig.json`, `phpstan.neon`). */
33
34
  hasTypeConfig: (dir: string) => boolean
34
35
  hasTests: (dir: string) => boolean
36
+ /** Every file of the repository that git tracks or does not ignore; read only for the name rule. */
37
+ repositoryFiles: () => readonly string[]
35
38
  }
36
39
 
37
- /** The stack as the check reads it; without a detected stack, every stack's markers and patterns together. */
40
+ /** The stack as the check reads it; with none or several detected, every stack's markers and patterns together. */
38
41
  export type CheckStack = {
39
42
  rootMarkers: string[]
40
43
  typeMarkers: string[]
@@ -44,6 +47,14 @@ export type CheckStack = {
44
47
  lintGlobs: string[]
45
48
  }
46
49
 
50
+ /** What the check reads only where no stack is detected at all (not where several are). */
51
+ export type NoStackFallback = {
52
+ /** `check.roots` and the repository root `.`: a file no root marker holds belongs to the nearest one above it. */
53
+ roots: readonly string[]
54
+ /** `commands.lint`, run once from `.` where no `check.lintFilesCommand` is set. */
55
+ lintCommand?: string
56
+ }
57
+
47
58
  /** What the plan reads from the settings: the test and type commands, the `check` block and the stack's patterns. */
48
59
  export type PlanInputs = {
49
60
  commands: PackageCommands
@@ -51,6 +62,8 @@ export type PlanInputs = {
51
62
  stack: Pick<CheckStack, 'lintGlobs' | 'sourceFile'>
52
63
  /** Repository paths of the kit's copies (`.jackherd/lock.json`); their tests run where the kit keeps the source. */
53
64
  kitCopies: readonly string[]
65
+ /** Null where a stack is detected, or several. */
66
+ noStackFallback: NoStackFallback | null
54
67
  }
55
68
 
56
69
  export type CheckPlan = {
@@ -63,8 +76,22 @@ export type CheckPlan = {
63
76
  checks: PlannedCheck[]
64
77
  /** The changed source files of the stack no planned test reaches, kit copies aside; a check without a test is no green. */
65
78
  untestedSources: string[]
79
+ /**
80
+ * Without a detected stack, the changed source files under a root found by `NoStackFallback.roots` that no test file
81
+ * of their name covers: a run from such a root reaches every file, so the name decides, and only as a warning.
82
+ */
83
+ sourcesWithoutNamedTest: string[]
66
84
  }
67
85
 
86
+ /** The roots of a change: by root marker, and without a detected stack by `NoStackFallback.roots` for the rest. */
87
+ type ChangeRoots = { roots: string[]; fallbackRoots: string[]; fallbackPaths: string[] }
88
+
89
+ /** What reaches a changed file: a test run from a directory above it, or a fired project check of kind tests whose globs (or no globs) match it. */
90
+ type TestReach = { dirs: readonly string[]; checks: readonly ProjectCheck[] }
91
+
92
+ /** Which changed source files no test covers: the paths under marker roots by reach, the fallback paths by name. */
93
+ type Coverage = Pick<CheckPlan, 'untestedSources' | 'sourcesWithoutNamedTest'>
94
+
68
95
  export const isSlow = (check: PlannedCheck): boolean =>
69
96
  check.kind === CHECK_KIND.types || check.kind === CHECK_KIND.tests
70
97
 
@@ -92,6 +119,36 @@ export const checkStackOf = (stack: Stack | null): CheckStack => {
92
119
  const lintedPaths = (paths: readonly string[], lintGlobs: readonly string[]): string[] =>
93
120
  lintGlobs.length === 0 ? [...paths] : paths.filter((path) => matchesAnyGlob(path, lintGlobs))
94
121
 
122
+ /** A predicate from the configured globs, or the given one where none are configured. */
123
+ const globsOr =
124
+ (globs: readonly string[] | undefined, fallback: (path: string) => boolean) =>
125
+ (path: string): boolean =>
126
+ globs === undefined ? fallback(path) : matchesAnyGlob(path, globs)
127
+
128
+ /** Each path no root marker holds, with the nearest fallback root above it; paths without one are left out. */
129
+ const fallbackRootsOf = (
130
+ paths: readonly string[],
131
+ fallback: readonly string[],
132
+ isProjectRoot: Filesystem['isProjectRoot'],
133
+ ): { path: string; root: string }[] =>
134
+ paths.flatMap((path) => {
135
+ const root = packageRootOf(path, isProjectRoot) === null ? nearestRootOf(path, fallback) : null
136
+ return root === null ? [] : [{ path, root }]
137
+ })
138
+
139
+ const changeRootsOf = (
140
+ paths: readonly string[],
141
+ fallback: readonly string[],
142
+ { isProjectRoot, isWorkspaceRoot }: Pick<Filesystem, 'isProjectRoot' | 'isWorkspaceRoot'>,
143
+ ): ChangeRoots => {
144
+ const isTestable = (root: string) => !isWorkspaceRoot(root)
145
+ const fallbackPairs = fallbackRootsOf(paths, fallback, isProjectRoot)
146
+ const fallbackPaths = fallbackPairs.map(({ path }) => path)
147
+ const fallbackRoots = unique(fallbackPairs.map(({ root }) => root)).filter(isTestable)
148
+ const markerRoots = packageRootsFor(paths, isProjectRoot).filter(isTestable)
149
+ return { roots: unique([...markerRoots, ...fallbackRoots]), fallbackRoots, fallbackPaths }
150
+ }
151
+
95
152
  /** No lint without a `lintFilesCommand`, none is guessed; none either when no changed file is the stack's to lint. */
96
153
  const lintChecks = (command: string | undefined, paths: readonly string[]): PlannedCheck[] =>
97
154
  command === undefined || paths.length === 0
@@ -106,6 +163,17 @@ const lintChecks = (command: string | undefined, paths: readonly string[]): Plan
106
163
  },
107
164
  ]
108
165
 
166
+ /** Without a detected stack and a `lintFilesCommand`, the project's lint once from the repository root. */
167
+ const projectLint = (command: string | undefined): PlannedCheck[] =>
168
+ command === undefined ? [] : [{ name: 'Lint', command, argv: tokenize(command), cwd: '.', kind: CHECK_KIND.lint }]
169
+
170
+ const lintOf = (
171
+ lintFilesCommand: string | undefined,
172
+ noStackFallback: NoStackFallback | null,
173
+ paths: readonly string[],
174
+ ): PlannedCheck[] =>
175
+ lintFilesCommand === undefined ? projectLint(noStackFallback?.lintCommand) : lintChecks(lintFilesCommand, paths)
176
+
109
177
  const projectChecks = (fired: readonly ProjectCheck[]): PlannedCheck[] =>
110
178
  fired.map(({ name, command, kind }) => ({
111
179
  name,
@@ -127,18 +195,15 @@ const inDirectory = (kind: CheckKind, label: string, command: string, dir: strin
127
195
  const typeChecks = (
128
196
  roots: readonly string[],
129
197
  command: string | undefined,
130
- hasTypeConfig: Filesystem['hasTypeConfig'],
198
+ isTypeRoot: (root: string) => boolean,
131
199
  ): PlannedCheck[] =>
132
200
  command === undefined
133
201
  ? []
134
- : roots.filter(hasTypeConfig).map((root) => inDirectory(CHECK_KIND.types, 'Types', command, root))
202
+ : roots.filter(isTypeRoot).map((root) => inDirectory(CHECK_KIND.types, 'Types', command, root))
135
203
 
136
204
  const testChecks = (dirs: readonly string[], command: string | undefined): PlannedCheck[] =>
137
205
  command === undefined ? [] : dirs.map((dir) => inDirectory(CHECK_KIND.tests, 'Tests', command, dir))
138
206
 
139
- /** What reaches a changed file: a test run from a directory above it, or a fired project check of kind tests whose globs (or no globs) match it. */
140
- type TestReach = { dirs: readonly string[]; checks: readonly ProjectCheck[] }
141
-
142
207
  const isReached = (path: string, { dirs, checks }: TestReach): boolean =>
143
208
  dirs.some((dir) => isUnder(path, dir)) ||
144
209
  checks.some(({ globs }) => globs === undefined || matchesAnyGlob(path, globs))
@@ -147,8 +212,43 @@ const isReached = (path: string, { dirs, checks }: TestReach): boolean =>
147
212
  const untestedSourcesOf = (
148
213
  paths: readonly string[],
149
214
  reach: TestReach,
150
- { sourceFile, kitCopies }: Pick<PlanInputs['stack'], 'sourceFile'> & Pick<PlanInputs, 'kitCopies'>,
151
- ): string[] => paths.filter((path) => sourceFile.test(path) && !kitCopies.includes(path) && !isReached(path, reach))
215
+ { isSource, kitCopies }: { isSource: (path: string) => boolean; kitCopies: readonly string[] },
216
+ ): string[] => paths.filter((path) => isSource(path) && !kitCopies.includes(path) && !isReached(path, reach))
217
+
218
+ /** The source files among `paths` with no test file of their name in the repository, read only when there are any. */
219
+ const sourcesWithoutNamedTestOf = (
220
+ paths: readonly string[],
221
+ testChecks: readonly ProjectCheck[],
222
+ { check, kitCopies }: Pick<PlanInputs, 'check' | 'kitCopies'>,
223
+ repositoryFiles: Filesystem['repositoryFiles'],
224
+ ): string[] => {
225
+ const isTest = globsOr(check.testFiles, isTestFile)
226
+ const isNamedSource = globsOr(check.sourceFiles, isSourceFile)
227
+ const isSource = (path: string) => !isTest(path) && isNamedSource(path)
228
+ const candidates = untestedSourcesOf(paths, { dirs: [], checks: testChecks }, { isSource, kitCopies })
229
+ if (candidates.length === 0) return []
230
+ const testStems = testStemsOf(repositoryFiles(), isTest)
231
+ return candidates.filter((path) => !hasNamedTest(path, testStems))
232
+ }
233
+
234
+ const coverageOf = (
235
+ { paths, fallbackPaths }: { paths: readonly string[]; fallbackPaths: readonly string[] },
236
+ reach: TestReach,
237
+ { check, stack, kitCopies }: Pick<PlanInputs, 'check' | 'stack' | 'kitCopies'>,
238
+ repositoryFiles: Filesystem['repositoryFiles'],
239
+ ): Coverage => {
240
+ const markerPaths = paths.filter((path) => !fallbackPaths.includes(path))
241
+ const isSource = globsOr(check.sourceFiles, (path) => stack.sourceFile.test(path))
242
+ return {
243
+ untestedSources: untestedSourcesOf(markerPaths, reach, { isSource, kitCopies }),
244
+ sourcesWithoutNamedTest: sourcesWithoutNamedTestOf(
245
+ fallbackPaths,
246
+ reach.checks,
247
+ { check, kitCopies },
248
+ repositoryFiles,
249
+ ),
250
+ }
251
+ }
152
252
 
153
253
  /** The reviewers a set of changed paths calls for, each named once: routed, public content and agent texts. */
154
254
  export const reviewersOf = (paths: readonly string[], config: CheckConfig): string[] => [
@@ -162,15 +262,17 @@ export const reviewersOf = (paths: readonly string[], config: CheckConfig): stri
162
262
  /**
163
263
  * Decides, without touching the disk or running anything, what a set of changed files calls for:
164
264
  * which package roots to type-check and test, which project checks fire, which reviewers to start,
165
- * and which changed source files no test covers. A root that gathers workspaces is never tested.
265
+ * and which changed source files no test covers. A root that gathers workspaces is never tested. Without a
266
+ * detected stack, a file no root marker holds is tested from its `NoStackFallback.roots` entry and judged by its name.
166
267
  */
167
268
  export const planChecks = (
168
269
  files: readonly ChangedFile[],
169
- { commands, check: config, stack, kitCopies }: PlanInputs,
270
+ { commands, check: config, stack, kitCopies, noStackFallback }: PlanInputs,
170
271
  filesystem: Filesystem,
171
272
  ): CheckPlan => {
172
273
  const paths = files.map((file) => file.path)
173
- const roots = packageRootsFor(paths, filesystem.isProjectRoot).filter((root) => !filesystem.isWorkspaceRoot(root))
274
+ const { roots, fallbackRoots, fallbackPaths } = changeRootsOf(paths, noStackFallback?.roots ?? [], filesystem)
275
+ const isTypeRoot = (root: string) => fallbackRoots.includes(root) || filesystem.hasTypeConfig(root)
174
276
  const loose = config.looseTests ? looseDirectories(paths, roots, filesystem.hasTests) : []
175
277
  const fired = projectChecksFor(paths, config.checks)
176
278
  const testDirs = commands.test === undefined ? [] : [...roots, ...loose]
@@ -178,9 +280,9 @@ export const planChecks = (
178
280
  paths.length === 0
179
281
  ? []
180
282
  : [
181
- ...lintChecks(config.lintFilesCommand, lintedPaths(paths, stack.lintGlobs)),
283
+ ...lintOf(config.lintFilesCommand, noStackFallback, lintedPaths(paths, stack.lintGlobs)),
182
284
  ...projectChecks(fired),
183
- ...typeChecks(roots, commands.typecheck, filesystem.hasTypeConfig),
285
+ ...typeChecks(roots, commands.typecheck, isTypeRoot),
184
286
  ...testChecks(testDirs, commands.test),
185
287
  ]
186
288
  const reach: TestReach = { dirs: testDirs, checks: fired.filter((check) => check.kind === CHECK_KIND.tests) }
@@ -191,6 +293,6 @@ export const planChecks = (
191
293
  generated: generatorOwnedFiles(paths, config.generatorOwned),
192
294
  artifacts: stagedBuildArtifacts(files, config.buildArtifacts),
193
295
  checks,
194
- untestedSources: untestedSourcesOf(paths, reach, { sourceFile: stack.sourceFile, kitCopies }),
296
+ ...coverageOf({ paths, fallbackPaths }, reach, { check: config, stack, kitCopies }, filesystem.repositoryFiles),
195
297
  }
196
298
  }
@@ -1,7 +1,8 @@
1
1
  import { STACKS, type StackDetection, stackOfCommand } from '../config/detect'
2
+ import { PROJECT_CONFIG_PATH } from '../config/location'
2
3
  import { CLI_NAME } from '../version/release'
3
4
  import { type ChangedFile, STATUS_COMMAND } from './changes'
4
- import type { CheckPlan } from './plan'
5
+ import type { CheckPlan, PackageCommands } from './plan'
5
6
  import { CHECK_STATUS, type CheckResult, type CheckStatus, RUN_MODE, type RunMode, SKIP_SLOW_FLAG } from './result'
6
7
 
7
8
  const STATUS_ICON = { pass: '✅', fail: '❌', warn: '⚠️ ', skipped: '⏭️ ' } as const
@@ -45,6 +46,48 @@ export const untestedResult = (sources: readonly string[], mode: RunMode): Check
45
46
  durationMs: 0,
46
47
  }
47
48
 
49
+ /** Without a detected stack, the changed source files no test file of their name covers: a warning in every run mode. */
50
+ export const namedTestResult = (sources: readonly string[]): CheckResult | null =>
51
+ sources.length === 0
52
+ ? null
53
+ : {
54
+ name: 'Tests by name',
55
+ command: 'check.testFiles',
56
+ status: CHECK_STATUS.warn,
57
+ detail: [
58
+ `no test file named like ${plural(sources.length, 'changed source file')}; add one, or name the test files in \`check.testFiles\``,
59
+ ...sources,
60
+ ].join('\n'),
61
+ durationMs: 0,
62
+ }
63
+
64
+ /** Without a detected stack nothing derives `commands.test`, and a check without it tests nothing, so it fails. */
65
+ export const missingTestCommandResult = (command: string | undefined, detection: StackDetection): CheckResult | null =>
66
+ command !== undefined || detection.kind !== 'none'
67
+ ? null
68
+ : {
69
+ name: 'Test command',
70
+ command: 'commands.test',
71
+ status: CHECK_STATUS.fail,
72
+ detail: `no stack detected and no \`commands.test\` set; set it in ${PROJECT_CONFIG_PATH} (\`${CLI_NAME} config test\` names the line, \`/jh-start\` proposes it from the CI)`,
73
+ durationMs: 0,
74
+ }
75
+
76
+ /** Without a detected stack a missing type or lint command is a note, not a result: not every language has the step. */
77
+ export const missingCommandNotes = (
78
+ commands: PackageCommands,
79
+ lintFilesCommand: string | undefined,
80
+ detection: StackDetection,
81
+ ): string[] => {
82
+ if (detection.kind !== 'none') return []
83
+ return [
84
+ ...(commands.typecheck === undefined ? [`${CLI_NAME}: no \`commands.typecheck\` set; no type check runs.`] : []),
85
+ ...(commands.lint === undefined && lintFilesCommand === undefined
86
+ ? [`${CLI_NAME}: neither \`check.lintFilesCommand\` nor \`commands.lint\` set; no lint runs.`]
87
+ : []),
88
+ ]
89
+ }
90
+
48
91
  /** The lock files of known stacks the repository holds; none means it has not installed yet. */
49
92
  const presentLockFiles = (detection: StackDetection): readonly string[] => {
50
93
  if (detection.kind === 'found') return [detection.lockFile]
@@ -46,6 +46,15 @@ export const kitCopyPaths = (manifest: unknown): string[] => {
46
46
  /** Whether a test run from `root` reaches `path`; from `.` it reaches every path. */
47
47
  export const isUnder = (path: string, root: string): boolean => root === '.' || path.startsWith(`${root}/`)
48
48
 
49
+ /**
50
+ * The deepest of `roots` above `path`; null when none is. Without a detected stack, a file no root marker holds
51
+ * belongs to the nearest `check.roots` entry, with the repository root `.` among them.
52
+ */
53
+ export const nearestRootOf = (path: string, roots: readonly string[]): string | null =>
54
+ roots
55
+ .filter((root) => isUnder(path, root))
56
+ .reduce<string | null>((nearest, root) => (nearest === null || root.length > nearest.length ? root : nearest), null)
57
+
49
58
  /**
50
59
  * Directories with test files that belong to no package root, which would otherwise be tested by
51
60
  * nobody. The repository root `.` is excluded: a test run from there walks the whole repository.
@@ -20,6 +20,9 @@ const glob = nonEmpty.refine((value) => !/[{}[\]]/.test(value), 'Braces and brac
20
20
 
21
21
  const globs = z.array(glob).min(1)
22
22
 
23
+ /** A directory from the repository root as the check compares it: `./crates/core/` reads as `crates/core`. */
24
+ const directory = nonEmpty.transform((value) => value.replace(/^(\.\/)+/, '').replace(/\/+$/, '') || '.')
25
+
23
26
  /** Staged output that belongs to a build, never to a commit. */
24
27
  export const DEFAULT_BUILD_ARTIFACT_GLOBS = [
25
28
  '**/tsup.config.bundled_*',
@@ -88,6 +91,15 @@ export const checkConfigSchema = z.strictObject({
88
91
  skillBudgets: skillBudgets.optional(),
89
92
  /** Also run tests in directories that hold test files but belong to no package. */
90
93
  looseTests: z.boolean().default(false),
94
+ /**
95
+ * Package roots, as directories from the repository root, read only where no stack is detected: a changed file
96
+ * belongs to the nearest one above it, else to the repository root `.`.
97
+ */
98
+ roots: z.array(directory).min(1).optional(),
99
+ /** The files a test covers; replaces the stack's pattern, or the rule of thumb where no stack is detected. */
100
+ sourceFiles: globs.optional(),
101
+ /** The test files; replaces the stack's pattern, or the rule of thumb where no stack is detected. */
102
+ testFiles: globs.optional(),
91
103
  })
92
104
 
93
105
  export type CheckConfig = z.infer<typeof checkConfigSchema>
@@ -10,7 +10,7 @@ import {
10
10
  } from '../config/detect'
11
11
  import { PROJECT_CONFIG_PATH } from '../config/location'
12
12
  import type { PlanLanguage } from '../vocabulary'
13
- import type { PackageCommands } from './plan'
13
+ import type { NoStackFallback, PackageCommands } from './plan'
14
14
  import { type CheckConfig, checkConfigSchema } from './schema'
15
15
 
16
16
  export const CONFIG_PATH = PROJECT_CONFIG_PATH
@@ -33,6 +33,10 @@ export type CheckSettings = {
33
33
  /** The stack a detection decided, by lock file or manifest; null for none or several, where the check reads every stack's markers. */
34
34
  export const stackOf = (detection: StackDetection): Stack | null => (isDecided(detection) ? detection.stack : null)
35
35
 
36
+ /** Without any detected stack: `check.roots` and the repository root `.` as fallback roots, `commands.lint` from the root. */
37
+ export const noStackFallbackOf = ({ stack, check, commands }: CheckSettings): NoStackFallback | null =>
38
+ stack.kind === 'none' ? { roots: [...(check.roots ?? []), '.'], lintCommand: commands.lint } : null
39
+
36
40
  const commandsSchema = jackherdConfigSchema.shape.commands
37
41
  const planDirectorySchema = jackherdConfigSchema.shape.planDirectory
38
42
  const planLanguageSchema = jackherdConfigSchema.shape.planLanguage
@@ -47,9 +51,9 @@ const worktreeDirectorySchema = jackherdConfigSchema.shape.worktreeDirectory
47
51
  export const parseCheckSettings = (raw: unknown, facts: RepositoryFacts): CheckSettings => {
48
52
  const detected = withDetected(z.record(z.string(), z.unknown()).parse(raw), facts)
49
53
  const object = z.record(z.string(), z.unknown()).parse(detected.raw)
50
- const { test, typecheck } = commandsSchema.parse(object.commands)
54
+ const { test, typecheck, lint } = commandsSchema.parse(object.commands)
51
55
  return {
52
- commands: { test, typecheck },
56
+ commands: { test, typecheck, lint },
53
57
  check: checkConfigSchema.parse(object.check ?? {}),
54
58
  planDirectory: planDirectorySchema.parse(object.planDirectory),
55
59
  planLanguage: planLanguageSchema.parse(object.planLanguage),
@@ -21,13 +21,14 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
21
21
  '.claude/skills/implement/SKILL.md': 2190,
22
22
  // P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
23
23
  // Plain words: no plan code without its words in a short question.
24
- '.claude/skills/orchestrate/SKILL.md': 7920,
24
+ // Any language: a pointer in § 0 and a section in events.md for `plan missing`.
25
+ '.claude/skills/orchestrate/SKILL.md': 7929,
25
26
  '.claude/skills/orchestrate/references/acceptance.md': 200,
26
27
  '.claude/skills/orchestrate/references/close-lane.md': 250,
27
28
  '.claude/skills/orchestrate/references/coordinator-handover.md': 551,
28
29
  '.claude/skills/orchestrate/references/draft-review.md': 300,
29
30
  // P33: a source entry the user must read or decide becomes a row of what waits on them, and when it closes.
30
- '.claude/skills/orchestrate/references/events.md': 1110,
31
+ '.claude/skills/orchestrate/references/events.md': 1166,
31
32
  '.claude/skills/orchestrate/references/herd.md': 299,
32
33
  '.claude/skills/orchestrate/references/lane-handover.md': 280,
33
34
  '.claude/skills/orchestrate/references/lane-session.md': 280,
@@ -40,10 +41,11 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
40
41
  '.claude/skills/pre-commit/SKILL.md': 1003,
41
42
  '.claude/skills/review/SKILL.md': 319,
42
43
  '.claude/skills/security-audit/SKILL.md': 519,
43
- '.claude/skills/start/SKILL.md': 607,
44
+ // Any language: step 3 points to proposing the missing commands from the CI; the proposal itself is a reference.
45
+ '.claude/skills/start/SKILL.md': 630,
44
46
  '.claude/skills/uncle-bob/SKILL.md': 466,
45
47
  // P33: grows with the skill above.
46
- '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 8720,
48
+ '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 8729,
47
49
  '.claude/agents/devops.md': 104,
48
50
  '.claude/agents/explore.md': 132,
49
51
  '.claude/agents/implement.md': 303,
@@ -27,7 +27,8 @@ export type PlanSources = {
27
27
  parsed?: PlanRound
28
28
  }
29
29
 
30
- const isOutsideRepository = (path: string): boolean => path.startsWith('..')
30
+ /** A repository path that a link resolved above the repository root. */
31
+ export const isOutsideRepository = (path: string): boolean => path.startsWith('..')
31
32
 
32
33
  /** The file as the base ref has it; null when the ref lacks it or the path leaves the repository. */
33
34
  export const textAtBase = (base: BaseReader, path: string): string | null =>
@@ -113,6 +113,7 @@ import {
113
113
  noTaskSignalDutyLines,
114
114
  otherPullRequestsLine,
115
115
  parallelDutyLines,
116
+ planMissingDutyLines,
116
117
  sourceEventLines,
117
118
  sourceFailingStatusLine,
118
119
  watchEventLine,
@@ -136,6 +137,8 @@ import {
136
137
  import { mainCheckoutDutyLine } from './main-checkout'
137
138
  import { monitorToolAvailable } from './monitor-tool'
138
139
  import { parseWatchOptions, type WatchOptions } from './options'
140
+ import type { PlanMissingEvent, PlanMissingState } from './plan-missing'
141
+ import { readPlanMissingEvents } from './plan-missing-read'
139
142
  import {
140
143
  ENSURE_TRIGGER,
141
144
  type EnsureTrigger,
@@ -311,6 +314,8 @@ type Watching = {
311
314
  laneStalls: StallState
312
315
  /** The open artifact sends in their grace or already reported as `unlinked artifact`; persisted like the fingerprints. */
313
316
  unlinkedArtifacts: UnlinkedState
317
+ /** Per working lane, the plan last reported as `plan missing on base`; persisted like the fingerprints. */
318
+ planMissing: PlanMissingState
314
319
  /** The approval requests of the last poll; null before the first read, so the stored ids stay. */
315
320
  approvalRequests: ApprovalRequestFile[] | null
316
321
  /** The board's appointment table at the last poll and the time it was read at; null before the first read and while the board cannot be read. */
@@ -365,6 +370,7 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
365
370
  noTaskSignals: watching.noTaskSignals,
366
371
  laneStalls: watching.laneStalls,
367
372
  unlinkedArtifacts: watching.unlinkedArtifacts,
373
+ planMissing: watching.planMissing,
368
374
  approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
369
375
  sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
370
376
  appointments: watching.appointments
@@ -665,6 +671,7 @@ const reportOf = async (
665
671
  ...laneModelDutyLines(events, watch.texts),
666
672
  ...parallelDutyLines(events, watch.texts),
667
673
  ...noTaskSignalDutyLines(events, watch.texts),
674
+ ...planMissingDutyLines(events, watch.texts),
668
675
  ...approvalDutyLines(events, watch.texts),
669
676
  ]
670
677
  const status = await statusLinesOf(watch, watching, next)
@@ -1129,6 +1136,27 @@ const unlinkedArtifactEventsOf = (watch: Watch, watching: Watching): UnlinkedArt
1129
1136
  }
1130
1137
  }
1131
1138
 
1139
+ /**
1140
+ * This heartbeat's `plan missing on base` events: the working lanes whose board row, as their worktree has it, links a
1141
+ * plan the base ref lacks, each once; records the state in `watching`. Nothing when the boards or git cannot be read:
1142
+ * the next heartbeat reads again.
1143
+ */
1144
+ const planMissingEventsOf = (watch: Watch, watching: Watching): PlanMissingEvent[] => {
1145
+ try {
1146
+ const { events, state } = readPlanMissingEvents(
1147
+ watch.root,
1148
+ watch.settings,
1149
+ watch.sprintFile,
1150
+ workingLanes(watching),
1151
+ watching.planMissing,
1152
+ )
1153
+ watching.planMissing = state
1154
+ return events
1155
+ } catch {
1156
+ return []
1157
+ }
1158
+ }
1159
+
1132
1160
  /** The progress of the working lanes, with the pull requests of the last GitHub read; nothing while no lane works. */
1133
1161
  const workingProgressOf = async (watch: Watch, watching: Watching): Promise<Record<string, LaneProgress>> => {
1134
1162
  const working = workingLanes(watching)
@@ -1137,13 +1165,14 @@ const workingProgressOf = async (watch: Watch, watching: Watching): Promise<Reco
1137
1165
  return progress
1138
1166
  }
1139
1167
 
1140
- /** The events a heartbeat of a report brings: the parallel-task proposals, the lanes without a task signal, the unlinked artifacts. */
1168
+ /** The events a heartbeat of a report brings: the parallel-task proposals, the lanes without a task signal, the unlinked artifacts, the plans missing on the base. */
1141
1169
  const heartbeatEventsOf = async (watch: Watch, watching: Watching, now: number): Promise<WatchEvent[]> => {
1142
1170
  const progress = await workingProgressOf(watch, watching)
1143
1171
  return [
1144
1172
  ...taskParallelEventsOf(watch, watching, now),
1145
1173
  ...noTaskSignalEventsOf(watch, watching, progress, now),
1146
1174
  ...unlinkedArtifactEventsOf(watch, watching),
1175
+ ...planMissingEventsOf(watch, watching),
1147
1176
  ]
1148
1177
  }
1149
1178
 
@@ -1206,6 +1235,7 @@ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: numbe
1206
1235
  ...taskParallelEventsOf(watch, watching, now),
1207
1236
  ...noTaskSignalEventsOf(watch, watching, progress, now),
1208
1237
  ...unlinkedArtifactEventsOf(watch, watching),
1238
+ ...planMissingEventsOf(watch, watching),
1209
1239
  ]
1210
1240
  for (const event of events) console.log(`${watchEventLine(event, null)}${hintFor(watch, eventReference(event))}`)
1211
1241
  return sent
@@ -1307,6 +1337,7 @@ const initialWatching = (
1307
1337
  noTaskSignals: start.state.noTaskSignals,
1308
1338
  laneStalls: start.state.laneStalls,
1309
1339
  unlinkedArtifacts: start.state.unlinkedArtifacts,
1340
+ planMissing: start.state.planMissing,
1310
1341
  approvalRequests: null,
1311
1342
  appointments: null,
1312
1343
  reportQuestions: null,
@@ -48,7 +48,12 @@ export const watchedLaneProgress = async (
48
48
  }
49
49
 
50
50
  /** The repository path of the plan of a lane's newest row that links one; null without such a row. */
51
- const lanePlanPath = (root: string, settings: PlansSettings, boardFile: string, lane: SprintLane): string | null => {
51
+ export const lanePlanPath = (
52
+ root: string,
53
+ settings: PlansSettings,
54
+ boardFile: string,
55
+ lane: SprintLane,
56
+ ): string | null => {
52
57
  const href = lane.steps.filter((step) => step.planHref !== null).at(-1)?.planHref
53
58
  return href ? resolvePlanPath(dirname(boardRepoPathOf(root, settings, boardFile)), href) : null
54
59
  }
@@ -13,6 +13,7 @@ import type { LaneModelEvent } from './lane-model'
13
13
  import type { LaneParallelEvent } from './lane-parallel'
14
14
  import { laneStalledLine } from './lane-stalled'
15
15
  import { AGENT_STATUS, type LaneSnapshot, NO_AGENT } from './lane-watch'
16
+ import { PLAN_MISSING_EVENT, type PlanMissingEvent, planMissingLine } from './plan-missing'
16
17
  import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
17
18
  import { SOURCE_FIELD } from './source-fields'
18
19
  import { type FieldChange, lineText, type SourceEvent } from './sources'
@@ -161,6 +162,7 @@ const laneEventLine = (event: WatchEvent): string | null => {
161
162
  return `=== ${event.kind} ${terminalSafe(event.lane)} ${phasesText(event.phases)}`
162
163
  if (event.kind === WATCH_EVENT.noTaskSignal) return `=== ${terminalSafe(noTaskSignalLine(event))}`
163
164
  if (event.kind === WATCH_EVENT.laneStalled) return `=== ${terminalSafe(laneStalledLine(event))}`
165
+ if (event.kind === WATCH_EVENT.planMissing) return `=== ${planMissingLine(event)}`
164
166
  return null
165
167
  }
166
168
 
@@ -339,6 +341,14 @@ export const noTaskSignalDutyLines = (events: readonly WatchEvent[], texts: Watc
339
341
  .filter(isNoTaskSignal)
340
342
  .map(({ lane }) => `${terminalSafe(lane)} ${NO_TASK_SIGNAL_EVENT}: ${texts.noTaskSignalDuty}`)
341
343
 
344
+ const isPlanMissing = (event: WatchEvent): event is PlanMissingEvent => event.kind === WATCH_EVENT.planMissing
345
+
346
+ /** What a lane whose plan only its worktree has asks of the coordinator (`orchestrate` § 2 step 0): one line per such lane. */
347
+ export const planMissingDutyLines = (events: readonly WatchEvent[], texts: WatchTexts): string[] =>
348
+ events
349
+ .filter(isPlanMissing)
350
+ .map(({ lane }) => `${terminalSafe(lane)} ${PLAN_MISSING_EVENT}: ${texts.planMissingDuty}`)
351
+
342
352
  const isApproval = (event: WatchEvent): event is ApprovalEvent => event.kind === WATCH_EVENT.approval
343
353
 
344
354
  /**
@@ -0,0 +1,47 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { parseSprintLanes } from '../plan-format'
4
+ import { baseReaderAt, commitOf } from '../plans/git'
5
+ import { isOutsideRepository } from '../plans/lane-plans'
6
+ import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from '../plans/settings'
7
+ import { lanePlanPath } from './lane-progress'
8
+ import {
9
+ type PlanMissingEvent,
10
+ type PlanMissingReadings,
11
+ type PlanMissingState,
12
+ planMissingEvents,
13
+ } from './plan-missing'
14
+
15
+ /**
16
+ * The plan the lane's newest linking row names in the board as its worktree has it; null without a worktree board or
17
+ * row, or for a link that leaves the repository, which no base ref can hold.
18
+ */
19
+ const worktreePlanOf = (root: string, settings: PlansSettings, boardFile: string, laneName: string): string | null => {
20
+ const file = join(worktreeOf(root, settings, laneName), boardRepoPathOf(root, settings, boardFile))
21
+ if (!existsSync(file)) return null
22
+ const lane = parseSprintLanes(readFileSync(file, 'utf8'), settings.planLanguage).find(({ name }) => name === laneName)
23
+ const path = lane === undefined ? null : lanePlanPath(root, settings, boardFile, lane)
24
+ return path === null || isOutsideRepository(path) ? null : path
25
+ }
26
+
27
+ /**
28
+ * This heartbeat's `plan missing on base` events and the state to keep, for the working lanes: the plan of each lane's
29
+ * row in its own worktree against the base ref, where a worktree made from the base would not find it. Nothing when
30
+ * the base ref does not resolve, which would make every plan look missing.
31
+ */
32
+ export const readPlanMissingEvents = (
33
+ root: string,
34
+ settings: PlansSettings,
35
+ boardFile: string,
36
+ workingLanes: readonly string[],
37
+ previous: PlanMissingState,
38
+ ): { events: PlanMissingEvent[]; state: PlanMissingState } => {
39
+ const baseRef = baseRefOf(settings)
40
+ const commit = commitOf(root, baseRef)
41
+ if (commit === null) return { events: [], state: previous }
42
+ const base = baseReaderAt(root, commit)
43
+ const readings: PlanMissingReadings = Object.fromEntries(
44
+ workingLanes.map((lane) => [lane, worktreePlanOf(root, settings, boardFile, lane)]),
45
+ )
46
+ return planMissingEvents(previous, readings, (path) => base(path) !== null, baseRef)
47
+ }
@@ -0,0 +1,43 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+ import { isRecord } from '../shared/is-record'
3
+
4
+ export const PLAN_MISSING_EVENT = 'plan missing on base'
5
+
6
+ /** Per working lane: the plan its newest board row links, as its worktree has it; null without one. */
7
+ export type PlanMissingReadings = Readonly<Record<string, string | null>>
8
+
9
+ /** Per lane: the plan path last reported as missing on the base ref; a lane whose plan is there has no entry. */
10
+ export type PlanMissingState = Record<string, string>
11
+
12
+ export type PlanMissingEvent = { kind: typeof PLAN_MISSING_EVENT; lane: string; path: string; baseRef: string }
13
+
14
+ /**
15
+ * One event per working lane whose board row, as its worktree has it, links a plan the base ref lacks (`existsAtBase`
16
+ * answers for a repository path). A plan is reported once while it stays missing: the state clears when it reaches the
17
+ * base or the row links another, and a lane without a reading (not at work) keeps what it had.
18
+ */
19
+ export const planMissingEvents = (
20
+ previous: PlanMissingState,
21
+ readings: PlanMissingReadings,
22
+ existsAtBase: (path: string) => boolean,
23
+ baseRef: string,
24
+ ): { events: PlanMissingEvent[]; state: PlanMissingState } => {
25
+ const events: PlanMissingEvent[] = []
26
+ const state: PlanMissingState = { ...previous }
27
+ for (const [lane, planPath] of Object.entries(readings)) {
28
+ if (planPath === null || existsAtBase(planPath)) {
29
+ delete state[lane]
30
+ continue
31
+ }
32
+ if (previous[lane] !== planPath) events.push({ kind: PLAN_MISSING_EVENT, lane, path: planPath, baseRef })
33
+ state[lane] = planPath
34
+ }
35
+ return { events, state }
36
+ }
37
+
38
+ /** `lane <name> plan missing on <base ref>: <path>`. */
39
+ export const planMissingLine = ({ lane, path, baseRef }: PlanMissingEvent): string =>
40
+ `lane ${terminalSafe(lane)} plan missing on ${terminalSafe(baseRef)}: ${terminalSafe(path)}`
41
+
42
+ export const isPlanMissingState = (value: unknown): value is PlanMissingState =>
43
+ isRecord(value) && Object.values(value).every((path) => typeof path === 'string')