jackherd 1.14.0 → 1.15.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 (83) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +27 -19
  3. package/assets/icons/skills.svg +1 -0
  4. package/dist/config.d.ts +9 -1
  5. package/dist/config.js +24 -1
  6. package/dist/plan-format.d.ts +24 -1
  7. package/dist/plan-format.js +25 -5
  8. package/docs/plan-contract.md +84 -0
  9. package/docs/skills-dark.svg +15 -2
  10. package/docs/skills.svg +15 -2
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/herdr-plugin/restore-plans.ts +56 -6
  13. package/install.sh +1 -1
  14. package/kit/hooks/hooks/merge-guard/board-settings.ts +5 -2
  15. package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
  16. package/kit/hooks/hooks/merge-guard/run.ts +6 -3
  17. package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
  18. package/kit/hooks/hooks/model-log/transcript.ts +4 -7
  19. package/kit/hooks/hooks/open-question/record.ts +4 -3
  20. package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
  21. package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
  22. package/kit/hooks/hooks/shared/transcript.ts +7 -4
  23. package/kit/hooks/hooks/task-hint/decision.ts +3 -2
  24. package/kit/hooks/hooks/task-hint/run.ts +2 -4
  25. package/kit/rules/rules/kit/pull-requests.md +1 -0
  26. package/kit/workflow/skills/orchestrate/SKILL.md +22 -9
  27. package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
  28. package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
  29. package/kit/workflow/skills/orchestrate/references/plan-commit.md +5 -5
  30. package/package.json +1 -1
  31. package/schemas/jackherd.schema.json +19 -0
  32. package/src/check/command.ts +1 -1
  33. package/src/check/file-kinds.ts +10 -8
  34. package/src/check/parallel-report.ts +10 -3
  35. package/src/check/settings.ts +4 -0
  36. package/src/check/skill-budget-defaults.ts +17 -5
  37. package/src/check/skill-budgets.ts +49 -19
  38. package/src/claude/paths.ts +12 -0
  39. package/src/cli.ts +1 -0
  40. package/src/config/schema.ts +27 -0
  41. package/src/context/fill.ts +2 -1
  42. package/src/context/log-vocabulary.ts +22 -0
  43. package/src/context/session-log.ts +2 -1
  44. package/src/lane-prompt/build.ts +10 -5
  45. package/src/lane-prompt/command.ts +6 -1
  46. package/src/lane-prompt/input.ts +4 -4
  47. package/src/lane-prompt/texts.ts +18 -10
  48. package/src/plan-contract/command.ts +69 -0
  49. package/src/plan-contract/contract.ts +144 -0
  50. package/src/plan-format/dependencies.ts +1 -4
  51. package/src/plan-format/index.ts +4 -0
  52. package/src/plan-format/lanes.ts +11 -0
  53. package/src/plan-format/markdown.ts +6 -5
  54. package/src/plan-format/model-table.ts +27 -0
  55. package/src/plans/activity-read.ts +98 -0
  56. package/src/plans/ask-coordinator.ts +20 -11
  57. package/src/plans/layout.ts +49 -4
  58. package/src/plans/live.ts +17 -4
  59. package/src/plans/plan-panes.ts +25 -5
  60. package/src/plans/snapshot.ts +18 -8
  61. package/src/plans/subagents-read.ts +4 -3
  62. package/src/plans/subagents.ts +2 -1
  63. package/src/plans/texts.ts +27 -1
  64. package/src/settled/send.ts +80 -4
  65. package/src/shared/parse-json.ts +8 -0
  66. package/src/sync/command.ts +17 -10
  67. package/src/sync/foreign-skills.ts +40 -0
  68. package/src/sync/global-kit.ts +31 -21
  69. package/src/sync/init.ts +2 -2
  70. package/src/sync/kit-exclude.ts +39 -0
  71. package/src/sync/plan-file.ts +1 -1
  72. package/src/sync/render.ts +2 -1
  73. package/src/sync/report.ts +37 -2
  74. package/src/sync/sync-result.ts +5 -2
  75. package/src/sync/sync.ts +13 -5
  76. package/src/task-signals/claude-signals.ts +6 -5
  77. package/src/watch/command.ts +56 -17
  78. package/src/watch/lane-message.ts +1 -1
  79. package/src/watch/lane-progress.ts +51 -2
  80. package/src/watch/lane-report.ts +57 -2
  81. package/src/watch/lane-watch.ts +2 -8
  82. package/src/watch/task-parallel-touched.ts +9 -2
  83. package/src/watch/texts.ts +4 -0
@@ -1,7 +1,8 @@
1
- import { AGENTS_DIRECTORY, claudePath, RULES_DIRECTORY, SKILLS_DIRECTORY } from '../claude/paths'
1
+ import { AGENT_PATH, claudePath, prefixedKitPath, RULES_DIRECTORY, SKILL_PATH, SKILLS_DIRECTORY } from '../claude/paths'
2
2
  import { SKILL_PREFIX } from '../config'
3
3
  import { TOOL_DIRECTORY } from '../config/location'
4
4
  import { REPOSITORY_TOKENS } from '../config/repository-values'
5
+ import { escapeRegExp } from '../plan-format/markdown'
5
6
  import { CLI_NAME, PACKAGE_NAME } from '../version/release'
6
7
  import type { KitFile, KitSource } from './kit'
7
8
  import { TOOL_ROOT } from './kit-path'
@@ -15,8 +16,6 @@ const GLOBAL_RULES_DIRECTORY = `${TOOL_ROOT}/${RULES_DIRECTORY}`
15
16
  /** Put at the top of a global file that names values each repository decides; `sync --global` renders them as `<name>`. */
