jackherd 1.6.0 → 1.7.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 (152) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/INSTALL.md +176 -0
  3. package/README.md +260 -315
  4. package/assets/branding/README.md +7 -3
  5. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  6. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  7. package/assets/branding/jackherd-logo-dark.svg +1 -1
  8. package/assets/icons/bark-dark.svg +1 -0
  9. package/assets/icons/bark.svg +1 -0
  10. package/assets/icons/bowl-dark.svg +1 -0
  11. package/assets/icons/bowl.svg +1 -0
  12. package/assets/icons/decide-dark.svg +1 -0
  13. package/assets/icons/decide.svg +1 -0
  14. package/assets/icons/ear-dark.svg +1 -0
  15. package/assets/icons/ear.svg +1 -0
  16. package/assets/icons/lanes-dark.svg +1 -0
  17. package/assets/icons/lanes.svg +1 -0
  18. package/assets/icons/plan-dark.svg +1 -0
  19. package/assets/icons/plan.svg +1 -0
  20. package/assets/icons/space.svg +1 -0
  21. package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
  22. package/dist/config.d.ts +14 -3
  23. package/dist/config.js +111 -10
  24. package/dist/plan-format.d.ts +180 -77
  25. package/dist/plan-format.js +292 -93
  26. package/docs/lanes-dark.svg +50 -0
  27. package/docs/lanes.svg +50 -1
  28. package/docs/skills-dark.svg +65 -0
  29. package/docs/skills.svg +65 -1
  30. package/docs/workflow-dark.svg +138 -0
  31. package/docs/workflow.svg +135 -169
  32. package/herdr-plugin/herdr-plugin.toml +6 -6
  33. package/herdr-plugin/open-plans.ts +1 -1
  34. package/herdr-plugin/restore-plans.ts +7 -7
  35. package/install.sh +1 -1
  36. package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
  37. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
  38. package/kit/hooks/hooks/approval-gate/index.ts +69 -0
  39. package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
  40. package/kit/hooks/hooks/approval-gate/request.ts +111 -0
  41. package/kit/hooks/hooks/approval-gate/run.ts +94 -0
  42. package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
  43. package/kit/hooks/hooks/approval-gate/store.ts +70 -0
  44. package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
  45. package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
  46. package/kit/hooks/hooks/merge-guard/run.ts +2 -1
  47. package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
  48. package/kit/hooks/hooks/model-gate/index.ts +58 -0
  49. package/kit/hooks/hooks/model-gate/run.ts +63 -0
  50. package/kit/hooks/hooks/model-log/index.ts +60 -0
  51. package/kit/hooks/hooks/model-log/run.ts +57 -0
  52. package/kit/hooks/hooks/model-log/store.ts +40 -0
  53. package/kit/hooks/hooks/model-log/transcript.ts +37 -0
  54. package/kit/hooks/hooks/shared/checkout.ts +22 -0
  55. package/kit/hooks/hooks/shared/command.ts +6 -0
  56. package/kit/hooks/hooks/shared/config.ts +21 -0
  57. package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
  58. package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
  59. package/kit/hooks/hooks/shared/files.ts +19 -0
  60. package/kit/hooks/hooks/shared/fresh.ts +4 -0
  61. package/kit/hooks/hooks/shared/json.ts +4 -0
  62. package/kit/hooks/hooks/shared/model-family.ts +48 -0
  63. package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
  64. package/kit/hooks/hooks/shared/private-directory.ts +29 -0
  65. package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
  66. package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
  67. package/kit/hooks/hooks.json +25 -0
  68. package/kit/workflow/skills/follow/SKILL.md +4 -3
  69. package/kit/workflow/skills/implement/SKILL.md +6 -6
  70. package/kit/workflow/skills/orchestrate/SKILL.md +113 -30
  71. package/kit/workflow/skills/plan/SKILL.md +5 -5
  72. package/kit/workflow/skills/start/SKILL.md +2 -2
  73. package/package.json +4 -1
  74. package/schemas/jackherd.schema.json +43 -5
  75. package/src/check/command.ts +31 -4
  76. package/src/check/deferral-arrows.ts +1 -1
  77. package/src/check/parallel-report.ts +23 -0
  78. package/src/check/plan-structure-check.ts +120 -53
  79. package/src/cli.ts +8 -2
  80. package/src/config/nested-quantifier.ts +73 -0
  81. package/src/config/repository-directory.ts +11 -0
  82. package/src/config/schema.ts +63 -8
  83. package/src/plan-format/board-prose.ts +36 -0
  84. package/src/plan-format/dependencies.ts +227 -0
  85. package/src/plan-format/documentation-gate.ts +16 -0
  86. package/src/plan-format/index.ts +23 -1
  87. package/src/plan-format/lanes.ts +35 -1
  88. package/src/plan-format/model-column.ts +52 -0
  89. package/src/plan-format/phases.ts +16 -1
  90. package/src/plan-format/plan-structure.ts +10 -28
  91. package/src/plan-format/progress.ts +7 -29
  92. package/src/plans/command.ts +3 -3
  93. package/src/plans/current-phase.ts +71 -0
  94. package/src/plans/ensure-panes.ts +2 -2
  95. package/src/plans/herd-command.ts +4 -0
  96. package/src/plans/lane-discovery.ts +14 -2
  97. package/src/plans/lane-tickets.ts +69 -0
  98. package/src/plans/layout.ts +182 -88
  99. package/src/plans/live.ts +5 -1
  100. package/src/plans/phase-models-read.ts +37 -0
  101. package/src/plans/phase-models.ts +70 -0
  102. package/src/plans/plan-panes.ts +8 -7
  103. package/src/plans/pull-requests.ts +78 -4
  104. package/src/plans/selection.ts +1 -40
  105. package/src/plans/settings.ts +9 -3
  106. package/src/plans/shown-with-more.ts +5 -0
  107. package/src/plans/snapshot.ts +82 -8
  108. package/src/plans/sprint-html.ts +2 -2
  109. package/src/plans/supervisor.ts +5 -4
  110. package/src/plans/symbols.ts +9 -6
  111. package/src/plans/texts.ts +30 -13
  112. package/src/plans/view.ts +1 -0
  113. package/src/sync/herdr-plugin.ts +1 -1
  114. package/src/sync/next-steps.ts +1 -1
  115. package/src/sync/render.ts +2 -1
  116. package/src/sync/setup-questions.ts +2 -2
  117. package/src/terminal/text.ts +4 -0
  118. package/src/terminal/types.ts +1 -1
  119. package/src/vocabulary/index.ts +39 -0
  120. package/src/watch/approvals.ts +96 -0
  121. package/src/watch/command.ts +154 -50
  122. package/src/watch/failed-tests.ts +52 -0
  123. package/src/watch/failure-read.ts +56 -0
  124. package/src/watch/failures.ts +107 -0
  125. package/src/watch/feedback-item.ts +21 -0
  126. package/src/watch/feedback-read.ts +2 -1
  127. package/src/watch/feedback.ts +37 -28
  128. package/src/watch/gh-process.ts +53 -0
  129. package/src/watch/gh.ts +10 -20
  130. package/src/watch/heartbeat-progress.ts +1 -1
  131. package/src/watch/lane-model.ts +54 -0
  132. package/src/watch/lane-parallel.ts +46 -0
  133. package/src/watch/lane-progress.ts +28 -4
  134. package/src/watch/lane-report.ts +62 -9
  135. package/src/watch/lane-sources.ts +1 -1
  136. package/src/watch/no-finding.ts +47 -0
  137. package/src/watch/plan-badges-read.ts +19 -7
  138. package/src/watch/plan-badges.ts +59 -7
  139. package/src/watch/plan-views.ts +5 -5
  140. package/src/watch/previews.ts +3 -9
  141. package/src/watch/pull-requests.ts +12 -2
  142. package/src/watch/recorded-reads.ts +25 -0
  143. package/src/watch/source-fields.ts +4 -0
  144. package/src/watch/source-tickets.ts +99 -0
  145. package/src/watch/sources.ts +62 -15
  146. package/src/watch/texts.ts +21 -1
  147. package/src/watch/watch-defaults.ts +8 -0
  148. package/src/watch/watch-events.ts +64 -4
  149. package/src/watch/watcher-lock.ts +1 -1
  150. package/src/watch/workflow-runs.ts +5 -7
  151. package/docs/lanes.mmd +0 -9
  152. package/docs/skills.mmd +0 -10
