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.
- package/CHANGELOG.md +31 -0
- package/INSTALL.md +176 -0
- package/README.md +260 -315
- package/assets/branding/README.md +7 -3
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -1
- package/assets/icons/bark-dark.svg +1 -0
- package/assets/icons/bark.svg +1 -0
- package/assets/icons/bowl-dark.svg +1 -0
- package/assets/icons/bowl.svg +1 -0
- package/assets/icons/decide-dark.svg +1 -0
- package/assets/icons/decide.svg +1 -0
- package/assets/icons/ear-dark.svg +1 -0
- package/assets/icons/ear.svg +1 -0
- package/assets/icons/lanes-dark.svg +1 -0
- package/assets/icons/lanes.svg +1 -0
- package/assets/icons/plan-dark.svg +1 -0
- package/assets/icons/plan.svg +1 -0
- package/assets/icons/space.svg +1 -0
- package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
- package/dist/config.d.ts +14 -3
- package/dist/config.js +111 -10
- package/dist/plan-format.d.ts +180 -77
- package/dist/plan-format.js +292 -93
- package/docs/lanes-dark.svg +50 -0
- package/docs/lanes.svg +50 -1
- package/docs/skills-dark.svg +65 -0
- package/docs/skills.svg +65 -1
- package/docs/workflow-dark.svg +138 -0
- package/docs/workflow.svg +135 -169
- package/herdr-plugin/herdr-plugin.toml +6 -6
- package/herdr-plugin/open-plans.ts +1 -1
- package/herdr-plugin/restore-plans.ts +7 -7
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
- package/kit/hooks/hooks/approval-gate/index.ts +69 -0
- package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
- package/kit/hooks/hooks/approval-gate/request.ts +111 -0
- package/kit/hooks/hooks/approval-gate/run.ts +94 -0
- package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
- package/kit/hooks/hooks/approval-gate/store.ts +70 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
- package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
- package/kit/hooks/hooks/merge-guard/run.ts +2 -1
- package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
- package/kit/hooks/hooks/model-gate/index.ts +58 -0
- package/kit/hooks/hooks/model-gate/run.ts +63 -0
- package/kit/hooks/hooks/model-log/index.ts +60 -0
- package/kit/hooks/hooks/model-log/run.ts +57 -0
- package/kit/hooks/hooks/model-log/store.ts +40 -0
- package/kit/hooks/hooks/model-log/transcript.ts +37 -0
- package/kit/hooks/hooks/shared/checkout.ts +22 -0
- package/kit/hooks/hooks/shared/command.ts +6 -0
- package/kit/hooks/hooks/shared/config.ts +21 -0
- package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
- package/kit/hooks/hooks/shared/files.ts +19 -0
- package/kit/hooks/hooks/shared/fresh.ts +4 -0
- package/kit/hooks/hooks/shared/json.ts +4 -0
- package/kit/hooks/hooks/shared/model-family.ts +48 -0
- package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
- package/kit/hooks/hooks/shared/private-directory.ts +29 -0
- package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
- package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
- package/kit/hooks/hooks.json +25 -0
- package/kit/workflow/skills/follow/SKILL.md +4 -3
- package/kit/workflow/skills/implement/SKILL.md +6 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +113 -30
- package/kit/workflow/skills/plan/SKILL.md +5 -5
- package/kit/workflow/skills/start/SKILL.md +2 -2
- package/package.json +4 -1
- package/schemas/jackherd.schema.json +43 -5
- package/src/check/command.ts +31 -4
- package/src/check/deferral-arrows.ts +1 -1
- package/src/check/parallel-report.ts +23 -0
- package/src/check/plan-structure-check.ts +120 -53
- package/src/cli.ts +8 -2
- package/src/config/nested-quantifier.ts +73 -0
- package/src/config/repository-directory.ts +11 -0
- package/src/config/schema.ts +63 -8
- package/src/plan-format/board-prose.ts +36 -0
- package/src/plan-format/dependencies.ts +227 -0
- package/src/plan-format/documentation-gate.ts +16 -0
- package/src/plan-format/index.ts +23 -1
- package/src/plan-format/lanes.ts +35 -1
- package/src/plan-format/model-column.ts +52 -0
- package/src/plan-format/phases.ts +16 -1
- package/src/plan-format/plan-structure.ts +10 -28
- package/src/plan-format/progress.ts +7 -29
- package/src/plans/command.ts +3 -3
- package/src/plans/current-phase.ts +71 -0
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/herd-command.ts +4 -0
- package/src/plans/lane-discovery.ts +14 -2
- package/src/plans/lane-tickets.ts +69 -0
- package/src/plans/layout.ts +182 -88
- package/src/plans/live.ts +5 -1
- package/src/plans/phase-models-read.ts +37 -0
- package/src/plans/phase-models.ts +70 -0
- package/src/plans/plan-panes.ts +8 -7
- package/src/plans/pull-requests.ts +78 -4
- package/src/plans/selection.ts +1 -40
- package/src/plans/settings.ts +9 -3
- package/src/plans/shown-with-more.ts +5 -0
- package/src/plans/snapshot.ts +82 -8
- package/src/plans/sprint-html.ts +2 -2
- package/src/plans/supervisor.ts +5 -4
- package/src/plans/symbols.ts +9 -6
- package/src/plans/texts.ts +30 -13
- package/src/plans/view.ts +1 -0
- package/src/sync/herdr-plugin.ts +1 -1
- package/src/sync/next-steps.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/setup-questions.ts +2 -2
- package/src/terminal/text.ts +4 -0
- package/src/terminal/types.ts +1 -1
- package/src/vocabulary/index.ts +39 -0
- package/src/watch/approvals.ts +96 -0
- package/src/watch/command.ts +154 -50
- package/src/watch/failed-tests.ts +52 -0
- package/src/watch/failure-read.ts +56 -0
- package/src/watch/failures.ts +107 -0
- package/src/watch/feedback-item.ts +21 -0
- package/src/watch/feedback-read.ts +2 -1
- package/src/watch/feedback.ts +37 -28
- package/src/watch/gh-process.ts +53 -0
- package/src/watch/gh.ts +10 -20
- package/src/watch/heartbeat-progress.ts +1 -1
- package/src/watch/lane-model.ts +54 -0
- package/src/watch/lane-parallel.ts +46 -0
- package/src/watch/lane-progress.ts +28 -4
- package/src/watch/lane-report.ts +62 -9
- package/src/watch/lane-sources.ts +1 -1
- package/src/watch/no-finding.ts +47 -0
- package/src/watch/plan-badges-read.ts +19 -7
- package/src/watch/plan-badges.ts +59 -7
- package/src/watch/plan-views.ts +5 -5
- package/src/watch/previews.ts +3 -9
- package/src/watch/pull-requests.ts +12 -2
- package/src/watch/recorded-reads.ts +25 -0
- package/src/watch/source-fields.ts +4 -0
- package/src/watch/source-tickets.ts +99 -0
- package/src/watch/sources.ts +62 -15
- package/src/watch/texts.ts +21 -1
- package/src/watch/watch-defaults.ts +8 -0
- package/src/watch/watch-events.ts +64 -4
- package/src/watch/watcher-lock.ts +1 -1
- package/src/watch/workflow-runs.ts +5 -7
- package/docs/lanes.mmd +0 -9
- package/docs/skills.mmd +0 -10
|
@@ -1,74 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What the user approved, read from the session transcript
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 {
|
|
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
|
+
}
|