16
17
  export const REPOSITORY_VALUES_NOTE = `Values in angle brackets such as \`<test>\`, \`<install>\` or \`<baseBranch>\` are the current repository's own: run \`${CLI_NAME} config\` once in it to read them all, or \`${CLI_NAME} config <name>\` for one.`
17
18
 
18
- const SKILL_PATH = new RegExp(`^${SKILLS_DIRECTORY}/([^/]+)/(.+)$`)
19
- const AGENT_PATH = new RegExp(`^${AGENTS_DIRECTORY}/([^/]+)\\.md$`)
20
19
  const SKILL_ENTRY = new RegExp(`^${SKILLS_DIRECTORY}/[^/]+/SKILL\\.md$`)
21
20
  const RULE_PATH = new RegExp(`^${RULES_DIRECTORY}/kit/(.+)$`)
22
21
 
@@ -24,8 +23,6 @@ const FRONTMATTER = /^---\n([\s\S]*?)\n---\n/
24
23
  const NAME_LINE = /^name: (.+)$/m
25
24
  const DESCRIPTION_LINE = /^description: '(.*)'$/m
26
25
 
27
- const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
28
-
29
26
  export const globalNote = (name: string, kind: EntryKind): string =>
30
27
  `Global ${PACKAGE_NAME} copy. In a project that has its own ${name} ${kind}, use that one instead. `
31
28
 