@@ -1,74 +1,19 @@
1
1
  /**
2
- * What the user approved, read from the session transcript. The transcript is written by Claude Code,
3
- * not by the agent: a dialog answer sits in it as the tool result of an `AskUserQuestion` call, with
4
- * the question the agent put and the label the user picked. Nothing an agent says in a message,
5
- * a file or a command counts, so a text cannot stand in for the click.
2
+ * What the user approved, read from the session transcript through `dialogAnswers`: a dialog answer sits in it as the
3
+ * tool result of an `AskUserQuestion` call, with the question the agent put and the label the user picked. Nothing an
4
+ * agent says in a message, a file or a command counts, so a text cannot stand in for the click.
6
5
  *
7
6
  * The limit: an agent that runs as the user could still write to the transcript file. This guards
8
7
  * against a mistake, not against an agent that sets out to get around it.
9
8
  */
9
+ import { type DialogAnswer, dialogAnswers } from '../shared/dialog-answers'
10
+ import { isFresh } from '../shared/fresh'
10
11
 
11
- type Record_ = Record<string, unknown>
12
-
13
- const MS_PER_MINUTE = 60_000
14
12
  const BRANCH_MENTION = /\bBranch `([^`]*)`/g
15
13
 
16
14
  /** A branch name as git takes it from a shell word: no leading dash, no space, no `..`, no shell character. */
17
15
  const BRANCH_NAME = /^(?![-/.])(?!.*(?:\.\.|\/\/|@\{))[A-Za-z0-9._/-]+$/
18
16
 
19
- const isRecord = (value: unknown): value is Record_ => typeof value === 'object' && value !== null && !Array.isArray(value)
20
-
21
- const contentOf = (entry: Record_): Record_[] => {
22
- const content = isRecord(entry.message) ? entry.message.content : undefined
23
- return Array.isArray(content) ? content.filter(isRecord) : []
24
- }
25
-
26
- const isMainThread = (entry: Record_): boolean => entry.isSidechain !== true
27
-
28
- /** One JSON value per line; a line that is not JSON is skipped. */
29
- export const parseTranscript = (text: string): unknown[] =>
30
- text.split('\n').flatMap((line) => {
31
- try {
32
- return line.trim() === '' ? [] : [JSON.parse(line)]
33
- } catch {
34
- return []
35
- }
36
- })
37
-
38
- /** Whether the question offers `label` as one option among single choices: a multi-select joins its picks into one text. */
39
- const offersLabel = (question: Record_, label: string): boolean =>
40
- question.multiSelect !== true &&
41
- Array.isArray(question.options) &&
42
- question.options.some((option) => isRecord(option) && option.label === label)
43
-
44
- const questionTexts = (input: unknown, label: string): string[] => {
45
- if (!isRecord(input) || 'answers' in input || !Array.isArray(input.questions)) return []
46
- return input.questions.flatMap((question) =>
47
- isRecord(question) && typeof question.question === 'string' && offersLabel(question, label)
48
- ? [question.question]
49
- : [],
50
- )
51
- }
52
-
53
- /**
54
- * The questions of each `AskUserQuestion` call that offer `label`, by tool use id. A call that carries its own
55
- * `answers` asked nobody.
56
- */
57
- const askedQuestions = (entries: Record_[], label: string): Map<string, string[]> =>
58
- new Map(
59
- entries
60
- .filter(isMainThread)
61
- .flatMap(contentOf)
62
- .filter((block) => block.type === 'tool_use' && block.name === 'AskUserQuestion' && typeof block.id === 'string')
63
- .map((block): [string, string[]] => [block.id as string, questionTexts(block.input, label)]),
64
- )
65
-
66
- const isFresh = (timestamp: unknown, now: number, windowMinutes: number): boolean => {
67
- const answeredAt = typeof timestamp === 'string' ? Date.parse(timestamp) : Number.NaN
68
- const age = now - answeredAt
69
- return age >= 0 && age <= windowMinutes * MS_PER_MINUTE
70
- }
71
-
72
17
  /** The one branch a question names as ``Branch `name` ``; a question that names none or several approves none. */
73
18
  const namedBranch = (question: string): string | undefined => {
74
19
  const names = new Set([...question.matchAll(BRANCH_MENTION)].map(([, name]) => name))
@@ -76,16 +21,7 @@ const namedBranch = (question: string): string | undefined => {
76
21
  return names.size === 1 && BRANCH_NAME.test(only) ? only : undefined
77
22
  }
78
23
 
79
- /** The branches one result entry approves: the questions of its `AskUserQuestion` call that the user answered with `label`. */
80
- const branchesApprovedIn = (entry: Record_, asked: Map<string, string[]>, label: string): string[] => {
81
- const answers = isRecord(entry.toolUseResult) ? entry.toolUseResult.answers : undefined
82
- if (!isRecord(answers)) return []
83
- return contentOf(entry)
84
- .filter((block) => block.type === 'tool_result')
85
- .flatMap((block) => asked.get(String(block.tool_use_id)) ?? [])
86
- .filter((question) => answers[question] === label)
87
- .flatMap((question) => namedBranch(question) ?? [])
88
- }
24
+ const approves = (answer: DialogAnswer, label: string): boolean => answer.answer === label && answer.offered.includes(label)
89
25
 
90
26
  export type ApprovalQuery = {
91
27
  entries: unknown[]
@@ -100,8 +36,9 @@ export type ApprovalQuery = {
100
36
  /** The branches whose question the user answered with `label` within the window. */
101
37
  export const approvedBranches = ({ entries, label, now, windowMinutes }: ApprovalQuery): Set<string> => {
102
38
  if (label === '') return new Set()
103
- const records = entries.filter(isRecord)
104
- const asked = askedQuestions(records, label)
105
- const fresh = records.filter((entry) => isMainThread(entry) && isFresh(entry.timestamp, now, windowMinutes))
106
- return new Set(fresh.flatMap((entry) => branchesApprovedIn(entry, asked, label)))
39
+ return new Set(
40
+ dialogAnswers(entries)
41
+ .filter((answer) => approves(answer, label) && isFresh(answer.answeredAt, now, windowMinutes))
42
+ .flatMap((answer) => namedBranch(answer.question) ?? []),
43
+ )
107
44
  }
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { isAbsolute, relative } from 'node:path'
3
- import { approvedBranches, parseTranscript } from './approval'
3
+ import { parseTranscript } from '../shared/dialog-answers'
4
+ import { approvedBranches } from './approval'
4
5
  import { allFilesMatch, changedFilesOf } from './check-free'
5
6
  import { decideBashCommand, type GuardPolicy, type MergeGuardDecision } from './decision'
6
7
  import { parseHeadBranch, parseRepositoryFacts, type RepositoryFacts, viewPullRequest } from './pull-request'
@@ -0,0 +1,13 @@
1
+ // Claude Code's agent definitions: a Markdown file per agent whose frontmatter may fix its `model`.
2
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---/
3
+ const FRONTMATTER_MODEL = /^model:\s*['"]?([^'"\s#]+)['"]?\s*$/m
4
+ /** An agent name that is one plain file name: lower-case letters, digits and dashes. */
5
+ const AGENT_NAME = /^[a-z0-9-]+$/
6
+
7
+ export const isAgentName = (name: string): boolean => AGENT_NAME.test(name)
8
+
9
+ /** The `model` of an agent definition's frontmatter; null without one. */
10
+ export const frontmatterModel = (definition: string): string | null => {
11
+ const frontmatter = FRONTMATTER.exec(definition)?.[1]
12
+ return frontmatter ? (FRONTMATTER_MODEL.exec(frontmatter)?.[1] ?? null) : null
13
+ }
@@ -0,0 +1,58 @@
1
+ // PreToolUse hook of Claude Code for the Agent tool, shipped by jackherd: the client adapter of the model gate. Before an
2
+ // implementing subagent starts, it compares the call's model (else the agent definition's) with the plan's
3
+ // recommendation for the phase the prompt names: a weaker model is denied with the recommendation, a stronger one gets
4
+ // a note. Anything else, and any failure, answers nothing, so the call runs.
5
+ import { homedir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { projectAndHomeConfigs, repositoryRootOf } from '../shared/checkout'
8
+ import { readTextOrNull, realPathOrNull } from '../shared/files'
9
+ import { isRecord } from '../shared/json'
10
+ import { frontmatterModel, isAgentName } from './agent-definition'
11
+ import { MODEL_GATE, type ModelGateDependencies, type ModelGateOutcome, runModelGate, type SubagentCall } from './run'
12
+
13
+ const HOOK_EVENT = 'PreToolUse'
14
+
15
+ /** Claude Code's agent definitions: the project's `.claude/agents/<name>.md`, then the home directory's. */
16
+ const agentModel = (root: string, agentType: string): string | null =>
17
+ [join(root, '.claude', 'agents'), join(homedir(), '.claude', 'agents')]
18
+ .map((directory) => readTextOrNull(join(directory, `${agentType}.md`)))
19
+ .map((definition) => (definition === null ? null : frontmatterModel(definition)))
20
+ .find((model) => model !== null) ?? null
21
+
22
+ const dependencies: ModelGateDependencies = {
23
+ repositoryRootOf,
24
+ configs: projectAndHomeConfigs,
25
+ realPath: realPathOrNull,
26
+ readText: readTextOrNull,
27
+ agentModel,
28
+ }
29
+
30
+ /** Claude Code's hook input: `cwd` and the Agent tool's `subagent_type`, `model` and `prompt`. */
31
+ const subagentCallOf = (text: string): SubagentCall | null => {
32
+ const input: unknown = JSON.parse(text)
33
+ if (!isRecord(input) || typeof input.cwd !== 'string' || !isRecord(input.tool_input)) return null
34
+ const { subagent_type: agentType, model, prompt } = input.tool_input
35
+ if (typeof agentType !== 'string' || !isAgentName(agentType) || typeof prompt !== 'string') return null
36
+ return { cwd: input.cwd, agentType, model: typeof model === 'string' && model !== '' ? model : null, prompt }
37
+ }
38
+
39
+ const decide = async (): Promise<ModelGateOutcome | null> => {
40
+ try {
41
+ const call = subagentCallOf(await Bun.stdin.text())
42
+ return call ? runModelGate(call, dependencies) : null
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ const hookOutput = (outcome: ModelGateOutcome): Record<string, unknown> | null => {
49
+ if (outcome.kind === MODEL_GATE.deny)
50
+ return { hookEventName: HOOK_EVENT, permissionDecision: 'deny', permissionDecisionReason: outcome.reason }
51
+ if (outcome.kind === MODEL_GATE.note) return { hookEventName: HOOK_EVENT, additionalContext: outcome.context }
52
+ return null
53
+ }
54
+
55
+ const outcome = await decide()
56
+ const output = outcome ? hookOutput(outcome) : null
57
+ if (output) console.log(JSON.stringify({ hookSpecificOutput: output }))
58
+ process.exit(0)
@@ -0,0 +1,63 @@
1
+ import { compareModels, familyOf, rankedFamiliesIn, MODEL_COMPARISON, type ModelComparison } from '../shared/model-family'
2
+ import { type PhaseRecommendation, type PlanFiles, phaseRecommendation, subagentSettingsOf } from '../shared/subagent-phase'
3
+
4
+ /** A subagent's start as the client hands it over: its directory, agent, model (null when the call names none) and prompt. */
5
+ export type SubagentCall = { cwd: string; agentType: string; model: string | null; prompt: string }
6
+
7
+ export const MODEL_GATE = { pass: 'pass', deny: 'deny', note: 'note' } as const
8
+
9
+ export type ModelGateOutcome =
10
+ | { kind: typeof MODEL_GATE.pass }
11
+ | { kind: typeof MODEL_GATE.deny; reason: string }
12
+ | { kind: typeof MODEL_GATE.note; context: string }
13
+
14
+ /** Everything with a side effect, passed in so a test reaches the flow without git, files or a home directory. */
15
+ export type ModelGateDependencies = PlanFiles & {
16
+ repositoryRootOf: (cwd: string) => string
17
+ /** The parsed configs, the project's first, then the home directory's. */
18
+ configs: (root: string) => Record<string, unknown>[]
19
+ /** The `model` of the agent's definition, the project's before the home directory's; null without one. */
20
+ agentModel: (root: string, agentType: string) => string | null
21
+ }
22
+
23
+ const PASS: ModelGateOutcome = { kind: MODEL_GATE.pass }
24
+
25
+ /** The situation in families only: the plan's cell and the call's model are text a lane wrote, never quoted. */
26
+ const situation = ({ phase, plan, cell }: PhaseRecommendation, agentType: string, model: string): string =>
27
+ `Phase P${phase} of ${plan} recommends ${rankedFamiliesIn(cell ?? '').join(' or ')}, and this call starts ${agentType} on ${familyOf(model)}`
28
+
29
+ /** Deny below the recommendation, a note above it, nothing within it or when either side names no family. */
30
+ const outcomeOf = (comparison: ModelComparison, situationText: string): ModelGateOutcome => {
31
+ if (comparison.kind === MODEL_COMPARISON.weaker)
32
+ return { kind: MODEL_GATE.deny, reason: `${situationText}. Start it again with model: "${comparison.recommended}".` }
33
+ if (comparison.kind === MODEL_COMPARISON.stronger)
34
+ return {
35
+ kind: MODEL_GATE.note,
36
+ context: `${situationText}, a stronger model. Start it with model: "${comparison.recommended}" unless the stronger one is intended.`,
37
+ }
38
+ return PASS
39
+ }
40
+
41
+ const gate = (call: SubagentCall, dependencies: ModelGateDependencies): ModelGateOutcome => {
42
+ const root = dependencies.repositoryRootOf(call.cwd)
43
+ const settings = subagentSettingsOf(dependencies.configs(root))
44
+ if (!settings.agentNames.includes(call.agentType)) return PASS
45
+ const found = phaseRecommendation(root, call.prompt, settings.planDirectory, dependencies)
46
+ const model = call.model ?? dependencies.agentModel(root, call.agentType)
47
+ if (!found?.cell || !model) return PASS
48
+ return outcomeOf(compareModels(found.cell, model), situation(found, call.agentType, model))
49
+ }
50
+
51
+ /**
52
+ * The model gate for the start of an implementing subagent: the phase its prompt names (`Phase P<n>`), that phase's
53
+ * row in the model table of the plan the prompt names, and the model of the call, else of the agent definition.
54
+ * A weaker model is denied with the recommendation, a stronger one passes with a note. Without a phase, a plan, a
55
+ * row or a model, and on any failure, the call passes: the gate never raises a false alarm.
56
+ */
57
+ export const runModelGate = (call: SubagentCall, dependencies: ModelGateDependencies): ModelGateOutcome => {
58
+ try {
59
+ return gate(call, dependencies)
60
+ } catch {
61
+ return PASS
62
+ }
63
+ }
@@ -0,0 +1,60 @@
1
+ // SubagentStop hook of Claude Code, shipped by jackherd: the client adapter of the model log. When an implementing
2
+ // subagent ends, it records the model of its last answer against the phase and plan its prompt names, in
3
+ // `.jackherd/models/` of the main checkout, for the herd. It prints nothing and never blocks.
4
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs'
5
+ import { mainCheckoutOf, projectAndHomeConfigs, repositoryRootOf } from '../shared/checkout'
6
+ import { readTextOrNull, realPathOrNull } from '../shared/files'
7
+ import { isRecord } from '../shared/json'
8
+ import { type ModelLogDependencies, runModelLog, type SubagentStop, type TranscriptEnds } from './run'
9
+ import { writeRecord } from './store'
10
+
11
+ /** The prompt is the transcript's first line; one long prompt still fits. */
12
+ const HEAD_BYTES = 1024 * 1024
13
+ /** The last answers lie at the end; a long run's transcript grows to many megabytes. */
14
+ const TAIL_BYTES = 4 * 1024 * 1024
15
+
16
+ /** The first and the last bytes of the transcript; a cut line at either edge is skipped by the parser. */
17
+ const readTranscript = (path: string): TranscriptEnds => {
18
+ const descriptor = openSync(path, 'r')
19
+ try {
20
+ const size = fstatSync(descriptor).size
21
+ const read = (length: number, position: number): string => {
22
+ const buffer = Buffer.alloc(length)
23
+ readSync(descriptor, buffer, 0, length, position)
24
+ return buffer.toString('utf8')
25
+ }
26
+ const tailLength = Math.min(size, TAIL_BYTES)
27
+ return { head: read(Math.min(size, HEAD_BYTES), 0), tail: read(tailLength, size - tailLength) }
28
+ } finally {
29
+ closeSync(descriptor)
30
+ }
31
+ }
32
+
33
+ const dependencies: ModelLogDependencies = {
34
+ repositoryRootOf,
35
+ mainCheckoutOf,
36
+ configs: projectAndHomeConfigs,
37
+ realPath: realPathOrNull,
38
+ readText: readTextOrNull,
39
+ readTranscript,
40
+ writeRecord,
41
+ now: () => Date.now(),
42
+ }
43
+
44
+ /** Claude Code's hook input: `cwd`, `agent_type` and `agent_transcript_path`. */
45
+ const subagentStopOf = (text: string): SubagentStop | null => {
46
+ const input: unknown = JSON.parse(text)
47
+ if (!isRecord(input)) return null
48
+ const { cwd, agent_type: agentType, agent_transcript_path: transcriptPath } = input
49
+ return typeof cwd === 'string' && typeof agentType === 'string' && typeof transcriptPath === 'string'
50
+ ? { cwd, agentType, transcriptPath }
51
+ : null
52
+ }
53
+
54
+ try {
55
+ const stop = subagentStopOf(await Bun.stdin.text())
56
+ if (stop) runModelLog(stop, dependencies)
57
+ } catch {
58
+ // Unreadable input records nothing.
59
+ }
60
+ process.exit(0)
@@ -0,0 +1,57 @@
1
+ import { compareModels, MODEL_COMPARISON } from '../shared/model-family'
2
+ import { type PlanFiles, phaseRecommendation, subagentSettingsOf } from '../shared/subagent-phase'
3
+ import type { PhaseModelRecord } from './store'
4
+ import { lastModelOf, promptOf } from './transcript'
5
+
6
+ /** A subagent's end as the client hands it over: the session's directory, the agent and its transcript. */
7
+ export type SubagentStop = { cwd: string; agentType: string; transcriptPath: string }
8
+
9
+ /** A transcript's start, where the prompt is, and its end, where the last answer is. */
10
+ export type TranscriptEnds = { head: string; tail: string }
11
+
12
+ /** Everything with a side effect, passed in so a test reaches the flow without git, files or a clock. */
13
+ export type ModelLogDependencies = PlanFiles & {
14
+ repositoryRootOf: (cwd: string) => string
15
+ mainCheckoutOf: (cwd: string) => string
16
+ /** The parsed configs, the project's first, then the home directory's. */
17
+ configs: (root: string) => Record<string, unknown>[]
18
+ readTranscript: (path: string) => TranscriptEnds
19
+ writeRecord: (main: string, record: PhaseModelRecord) => void
20
+ now: () => number
21
+ }
22
+
23
+ const recordOf = (stop: SubagentStop, dependencies: ModelLogDependencies): PhaseModelRecord | null => {
24
+ const root = dependencies.repositoryRootOf(stop.cwd)
25
+ const settings = subagentSettingsOf(dependencies.configs(root))
26
+ if (!settings.agentNames.includes(stop.agentType)) return null
27
+ const { head, tail } = dependencies.readTranscript(stop.transcriptPath)
28
+ const prompt = promptOf(head)
29
+ const answered = lastModelOf(tail)
30
+ const found = prompt ? phaseRecommendation(root, prompt, settings.planDirectory, dependencies) : null
31
+ if (!found || !answered) return null
32
+ const comparison = found.cell ? compareModels(found.cell, answered.model) : null
33
+ const known = comparison && comparison.kind !== MODEL_COMPARISON.unknown ? comparison : null
34
+ return {
35
+ plan: found.plan,
36
+ phase: found.phase,
37
+ model: answered.model,
38
+ used: answered.family,
39
+ recommended: known?.recommended ?? null,
40
+ fits: known ? known.kind === MODEL_COMPARISON.fits : null,
41
+ at: dependencies.now(),
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Records the model an implementing subagent ran on against the phase and plan its prompt names, in the main checkout,
47
+ * for the herd. Records nothing for another agent, a prompt without phase or plan, or a transcript without a model,
48
+ * and nothing on any failure: the log never stands in the agent's way.
49
+ */
50
+ export const runModelLog = (stop: SubagentStop, dependencies: ModelLogDependencies): void => {
51
+ try {
52
+ const record = recordOf(stop, dependencies)
53
+ if (record) dependencies.writeRecord(dependencies.mainCheckoutOf(stop.cwd), record)
54
+ } catch {
55
+ // A failed record is no record; the herd shows the phase without its model.
56
+ }
57
+ }
@@ -0,0 +1,40 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { chmodSync, renameSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import type { ModelFamily } from '../shared/model-family'
5
+ import { ensurePrivateDirectory, PRIVATE_FILE } from '../shared/private-directory'
6
+
7
+ /**
8
+ * The model an implementing subagent of a phase ran on: the plan (from the repository root, `/`-separated), the phase,
9
+ * the model id of its last answer and its family, the family the plan recommends with whether the model fits it (both
10
+ * null when the plan's row names none), and when it ended.
11
+ */
12
+ export type PhaseModelRecord = {
13
+ plan: string
14
+ phase: string
15
+ model: string
16
+ used: ModelFamily
17
+ recommended: ModelFamily | null
18
+ fits: boolean | null
19
+ at: number
20
+ }
21
+
22
+ const KEY_LENGTH = 16
23
+
24
+ /** Where the records lie: in the main checkout beside `.jackherd/approvals`, which the herd reads. Ignored by git. */
25
+ export const modelsDirectory = (main: string): string => join(main, '.jackherd', 'models')
26
+
27
+ /** One file per plan and phase, so a later run of the phase replaces the record of the earlier one. */
28
+ const recordFileName = ({ plan, phase }: Pick<PhaseModelRecord, 'plan' | 'phase'>): string =>
29
+ `${createHash('sha256').update(`${plan}\n${phase}`).digest('hex').slice(0, KEY_LENGTH)}.json`
30
+
31
+ /** Writes the record through a temporary file, so a reader never sees half of it. */
32
+ export const writeRecord = (main: string, record: PhaseModelRecord): void => {
33
+ const directory = modelsDirectory(main)
34
+ ensurePrivateDirectory(directory)
35
+ const path = join(directory, recordFileName(record))
36
+ const temporary = `${path}.${process.pid}.tmp`
37
+ writeFileSync(temporary, `${JSON.stringify(record)}\n`, { mode: PRIVATE_FILE })
38
+ chmodSync(temporary, PRIVATE_FILE)
39
+ renameSync(temporary, path)
40
+ }
@@ -0,0 +1,37 @@
1
+ import { parseTranscript } from '../shared/dialog-answers'
2
+ import { isRecord, type JsonObject } from '../shared/json'
3
+ import { familyOf, type ModelFamily } from '../shared/model-family'
4
+
5
+ const USER_ENTRY = 'user'
6
+ const ASSISTANT_ENTRY = 'assistant'
7
+ const TEXT_BLOCK = 'text'
8
+
9
+ const messageOf = (entry: unknown): JsonObject | null =>
10
+ isRecord(entry) && isRecord(entry.message) ? entry.message : null
11
+
12
+ const textOf = (content: unknown): string | null => {
13
+ if (typeof content === 'string') return content
14
+ if (!Array.isArray(content)) return null
15
+ const texts = content.flatMap((block) =>
16
+ isRecord(block) && block.type === TEXT_BLOCK && typeof block.text === 'string' ? [block.text] : [],
17
+ )
18
+ return texts.length > 0 ? texts.join('\n') : null
19
+ }
20
+
21
+ /** The subagent's prompt: the text of the first user entry of its transcript; null without one. */
22
+ export const promptOf = (head: string): string | null => {
23
+ const first = parseTranscript(head).find((entry) => isRecord(entry) && entry.type === USER_ENTRY)
24
+ return textOf(messageOf(first)?.content)
25
+ }
26
+
27
+ /** A model id with its family. */
28
+ export type AnsweredModel = { model: string; family: ModelFamily }
29
+
30
+ const answeredModel = (entry: unknown): AnsweredModel[] => {
31
+ const model = isRecord(entry) && entry.type === ASSISTANT_ENTRY ? messageOf(entry)?.model : undefined
32
+ const family = typeof model === 'string' ? familyOf(model) : null
33
+ return typeof model === 'string' && family ? [{ model, family }] : []
34
+ }
35
+
36
+ /** The model of the last answer that names a model family (`<synthetic>` names none); null without one. */
37
+ export const lastModelOf = (tail: string): AnsweredModel | null => parseTranscript(tail).flatMap(answeredModel).at(-1) ?? null
@@ -0,0 +1,22 @@
1
+ import { homedir } from 'node:os'
2
+ import { basename, dirname } from 'node:path'
3
+ import { commandOutput } from './command'
4
+ import { readConfig } from './config'
5
+
6
+ const gitOutput = (cwd: string, ...args: string[]): string => commandOutput(['git', ...args], cwd)
7
+
8
+ /** The main checkout: the directory that holds the repository's common git directory. Throws outside a repository. */
9
+ export const mainCheckoutOf = (cwd: string): string => {
10
+ const common = gitOutput(cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir')
11
+ if (basename(common) !== '.git') throw new Error('no main checkout')
12
+ return dirname(common)
13
+ }
14
+
15
+ /** The top of the checkout `cwd` lies in, a worktree's own top for a worktree. Throws outside a repository. */
16
+ export const repositoryRootOf = (cwd: string): string => gitOutput(cwd, 'rev-parse', '--show-toplevel')
17
+
18
+ /** The parsed configs of the main checkout and of the home directory, in that order: a lane's worktree has no say. */
19
+ export const projectAndHomeConfigs = (root: string): Record<string, unknown>[] => [
20
+ readConfig([mainCheckoutOf(root)]),
21
+ readConfig([homedir()]),
22
+ ]
@@ -0,0 +1,6 @@
1
+ /** The trimmed stdout of a command run without a shell; throws when it fails, so the caller gives up. */
2
+ export const commandOutput = (command: string[], cwd?: string): string => {
3
+ const result = Bun.spawnSync(command, { cwd, stdout: 'pipe', stderr: 'ignore' })
4
+ if (result.exitCode !== 0) throw new Error(`${command[0]} failed`)
5
+ return result.stdout.toString().trim()
6
+ }
@@ -0,0 +1,21 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { isRecord } from './json'
4
+
5
+ const CONFIG_PATH = join('.jackherd', 'config.json')
6
+
7
+ /** The first `.jackherd/config.json` of the directories that exists, as an object; `{}` when none or unreadable. A hook has no schema library, `jh` validates the file. */
8
+ export const readConfig = (directories: readonly string[]): Record<string, unknown> => {
9
+ const path = directories.map((directory) => join(directory, CONFIG_PATH)).find((candidate) => existsSync(candidate))
10
+ if (!path) return {}
11
+ try {
12
+ const config: unknown = JSON.parse(readFileSync(path, 'utf8'))
13
+ return isRecord(config) ? config : {}
14
+ } catch {
15
+ return {}
16
+ }
17
+ }
18
+
19
+ /** The object under `key`, `{}` when it is missing or no object. */
20
+ export const sectionOf = (config: Record<string, unknown>, key: string): Record<string, unknown> =>
21
+ isRecord(config[key]) ? config[key] : {}
@@ -0,0 +1,5 @@
1
+ /**
2
+ * One question the user answered in a dialog, whatever client wrote it down. `offered` holds the labels of a
3
+ * single-choice question; a multi-select joins its picks into one text and offers none.
4
+ */
5
+ export type DialogAnswer = { question: string; answer: string; offered: string[]; answeredAt: number }
@@ -0,0 +1,74 @@
1
+ import type { DialogAnswer } from './dialog-answer'
2
+ import { isRecord, type JsonObject } from './json'
3
+
4
+ export type { DialogAnswer } from './dialog-answer'
5
+
6
+ /**
7
+ * The answers of the user's dialogs, read from a session transcript. The transcript is written by the agent's own
8
+ * client, not by the agent: a dialog answer sits in it as the result of an `AskUserQuestion` call, with the question the
9
+ * agent put and the label the user picked. Nothing an agent says in a message, a file or a command is one.
10
+ */
11
+
12
+ const contentOf = (entry: JsonObject): JsonObject[] => {
13
+ const content = isRecord(entry.message) ? entry.message.content : undefined
14
+ return Array.isArray(content) ? content.filter(isRecord) : []
15
+ }
16
+
17
+ const isMainThread = (entry: JsonObject): boolean => entry.isSidechain !== true
18
+
19
+ /** One JSON value per line; a line that is not JSON is skipped. */
20
+ export const parseTranscript = (text: string): unknown[] =>
21
+ text.split('\n').flatMap((line) => {
22
+ try {
23
+ return line.trim() === '' ? [] : [JSON.parse(line)]
24
+ } catch {
25
+ return []
26
+ }
27
+ })
28
+
29
+ const offeredLabels = (question: JsonObject): string[] =>
30
+ question.multiSelect !== true && Array.isArray(question.options)
31
+ ? question.options.flatMap((option) => (isRecord(option) && typeof option.label === 'string' ? [option.label] : []))
32
+ : []
33
+
34
+ type AskedQuestion = { question: string; offered: string[] }
35
+
36
+ /** The questions of an `AskUserQuestion` input; a call that carries its own `answers` asked nobody. */
37
+ const questionsOf = (input: unknown): AskedQuestion[] => {
38
+ if (!isRecord(input) || 'answers' in input || !Array.isArray(input.questions)) return []
39
+ return input.questions.flatMap((question) =>
40
+ isRecord(question) && typeof question.question === 'string'
41
+ ? [{ question: question.question, offered: offeredLabels(question) }]
42
+ : [],
43
+ )
44
+ }
45
+
46
+ /** The questions of each `AskUserQuestion` call of the main thread, by tool use id. */
47
+ const askedQuestions = (entries: JsonObject[]): Map<string, AskedQuestion[]> =>
48
+ new Map(
49
+ entries
50
+ .filter(isMainThread)
51
+ .flatMap(contentOf)
52
+ .filter((block) => block.type === 'tool_use' && block.name === 'AskUserQuestion' && typeof block.id === 'string')
53
+ .map((block): [string, AskedQuestion[]] => [block.id as string, questionsOf(block.input)]),
54
+ )
55
+
56
+ const answersIn = (entry: JsonObject, asked: Map<string, AskedQuestion[]>): DialogAnswer[] => {
57
+ const answers = isRecord(entry.toolUseResult) ? entry.toolUseResult.answers : undefined
58
+ const answeredAt = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN
59
+ if (!isRecord(answers) || Number.isNaN(answeredAt)) return []
60
+ return contentOf(entry)
61
+ .filter((block) => block.type === 'tool_result')
62
+ .flatMap((block) => asked.get(String(block.tool_use_id)) ?? [])
63
+ .flatMap(({ question, offered }) => {
64
+ const answer = answers[question]
65
+ return typeof answer === 'string' ? [{ question, answer, offered, answeredAt }] : []
66
+ })
67
+ }
68
+
69
+ /** Every question of the main thread the user answered in a dialog, in transcript order. */
70
+ export const dialogAnswers = (entries: readonly unknown[]): DialogAnswer[] => {
71
+ const records = entries.filter(isRecord)
72
+ const asked = askedQuestions(records)
73
+ return records.filter(isMainThread).flatMap((entry) => answersIn(entry, asked))
74
+ }
@@ -0,0 +1,19 @@
1
+ import { readFileSync, realpathSync } from 'node:fs'
2
+
3
+ /** The file's text; null when it cannot be read. */
4
+ export const readTextOrNull = (path: string): string | null => {
5
+ try {
6
+ return readFileSync(path, 'utf8')
7
+ } catch {
8
+ return null
9
+ }
10
+ }
11
+
12
+ /** Where the path resolves with every link followed; null when it does not exist. */
13
+ export const realPathOrNull = (path: string): string | null => {
14
+ try {
15
+ return realpathSync(path)
16
+ } catch {
17
+ return null
18
+ }
19
+ }
@@ -0,0 +1,4 @@
1
+ export const MS_PER_MINUTE = 60_000
2
+
3
+ /** Whether a moment lies within the last `minutes` before `now`; one from the future never does. */
4
+ export const isFresh = (at: number, now: number, minutes: number): boolean => at <= now && now - at <= minutes * MS_PER_MINUTE
@@ -0,0 +1,4 @@
1
+ export type JsonObject = Record<string, unknown>
2
+
3
+ export const isRecord = (value: unknown): value is JsonObject =>
4
+ typeof value === 'object' && value !== null && !Array.isArray(value)