@@ -38,20 +35,12 @@ const assertUsablePrefix = (prefix: string): void => {
38
35
  /** Where the status line lands: `~/.jackherd/statusline.sh`; `init --global` offers it from there. */
39
36
  const GLOBAL_STATUS_LINE_PATH = `${TOOL_ROOT}/statusline.sh`
40
37
 
41
- /** The path in a project's `.claude/`: skills and agents prefixed, everything else where the kit puts it. */
42
- const projectPath = (path: string, prefix: string): string => {
43
- const skill = SKILL_PATH.exec(path)
44
- if (skill) return `${SKILLS_DIRECTORY}/${prefix}${skill[1]}/${skill[2]}`
45
- const agent = AGENT_PATH.exec(path)
46
- return agent ? `${AGENTS_DIRECTORY}/${prefix}${agent[1]}.md` : path
47
- }
48
-
49
38
  /** The path in `~/.claude`, or null for what stays out of a global install (the hooks). */
50
39
  const globalPath = (path: string, prefix: string): string | null => {
51
40
  if (path === 'statusline.sh') return GLOBAL_STATUS_LINE_PATH
52
41
  const rule = RULE_PATH.exec(path)
53
42
  if (rule) return `${GLOBAL_RULES_DIRECTORY}/${rule[1]}`
54
- return SKILL_PATH.test(path) || AGENT_PATH.test(path) ? projectPath(path, prefix) : null
43
+ return SKILL_PATH.test(path) || AGENT_PATH.test(path) ? prefixedKitPath(path, prefix) : null
55
44
  }
56
45
 
57
46
  const entryKind = (path: string): EntryKind | null => {
@@ -69,16 +58,37 @@ const kitNames = (files: KitFile[]): string[] => {
69
58
  return [...new Set(names)].sort((left, right) => right.length - left.length)
70
59
  }
71
60
 
72
- /** Names as a skill or agent calls another: slash commands, `subagent_type`, backticked names, relative links. */
73
- const rewriteNames = (text: string, alternation: string, prefix: string): string =>
61
+ /**
62
+ * Names as a skill or agent calls another: slash commands, `subagent_type`, backticked names. `rename` gives the new
63
+ * name inside the call, `suffix` follows the whole call, after a closing quote or backtick.
64
+ */
65
+ export const rewriteCalls = (
66
+ text: string,
67
+ alternation: string,
68
+ rename: (name: string) => string,
69
+ suffix = '',
70
+ ): string =>
74
71
  text
75
72
  .replace(
73
+ new RegExp(`(?<![\\w.~/-])/(${alternation})(?![\\w/-])`, 'g'),
74
+ (_call, name: string) => `/${rename(name)}${suffix}`,
75
+ )
76
+ .replace(
77
+ new RegExp(`subagent_type: '(${alternation})'`, 'g'),
78
+ (_call, name: string) => `subagent_type: '${rename(name)}'${suffix}`,
79
+ )
80
+ .replace(new RegExp(`\`(${alternation})\``, 'g'), (_call, name: string) => `\`${rename(name)}\`${suffix}`)
81
+
82
+ /** The calls of `rewriteCalls` and relative links, each with the prefix. */
83
+ const rewriteNames = (text: string, alternation: string, prefix: string): string =>
84
+ rewriteCalls(
85
+ text.replace(
76
86
  new RegExp(`\\.\\./${SKILLS_DIRECTORY}/(${alternation})(?![\\w-])`, 'g'),
77
87
  `../${SKILLS_DIRECTORY}/${prefix}$1`,
78
- )
79
- .replace(new RegExp(`(?<![\\w.~/-])/(${alternation})(?![\\w/-])`, 'g'), `/${prefix}$1`)
80
- .replace(new RegExp(`subagent_type: '(${alternation})'`, 'g'), `subagent_type: '${prefix}$1'`)
81
- .replace(new RegExp(`\`(${alternation})\``, 'g'), `\`${prefix}$1\``)
88
+ ),
89
+ alternation,
90
+ (name) => `${prefix}${name}`,
91
+ )
82
92
 
83
93
  const PROJECT_SKILLS = claudePath(SKILLS_DIRECTORY)
84
94
  const GLOBAL_SKILLS = `~/${PROJECT_SKILLS}`
@@ -151,7 +161,7 @@ export const toGlobalKit = (kit: KitSource, prefix: string): KitSource => {
151
161
  }
152
162
 
153
163
  const toProjectFile = (file: KitFile, names: string[], prefix: string): KitFile => {
154
- const path = projectPath(file.path, prefix)
164
+ const path = prefixedKitPath(file.path, prefix)
155
165
  if (!file.path.endsWith('.md') || file.path.startsWith('hooks/')) return { ...file, path }
156
166
  const alternation = names.map(escapeRegExp).join('|')
157
167
  const body = rewriteNames(rewriteSkillPaths(file.template, alternation, prefix, PROJECT_SKILLS), alternation, prefix)
package/src/sync/init.ts CHANGED
@@ -5,6 +5,7 @@ import { type JackherdConfig, parseJackherdConfig, type TerminalAdapter } from '
5
5
  import { type RepositoryFacts, STACKS, withDetected } from '../config/detect'
6
6
  import { CONFIG_FILE, CONFIG_SCHEMA_REFERENCE, TOOL_DIRECTORY } from '../config/location'
7
7
  import { readRepositoryFacts } from '../config/repository-facts'
8
+ import { DEFAULT_SKILL_PREFIX } from '../config/schema'
8
9
  import type { KitSource } from './kit'
9
10
  import { moveGlobalLegacyFiles, moveProjectLegacyFiles } from './legacy-files'
10
11
  import { assertNotHomeDirectory, assertOwnDirectoriesInProject, createFileExclusively } from './project-fs'
@@ -35,8 +36,7 @@ export type InitResult = {
35
36
  terminalAdapter: TerminalAdapter
36
37
  }
37
38
 
38
- /** What a global install, and a project that already has skills of kit names, puts before every kit name. */
39
- export const DEFAULT_SKILL_PREFIX = 'jh-'
39
+ export { DEFAULT_SKILL_PREFIX }
40
40
 
41
41
  const SKILL_ENTRY = new RegExp(`^${SKILLS_DIRECTORY}/([^/]+)/SKILL\\.md$`)
42
42
  const AGENT_ENTRY = new RegExp(`^${AGENTS_DIRECTORY}/([^/]+)\\.md$`)
@@ -0,0 +1,39 @@
1
+ import { AGENT_PATH, SKILL_PATH } from '../claude/paths'
2
+ import { escapeRegExp } from '../plan-format/markdown'
3
+ import { rewriteCalls } from './global-kit'
4
+ import type { KitFile, KitSource } from './kit'
5
+
6
+ /** The skill or agent a kit file belongs to, by its unprefixed name; null for rules, hooks and the status line. */
7
+ const entryNameOf = ({ path }: KitFile): string | null => (SKILL_PATH.exec(path) ?? AGENT_PATH.exec(path))?.[1] ?? null
8
+
9
+ /** Kit texts an agent reads; the hooks' Markdown and every script stay as the kit wrote them. */
10
+ const isAgentText = ({ path }: KitFile): boolean => path.endsWith('.md') && !path.startsWith('hooks/')
11
+
12
+ /**
13
+ * A call of an excluded name reads `/devops (not installed)`, so an agent sees the skill is missing instead of a dead
14
+ * call. Links and `.claude/skills/<name>/` paths stay as they are: a mark inside a path would break the link it is in.
15
+ */
16
+ const markedNotInstalled = (file: KitFile, alternation: string): KitFile =>
17
+ isAgentText(file)
18
+ ? { ...file, template: rewriteCalls(file.template, alternation, (name) => name, ' (not installed)') }
19
+ : file
20
+
21
+ /**
22
+ * The kit without the skills and agents `kit.exclude` names: a name takes its skill directory and its agent alike, and
23
+ * every call of it in the remaining texts is marked as not installed. Applied before `skillPrefix`, so the names are
24
+ * the kit's own. A name the kit has neither of throws, so a typo never reads as excluded.
25
+ */
26
+ export const withoutExcluded = (kit: KitSource, exclude: readonly string[]): KitSource => {
27
+ if (exclude.length === 0) return kit
28
+ const known = new Set(kit.files.map(entryNameOf))
29
+ const unknown = exclude.filter((name) => !known.has(name))
30
+ if (unknown.length > 0) {
31
+ throw new Error(
32
+ `kit.exclude names no kit skill or agent: ${unknown.join(', ')}. Correct the name, or take it off the list if this kit version no longer ships it.`,
33
+ )
34
+ }
35
+ const excluded = new Set(exclude)
36
+ const alternation = exclude.map(escapeRegExp).join('|')
37
+ const files = kit.files.filter((file) => !excluded.has(entryNameOf(file) ?? ''))
38
+ return { ...kit, files: files.map((file) => markedNotInstalled(file, alternation)) }
39
+ }
@@ -80,7 +80,7 @@ const planKitDropped = (input: FileInput): FilePlan => {
80
80
  if (checksumOf(input.local) === input.entry.base) {
81
81
  return plan(input, FILE_OUTCOME.removed, [{ kind: 'remove', target: 'file' }, ...cleanUpKitNew(input)], undefined)
82
82
  }
83
- return plan(input, FILE_OUTCOME.untracked, [], undefined)
83
+ return plan(input, FILE_OUTCOME.untracked, cleanUpKitNew(input), undefined)
84
84
  }
85
85
 
86
86
  const planConflict = (input: FileInput, rendered: RenderedFile): FilePlan => {
@@ -47,6 +47,7 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
47
47
  ...prefixed('guard.standingApproval', STANDING_APPROVAL_TEXTS[config.responseLanguage]),
48
48
  ...prefixed('approval', APPROVAL_TEXTS[config.responseLanguage]),
49
49
  ['kit.merge.withoutChecks', checkFreeList(config.merge.withoutChecks)],
50
+ ['kit.merge.openAsDraft', config.merge.openAsDraft ? 'on' : 'off'],
50
51
  ...prefixed('heading', { ...header, ...sections, ...columns, phase }),
51
52
  ])
52
53
  }
@@ -58,7 +59,7 @@ export const listPlaceholders = (template: string): string[] =>
58
59
  /**
59
60
  * Fills the kit placeholders from the project config: `{{kit.model.<role>}}`,
60
61
  * `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile|verifyInstall>}}`, `{{kit.word.<laneClosed|openSubagents>}}`,
61
- * `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.guard.standingApproval.<approve|until>}}`, `{{kit.merge.withoutChecks}}`, `{{kit.heading.<key>}}` from the plan
62
+ * `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.guard.standingApproval.<approve|until>}}`, `{{kit.merge.<withoutChecks|openAsDraft>}}`, `{{kit.heading.<key>}}` from the plan
62
63
  * vocabulary, the two languages, `{{kit.responseAddress}}`, the plan and worktree directory and the base branch. A
63
64
  * command or lock file the config leaves unset renders as `unsetValueText`, never as a guessed default. Other
64
65
  * `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
@@ -1,4 +1,7 @@
1
+ import { PROJECT_CONFIG_PATH } from '../config/location'
2
+ import { DEFAULT_SKILL_PREFIX } from '../config/schema'
1
3
  import { CLI_NAME } from '../version/release'
4
+ import { foreignSkillNamesOf, SKILLS_LOCK_FILE, skillEntryPath } from './foreign-skills'
2
5
  import { shownPath } from './kit-path'
3
6
  import { FILE_OUTCOME, type FileOutcome } from './plan-file'
4
7
  import { HOOK_OUTCOME, type HookOutcome } from './plan-hooks'
@@ -61,7 +64,7 @@ const FILE_SECTIONS: { outcome: FileOutcome; heading: string; line: (path: strin
61
64
  },
62
65
  {
63
66
  outcome: FILE_OUTCOME.untracked,
64
- heading: 'No longer in the kit, kept with your changes and no longer tracked',
67
+ heading: 'No longer in the kit or excluded, kept with your changes and no longer tracked',
65
68
  line: (path) => ` ${shownPath(path)}`,
66
69
  },
67
70
  { outcome: FILE_OUTCOME.resolved, heading: 'Conflicts resolved', line: (path) => ` ${shownPath(path)}` },
@@ -78,6 +81,28 @@ const HOOK_SECTIONS: { outcome: HookOutcome; heading: string }[] = [
78
81
 
79
82
  const RESOLVE_HINT = `Resolve a conflict: merge the .kit-new into your file and delete it, or run \`${CLI_NAME} sync --accept <path>\` to take the kit version.`
80
83
 
84
+ const FOREIGN_SKILL_HEADING = `A skill from ${SKILLS_LOCK_FILE} sits on the kit's path, your file was kept`
85
+
86
+ /** A conflict, or a kept file: `npx skills add` overwrites the kit's file, and an unchanged kit reports it as kept. */
87
+ const FOREIGN_SKILL_OUTCOMES: readonly FileOutcome[] = [FILE_OUTCOME.conflict, FILE_OUTCOME.kept]
88
+
89
+ const foreignSkillHint = (names: readonly string[]): string =>
90
+ ` Set "skillPrefix": "${DEFAULT_SKILL_PREFIX}" in ${PROJECT_CONFIG_PATH} and run \`${CLI_NAME} sync\`: the kit moves to ${names.map((name) => `/${DEFAULT_SKILL_PREFIX}${name}`).join(', ')} and the foreign skill stays. Or install the skill as a plugin with its own namespace.`
91
+
92
+ /**
93
+ * The lines for a kit skill that `skills-lock.json` lists and that sync kept or left in conflict, heading and the
94
+ * two ways out; empty when there is none. `foreign` is the set of skill names from that file.
95
+ */
96
+ export const foreignSkillLines = (result: SyncResult, foreign: ReadonlySet<string>): string[] => {
97
+ const occupied = result.files
98
+ .filter(({ outcome }) => FOREIGN_SKILL_OUTCOMES.includes(outcome))
99
+ .map(({ path }) => path)
100
+ const names = foreignSkillNamesOf(occupied, foreign)
101
+ if (names.length === 0) return []
102
+ const lines = names.map((name) => ` ${shownPath(skillEntryPath(name))}`)
103
+ return ['', `${FOREIGN_SKILL_HEADING}:`, ...lines, foreignSkillHint(names)]
104
+ }
105
+
81
106
  export const exitCodeOf = (result: SyncResult): number => {
82
107
  const outcomes = [...result.files, ...result.hooks].map(({ outcome }) => outcome)
83
108
  return outcomes.some((outcome) => NEEDS_ATTENTION.includes(outcome)) ? SYNC_EXIT.conflicts : SYNC_EXIT.clean
@@ -98,6 +123,9 @@ const summaryLine = (outcomes: Outcome[]): string => {
98
123
 
99
124
  const section = (heading: string, lines: string[]): string[] => (lines.length > 0 ? ['', `${heading}:`, ...lines] : [])
100
125
 
126
+ const excludedLines = (excluded: readonly string[] = []): string[] =>
127
+ excluded.length > 0 ? ['', `Left out by kit.exclude: ${excluded.join(', ')}`] : []
128
+
101
129
  export const formatReport = (result: SyncResult): string => {
102
130
  const fileLines = FILE_SECTIONS.flatMap(({ outcome, heading, line }) =>
103
131
  section(
@@ -113,5 +141,12 @@ export const formatReport = (result: SyncResult): string => {
113
141
  )
114
142
  const hint = exitCodeOf(result) === SYNC_EXIT.conflicts ? ['', RESOLVE_HINT] : []
115
143
  const summary = summaryLine([...result.files, ...result.hooks].map(({ outcome }) => outcome))
116
- return [...(result.notes ?? []), summary, ...fileLines, ...hookLines, ...hint].join('\n')
144
+ return [
145
+ ...(result.notes ?? []),
146
+ summary,
147
+ ...excludedLines(result.excluded),
148
+ ...fileLines,
149
+ ...hookLines,
150
+ ...hint,
151
+ ].join('\n')
117
152
  }
@@ -5,5 +5,8 @@ export type FileReport = { path: string; outcome: FileOutcome }
5
5
 
6
6
  export type HookReport = { name: string; outcome: HookOutcome }
7
7
 
8
- /** `notes` name the config values the repository did not decide, printed before the summary. */
9
- export type SyncResult = { files: FileReport[]; hooks: HookReport[]; notes?: string[] }
8
+ /**
9
+ * `notes` name the config values the repository did not decide, printed before the summary. `excluded` are the kit
10
+ * skills and agents `kit.exclude` left out, by the kit's name.
11
+ */
12
+ export type SyncResult = { files: FileReport[]; hooks: HookReport[]; notes?: string[]; excluded?: string[] }
package/src/sync/sync.ts CHANGED
@@ -12,6 +12,7 @@ import { emptiedDirectories } from './emptied-directories'
12
12
  import { ensureGitignoreLines } from './gitignore'
13
13
  import { toGlobalKit, toProjectKit } from './global-kit'
14
14
  import type { KitSource } from './kit'
15
+ import { withoutExcluded } from './kit-exclude'
15
16
  import { kitNewPath, toolRootPath } from './kit-path'
16
17
  import { moveGlobalLegacyFiles, moveProjectLegacyFiles, removeEmptyLegacyDirectories } from './legacy-files'
17
18
  import { EMPTY_MANIFEST, type Manifest, parseManifest, serializeManifest } from './manifest'
@@ -297,9 +298,10 @@ const applyPlan = async (
297
298
  return notes
298
299
  }
299
300
 
300
- const syncResultOf = (plan: SyncPlan): SyncResult => ({
301
+ const syncResultOf = (plan: SyncPlan, excluded: readonly string[]): SyncResult => ({
301
302
  files: plan.files.map(({ path, outcome }) => ({ path, outcome })),
302
303
  hooks: plan.hooks.outcomes,
304
+ ...(excluded.length > 0 ? { excluded: [...excluded] } : {}),
303
305
  })
304
306
 
305
307
  /**
@@ -319,7 +321,7 @@ export const syncProjectWithConfig = async (request: SyncRequest): Promise<Confi
319
321
  const directories = { claudeDirectory, toolDirectory, locate: inClaudeDirectory(claudeDirectory) }
320
322
  const plan = await buildPlan({
321
323
  ...directories,
322
- kit: toProjectKit(request.kit, config.skillPrefix),
324
+ kit: toProjectKit(withoutExcluded(request.kit, config.kit.exclude), config.skillPrefix),
323
325
  config,
324
326
  accept: request.accept,
325
327
  readHooks: projectHooks(claudeDirectory, request.kit, config),
@@ -327,7 +329,10 @@ export const syncProjectWithConfig = async (request: SyncRequest): Promise<Confi
327
329
  const keptDirectoryNotes = request.check ? [] : await applyPlan(directories, plan)
328
330
  if (!request.check) await writeConfigRewrite(toolDirectory, read)
329
331
  if (!request.check) await ensureGitignoreLines(request.projectRoot)
330
- return { result: { ...syncResultOf(plan), notes: [...moved, ...notes, ...keptDirectoryNotes] }, config }
332
+ return {
333
+ result: { ...syncResultOf(plan, config.kit.exclude), notes: [...moved, ...notes, ...keptDirectoryNotes] },
334
+ config,
335
+ }
331
336
  }
332
337
 
333
338
  export const runSync = async (request: SyncRequest): Promise<SyncResult> =>
@@ -354,7 +359,7 @@ export const syncGlobalWithConfig = async (request: GlobalSyncRequest): Promise<
354
359
  const directories = { claudeDirectory, toolDirectory, locate: inGlobalDirectories(claudeDirectory, toolDirectory) }
355
360
  const plan = await buildPlan({
356
361
  ...directories,
357
- kit: toGlobalKit(request.kit, config.skillPrefix),
362
+ kit: toGlobalKit(withoutExcluded(request.kit, config.kit.exclude), config.skillPrefix),
358
363
  config,
359
364
  accept: request.accept,
360
365
  readHooks: noHooks,
@@ -363,7 +368,10 @@ export const syncGlobalWithConfig = async (request: GlobalSyncRequest): Promise<
363
368
  const keptDirectoryNotes = await applyPlan(directories, plan)
364
369
  await writeConfigRewrite(toolDirectory, read)
365
370
  await removeEmptyLegacyDirectories(claudeDirectory)
366
- return { result: { ...syncResultOf(plan), notes: [...moved, ...read.notes, ...keptDirectoryNotes] }, config }
371
+ return {
372
+ result: { ...syncResultOf(plan, config.kit.exclude), notes: [...moved, ...read.notes, ...keptDirectoryNotes] },
373
+ config,
374
+ }
367
375
  }
368
376
 
369
377
  export const runGlobalSync = async (request: GlobalSyncRequest): Promise<SyncResult> =>
@@ -1,3 +1,4 @@
1
+ import { CLAUDE_TOOL, CONTENT_BLOCK_TYPE } from '../context/log-vocabulary'
1
2
  import { parseLogEntry } from '../context/session-log'
2
3
  import { isRecord } from '../shared/is-record'
3
4
  import { cleanTaskText } from './text'
@@ -76,9 +77,9 @@ const onTodoWrite = (tracker: Tracker, input: Record<string, unknown>): void =>
76
77
  const onToolUse = (tracker: Tracker, block: Block): void => {
77
78
  const input = block.input
78
79
  if (!isRecord(input)) return
79
- if (block.name === 'TaskCreate' && typeof block.id === 'string') tracker.pending.set(block.id, input)
80
- if (block.name === 'TaskUpdate') onTaskUpdate(tracker, input)
81
- if (block.name === 'TodoWrite') onTodoWrite(tracker, input)
80
+ if (block.name === CLAUDE_TOOL.taskCreate && typeof block.id === 'string') tracker.pending.set(block.id, input)
81
+ if (block.name === CLAUDE_TOOL.taskUpdate) onTaskUpdate(tracker, input)
82
+ if (block.name === CLAUDE_TOOL.todoWrite) onTodoWrite(tracker, input)
82
83
  }
83
84
 
84
85
  const visit = (tracker: Tracker, entry: Record<string, unknown>): void => {
@@ -86,8 +87,8 @@ const visit = (tracker: Tracker, entry: Record<string, unknown>): void => {
86
87
  if (!Number.isNaN(time)) tracker.time = time
87
88
  if (typeof entry.cwd === 'string') tracker.cwd = entry.cwd
88
89
  for (const block of blocksOf(entry)) {
89
- if (block.type === 'tool_use') onToolUse(tracker, block)
90
- if (block.type === 'tool_result') onResult(tracker, entry, block)
90
+ if (block.type === CONTENT_BLOCK_TYPE.toolUse) onToolUse(tracker, block)
91
+ if (block.type === CONTENT_BLOCK_TYPE.toolResult) onResult(tracker, entry, block)
91
92
  }
92
93
  }
93
94
 
@@ -95,7 +95,14 @@ import { type LaneReadings, laneFillEvents, recordLaneLevels } from './lane-fill
95
95
  import { laneMessage, rememberedStatusLines, statusBlock } from './lane-message'
96
96
  import { type LaneModelReading, laneModelEvents, recordLaneModels } from './lane-model'
97
97
  import { laneParallelEvents, recordLaneParallels } from './lane-parallel'
98
- import { boardCheckoutRows, watchedLaneParallel, watchedLaneProgress, watchedPlanPullRequests } from './lane-progress'
98
+ import {
99
+ boardCheckoutRows,
100
+ watchedLaneParallel,
101
+ watchedLaneProgress,
102
+ watchedPlanPullRequests,
103
+ watchedReadyPhases,
104
+ workedPhaseKeys,
105
+ } from './lane-progress'
99
106
  import {
100
107
  appointmentEventLine,
101
108
  approvalDutyLines,
@@ -114,6 +121,8 @@ import {
114
121
  otherPullRequestsLine,
115
122
  parallelDutyLines,
116
123
  planMissingDutyLines,
124
+ type ReadyPhase,
125
+ readyPhaseDutyLines,
117
126
  sourceEventLines,
118
127
  sourceFailingStatusLine,
119
128
  watchEventLine,
@@ -556,12 +565,14 @@ const laneReportOf = (
556
565
  }
557
566
  }
558
567
 
568
+ type BoardReadOrNote = { progress: Record<string, LaneProgress>; checkoutRows: number; notes: string[] }
569
+
559
570
  /** `boardReadOf`, or when the board cannot be read, nothing of it and a note that says why. */
560
571
  const boardReadOrNote = async (
561
572
  watch: Watch,
562
573
  lanes: readonly string[],
563
574
  pullRequests: readonly WatchedPullRequest[],
564
- ): Promise<{ progress: Record<string, LaneProgress>; checkoutRows: number; notes: string[] }> => {
575
+ ): Promise<BoardReadOrNote> => {
565
576
  try {
566
577
  return { ...(await boardReadOf(watch, lanes, pullRequests)), notes: [] }
567
578
  } catch (error) {
@@ -577,15 +588,15 @@ const boardReadOrNote = async (
577
588
  * The lane lines of the status block, the coordinator's line, the line of the own pull requests without a lane that
578
589
  * follows it (`others`), and the lines after the block (notes, a gh outage).
579
590
  */
580
- const statusLinesOf = async (
591
+ const statusLinesOf = (
581
592
  watch: Watch,
582
593
  watching: Watching,
583
594
  state: WatchState,
584
- ): Promise<{ laneLines: string[]; coordinator: string; others: string[]; rest: string[] }> => {
595
+ { progress, checkoutRows, notes }: BoardReadOrNote,
596
+ ): { laneLines: string[]; coordinator: string; others: string[]; rest: string[] } => {
585
597
  const github = answered(watching.github)
586
598
  const pullRequests = github?.pullRequests ?? []
587
599
  const others = otherPullRequestsLine(pullRequests, github?.own ?? [], watch.texts)
588
- const { progress, checkoutRows, notes } = await boardReadOrNote(watch, watching.lanes, pullRequests)
589
600
  const closeLaneHint = hintFor(watch, REFERENCE.closeLane)
590
601
  const laneLines = watching.lanes.map((lane) =>
591
602
  laneReportLine(
@@ -648,6 +659,19 @@ const mainCheckoutLineAfter = async (
648
659
  return mainCheckoutLineOf(watch)
649
660
  }
650
661
 
662
+ /** The ready phases of the stopped lanes that rest (`done`, `idle`); only their plans are read. */
663
+ const readyPhasesOfResting = (
664
+ watch: Watch,
665
+ watching: Watching,
666
+ stopped: readonly string[],
667
+ progress: Readonly<Record<string, LaneProgress>>,
668
+ ): Record<string, ReadyPhase[]> => {
669
+ const resting = stopped.filter((lane) => IDLE_STATUSES.has(watching.current[lane]?.status ?? ''))
670
+ if (resting.length === 0) return {}
671
+ const lanes = (readSprintLanes(watch) ?? []).filter((lane) => resting.includes(lane.name))
672
+ return watchedReadyPhases(watch.root, watch.settings, watch.sprintFile, lanes, workedPhaseKeys(progress))
673
+ }
674
+
651
675
  /**
652
676
  * Every line of one return, gathered before any is printed, so the output is never cut halfway by a slow lookup, and
653
677
  * the state with what it showed: the report times of the stopped lanes and the status lines. A state without status
@@ -663,8 +687,11 @@ const reportOf = async (
663
687
  const stops = stoppedLaneSections(watch, watching, stopped, next)
664
688
  const eventLines = await eventLinesOf(watch, events, watching.github)
665
689
  const mainCheckout = await mainCheckoutLineAfter(watch, watching, events)
690
+ const board = await boardReadOrNote(watch, watching.lanes, answered(watching.github)?.pullRequests ?? [])
691
+ const ready = readyPhasesOfResting(watch, watching, stopped, board.progress)
666
692
  const dutyLines = [
667
693
  ...blockedDutyLines(stopped, watching.current, watch.texts),
694
+ ...readyPhaseDutyLines(stopped, watching.current, ready, watch.texts),
668
695
  ...mergeDutyLines(events, watching.lanes, watch.texts),
669
696
  ...(mainCheckout === null ? [] : [mainCheckout]),
670
697
  ...laneFillDutyLines(events, watch.texts),
@@ -674,7 +701,7 @@ const reportOf = async (
674
701
  ...planMissingDutyLines(events, watch.texts),
675
702
  ...approvalDutyLines(events, watch.texts),
676
703
  ]
677
- const status = await statusLinesOf(watch, watching, next)
704
+ const status = statusLinesOf(watch, watching, next, board)
678
705
  const block = statusBlock(status.laneLines, status.coordinator, next.statusLines, status.others)
679
706
  const shownReports = stopped.filter((lane) => stops.reportMtimes[lane] !== next.reportMtimes[lane])
680
707
  return {
@@ -1350,7 +1377,8 @@ const saveState = (watch: Watch, watching: Watching) => {
1350
1377
  writePrivateFile(watch.files.state, JSON.stringify(unread ? watching.state : recordedOf(watch, watching)))
1351
1378
  }
1352
1379
 
1353
- const watchLanes = async (watch: Watch) => {
1380
+ /** Watches until the lanes end or the kit changes on disk; true when a kit update ended it. */
1381
+ const watchLanes = async (watch: Watch): Promise<boolean> => {
1354
1382
  const takeOver = await takeOverLock(watch.files.lock)
1355
1383
  const state = await readState(watch.files)
1356
1384
  const agents = listAgents()
@@ -1389,8 +1417,9 @@ const watchLanes = async (watch: Watch) => {
1389
1417
  await Bun.sleep(POLL_MS)
1390
1418
  pollLanes(watch, watching)
1391
1419
  await announceUpdate?.()
1392
- if (endedByKitUpdate(kit, effects)) return
1420
+ if (endedByKitUpdate(kit, effects)) return true
1393
1421
  }
1422
+ return false
1394
1423
  } finally {
1395
1424
  sourceChanges?.close()
1396
1425
  stopPublisher?.()
@@ -1456,6 +1485,20 @@ const watchOf = (options: WatchOptions): Watch => {
1456
1485
  }
1457
1486
  }
1458
1487
 
1488
+ /** How a watch ended: the kit's source changed under it, or the loop stopped with an exit code. */
1489
+ type WatchEnd = { kind: 'kitUpdated' } | { kind: 'stopped'; code: number }
1490
+
1491
+ const watchToEnd = async (watch: Watch): Promise<WatchEnd> => {
1492
+ try {
1493
+ return (await watchLanes(watch)) ? { kind: 'kitUpdated' } : { kind: 'stopped', code: 0 }
1494
+ } catch (error) {
1495
+ console.log(`watcher stopped: ${errorText(error)}`)
1496
+ return { kind: 'stopped', code: 1 }
1497
+ } finally {
1498
+ releaseLock(watch.files.lock)
1499
+ }
1500
+ }
1501
+
1459
1502
  export const run = async (args: string[]): Promise<number> => {
1460
1503
  let options: WatchOptions
1461
1504
  try {
@@ -1480,13 +1523,9 @@ export const run = async (args: string[]): Promise<number> => {
1480
1523
  console.error(errorText(error))
1481
1524
  return 1
1482
1525
  }
1483
- try {
1484
- await watchLanes(watch)
1485
- return 0
1486
- } catch (error) {
1487
- console.log(`watcher stopped: ${errorText(error)}`)
1488
- return 1
1489
- } finally {
1490
- releaseLock(watch.files.lock)
1491
- }
1526
+ const end = await watchToEnd(watch)
1527
+ // The loop's timers and child handles outlive it, so the process would stay beside the watcher that replaces it;
1528
+ // the state is saved and the lock released, as on SIGTERM.
1529
+ if (end.kind === 'kitUpdated') process.exit(0)
1530
+ return end.code
1492
1531
  }
@@ -1,5 +1,5 @@
1
1
  import { terminalSafe } from '../plan-format/terminal-text'
2
- import { AGENT_STATUS } from './lane-watch'
2
+ import { AGENT_STATUS } from '../terminal/types'
3
3
  import { budgetQuestions, questionCount, questionSections } from './report-questions'
4
4
 
5
5
  /** A lane's report file as the watcher found it: its path, when it was last written, its text. */
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
- import { type LaneProgress, parallelGroupsOfPlan, resolvePlanPath, type SprintLane } from '../plan-format'
3
+ import { currentPhase, type LaneProgress, parallelGroupsOfPlan, resolvePlanPath, type SprintLane } from '../plan-format'
4
4
  import { boardRowsOf } from '../plans/board-rows'
5
5
  import { type BaseReader, baseReaderOf, gitStatusOf } from '../plans/git'
6
6
  import { laneWorkOf, readPlans, textAtBase } from '../plans/lane-plans'
@@ -8,6 +8,7 @@ import { laneHeads, type PlanPullRequest, planPullRequests } from '../plans/plan
8
8
  import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from '../plans/settings'
9
9
  import { workedLaneProgress } from '../plans/worked-phases'
10
10
  import { parallelBatchOf } from './lane-parallel'
11
+ import { type ReadyPhase, readyPhasesOf } from './lane-report'
11
12
  import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
12
13
 
13
14
  /** The open pull requests of the watcher's list that change a plan, as the herd chooses them. */
@@ -47,6 +48,10 @@ export const watchedLaneProgress = async (
47
48
  return workedLaneProgress(lane, plans)
48
49
  }
49
50
 
51
+ /** The plan link of a lane's newest row that links one, as the board spells it; null without such a row. */
52
+ const newestPlanHref = (lane: SprintLane): string | null =>
53
+ lane.steps.filter((step) => step.planHref !== null).at(-1)?.planHref ?? null
54
+
50
55
  /** The repository path of the plan of a lane's newest row that links one; null without such a row. */
51
56
  export const lanePlanPath = (
52
57
  root: string,
@@ -54,7 +59,7 @@ export const lanePlanPath = (
54
59
  boardFile: string,
55
60
  lane: SprintLane,
56
61
  ): string | null => {
57
- const href = lane.steps.filter((step) => step.planHref !== null).at(-1)?.planHref
62
+ const href = newestPlanHref(lane)
58
63
  return href ? resolvePlanPath(dirname(boardRepoPathOf(root, settings, boardFile)), href) : null
59
64
  }
60
65
 
@@ -102,6 +107,50 @@ export const watchedLaneParallel = (
102
107
  return groups === null ? [] : parallelBatchOf(groups)
103
108
  }
104
109
 
110
+ /** A phase the lanes work on, keyed by its plan link as the board spells it, since two plans may share an id. */
111
+ const workedPhaseKey = (planHref: string, id: string): string => `${planHref}#${id}`
112
+
113
+ /** The ids of the phases in the plan of `planHref` that a lane works on, out of `workedPhaseKeys`. */
114
+ const phaseIdsWorkedIn = (worked: ReadonlySet<string>, planHref: string): Set<string> => {
115
+ const prefix = workedPhaseKey(planHref, '')
116
+ return new Set([...worked].flatMap((key) => (key.startsWith(prefix) ? [key.slice(prefix.length)] : [])))
117
+ }
118
+
119
+ /** The phases the lanes work on: the current phase of each lane's progress that has one, with its plan link. */
120
+ export const workedPhaseKeys = (progress: Readonly<Record<string, LaneProgress>>): Set<string> =>
121
+ new Set(
122
+ Object.values(progress).flatMap((each) => {
123
+ const phase = currentPhase(each)
124
+ const planHref = each.current === null ? null : each.steps[each.current.stepIndex]?.step.planHref
125
+ return phase && planHref ? [workedPhaseKey(planHref, phase.id)] : []
126
+ }),
127
+ )
128
+
129
+ /**
130
+ * Per lane, the open phases of its plan whose dependencies are all ticked off and that no lane works on (`worked`, from
131
+ * `workedPhaseKeys`), by the plan's dependency table and ticks, as the lane's worktree has them; empty for a lane
132
+ * without a readable plan.
133
+ */
134
+ export const watchedReadyPhases = (
135
+ root: string,
136
+ settings: PlansSettings,
137
+ boardFile: string,
138
+ lanes: readonly SprintLane[],
139
+ worked: ReadonlySet<string>,
140
+ ): Record<string, ReadyPhase[]> =>
141
+ Object.fromEntries(
142
+ lanes.map((lane) => {
143
+ const planHref = newestPlanHref(lane)
144
+ const text = watchedLanePlanText(root, settings, boardFile, lane)
145
+ return [
146
+ lane.name,
147
+ text === null || planHref === null
148
+ ? []
149
+ : readyPhasesOf(text, settings.planLanguage, phaseIdsWorkedIn(worked, planHref)),
150
+ ]
151
+ }),
152
+ )
153
+
105
154
  /**
106
155
  * The rows the checkout's board file (`checkoutText`) has beyond the board at the base ref, counted as the herd
107
156
  * adds them; 0 when the base ref lacks the board, which is then read from the checkout as a whole.