jackherd 0.0.0-stage → 1.0.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 +61 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1123 -2
- package/assets/branding/README.md +62 -0
- package/assets/branding/jackherd-icon-256.png +0 -0
- package/assets/branding/jackherd-icon-512.png +0 -0
- package/assets/branding/jackherd-icon-64.png +0 -0
- package/assets/branding/jackherd-icon-small-32.png +0 -0
- package/assets/branding/jackherd-icon-small-64.png +0 -0
- package/assets/branding/jackherd-icon-small.svg +1 -0
- package/assets/branding/jackherd-icon.svg +1 -0
- package/assets/branding/jackherd-logo-1024.png +0 -0
- package/assets/branding/jackherd-logo-512.png +0 -0
- 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 -0
- package/assets/branding/jackherd-logo-mark-1024.png +0 -0
- package/assets/branding/jackherd-logo-mark-512.png +0 -0
- package/assets/branding/jackherd-logo-mark.svg +1 -0
- package/assets/branding/jackherd-logo.svg +1 -0
- package/dist/chunk-R6MGHCD7.js +93 -0
- package/dist/config.d.ts +167 -0
- package/dist/config.js +401 -0
- package/dist/plan-format.d.ts +325 -0
- package/dist/plan-format.js +636 -0
- package/docs/lanes.mmd +9 -0
- package/docs/lanes.svg +1 -0
- package/docs/skills.mmd +10 -0
- package/docs/skills.svg +1 -0
- package/docs/workflow.svg +172 -0
- package/herdr-plugin/herdr-plugin.toml +22 -0
- package/herdr-plugin/open-plans.ts +43 -0
- package/herdr-plugin/restore-plans.ts +107 -0
- package/install.sh +157 -0
- package/kit/hooks/hooks/git-guard/decision.ts +438 -0
- package/kit/hooks/hooks/git-guard/index.ts +38 -0
- package/kit/hooks/hooks/git-guard/shell.ts +407 -0
- package/kit/hooks/hooks/watch-guard/background.ts +36 -0
- package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
- package/kit/hooks/hooks/watch-guard/index.ts +11 -0
- package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
- package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
- package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
- package/kit/hooks/hooks.json +30 -0
- package/kit/hooks/statusline.sh +72 -0
- package/kit/review/agents/devops.md +11 -0
- package/kit/review/agents/public-content.md +39 -0
- package/kit/review/agents/security-audit.md +11 -0
- package/kit/review/agents/uncle-bob.md +11 -0
- package/kit/review/skills/devops/SKILL.md +50 -0
- package/kit/review/skills/diagnose/SKILL.md +140 -0
- package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
- package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
- package/kit/review/skills/pre-commit/SKILL.md +100 -0
- package/kit/review/skills/review/SKILL.md +43 -0
- package/kit/review/skills/security-audit/SKILL.md +65 -0
- package/kit/review/skills/uncle-bob/SKILL.md +43 -0
- package/kit/rules/rules/kit/arc42.md +89 -0
- package/kit/rules/rules/kit/architecture-files.md +87 -0
- package/kit/rules/rules/kit/code-style.md +159 -0
- package/kit/rules/rules/kit/pull-requests.md +59 -0
- package/kit/rules/rules/kit/testing.md +91 -0
- package/kit/rules/rules/kit/writing-for-agents.md +110 -0
- package/kit/workflow/agents/explore.md +25 -0
- package/kit/workflow/agents/implement.md +16 -0
- package/kit/workflow/agents/plan.md +13 -0
- package/kit/workflow/agents/spec-review.md +96 -0
- package/kit/workflow/skills/follow/SKILL.md +48 -0
- package/kit/workflow/skills/grill/SKILL.md +60 -0
- package/kit/workflow/skills/implement/SKILL.md +107 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
- package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
- package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
- package/kit/workflow/skills/plan/SKILL.md +118 -0
- package/kit/workflow/skills/plan/references/template-English.md +92 -0
- package/kit/workflow/skills/plan/references/template-German.md +92 -0
- package/kit/workflow/skills/start/SKILL.md +47 -0
- package/package.json +65 -4
- package/schemas/jackherd.schema.json +601 -0
- package/src/check/changes.ts +45 -0
- package/src/check/command-line.ts +12 -0
- package/src/check/command.ts +136 -0
- package/src/check/deferral-arrows.ts +41 -0
- package/src/check/execute.ts +29 -0
- package/src/check/glob.ts +15 -0
- package/src/check/plan-structure-check.ts +135 -0
- package/src/check/plan.ts +106 -0
- package/src/check/public-content-check.ts +32 -0
- package/src/check/public-content-facts.ts +177 -0
- package/src/check/public-content.ts +216 -0
- package/src/check/report.ts +55 -0
- package/src/check/result.ts +42 -0
- package/src/check/roots.ts +36 -0
- package/src/check/routing.ts +22 -0
- package/src/check/schema.ts +83 -0
- package/src/check/settings.ts +44 -0
- package/src/claude/paths.ts +14 -0
- package/src/cli.ts +46 -0
- package/src/config/command.ts +45 -0
- package/src/config/detect.ts +196 -0
- package/src/config/index.ts +2 -0
- package/src/config/json-schema.ts +5 -0
- package/src/config/legacy-names.ts +56 -0
- package/src/config/link-kinds.ts +25 -0
- package/src/config/location.ts +46 -0
- package/src/config/read-config.ts +42 -0
- package/src/config/repository-facts.ts +19 -0
- package/src/config/repository-values.ts +46 -0
- package/src/config/schema.ts +302 -0
- package/src/context/command.ts +39 -0
- package/src/context/coordinator-name.ts +16 -0
- package/src/context/fill.ts +69 -0
- package/src/context/index.ts +10 -0
- package/src/context/level.ts +22 -0
- package/src/context/options.ts +29 -0
- package/src/context/report.ts +42 -0
- package/src/context/session-log.ts +91 -0
- package/src/plan-format/drift.ts +69 -0
- package/src/plan-format/header.ts +27 -0
- package/src/plan-format/index.ts +9 -0
- package/src/plan-format/lanes.ts +128 -0
- package/src/plan-format/markdown.ts +98 -0
- package/src/plan-format/phase-tally.ts +55 -0
- package/src/plan-format/phases.ts +224 -0
- package/src/plan-format/plan-structure.ts +150 -0
- package/src/plan-format/progress.ts +202 -0
- package/src/plan-format/sprint-document.ts +69 -0
- package/src/plan-format/terminal-text.ts +28 -0
- package/src/plans/board-choice.ts +60 -0
- package/src/plans/board-pick.ts +72 -0
- package/src/plans/board-rows.ts +62 -0
- package/src/plans/command.ts +60 -0
- package/src/plans/ensure-panes.ts +71 -0
- package/src/plans/error-text.ts +5 -0
- package/src/plans/fetch.ts +96 -0
- package/src/plans/git.ts +145 -0
- package/src/plans/github.ts +35 -0
- package/src/plans/lane-discovery.ts +34 -0
- package/src/plans/lane-plans.ts +80 -0
- package/src/plans/layout.ts +881 -0
- package/src/plans/link-chips.ts +74 -0
- package/src/plans/live.ts +336 -0
- package/src/plans/markdown-html.ts +140 -0
- package/src/plans/options.ts +15 -0
- package/src/plans/phase-source.ts +71 -0
- package/src/plans/plan-chips.ts +75 -0
- package/src/plans/plan-panes.ts +206 -0
- package/src/plans/planned.ts +117 -0
- package/src/plans/pull-requests.ts +107 -0
- package/src/plans/restart-decision.ts +42 -0
- package/src/plans/selection.ts +352 -0
- package/src/plans/settings.ts +68 -0
- package/src/plans/single-flight.ts +25 -0
- package/src/plans/snapshot.ts +300 -0
- package/src/plans/sprint-export.ts +103 -0
- package/src/plans/sprint-html.ts +301 -0
- package/src/plans/sprint-page.ts +287 -0
- package/src/plans/supervisor.ts +124 -0
- package/src/plans/symbols.ts +54 -0
- package/src/plans/texts.ts +103 -0
- package/src/plans/unlisted-lanes.ts +45 -0
- package/src/plans/view.ts +68 -0
- package/src/plans/worked-phases.ts +53 -0
- package/src/sync/checksum.ts +13 -0
- package/src/sync/choice.ts +14 -0
- package/src/sync/command-context.ts +113 -0
- package/src/sync/command.ts +94 -0
- package/src/sync/error-message.ts +1 -0
- package/src/sync/gitignore.ts +45 -0
- package/src/sync/global-kit.ts +176 -0
- package/src/sync/herdr-plugin.ts +175 -0
- package/src/sync/if-changed.ts +109 -0
- package/src/sync/init-command.ts +239 -0
- package/src/sync/init.ts +136 -0
- package/src/sync/kit-hooks.ts +18 -0
- package/src/sync/kit-path.ts +77 -0
- package/src/sync/kit-source.ts +169 -0
- package/src/sync/kit.ts +9 -0
- package/src/sync/legacy-files.ts +116 -0
- package/src/sync/manifest.ts +44 -0
- package/src/sync/next-steps.ts +42 -0
- package/src/sync/plan-file.ts +154 -0
- package/src/sync/plan-hooks.ts +165 -0
- package/src/sync/project-fs.ts +132 -0
- package/src/sync/render.ts +60 -0
- package/src/sync/report.ts +96 -0
- package/src/sync/settings-hooks.ts +90 -0
- package/src/sync/settings-migration.ts +94 -0
- package/src/sync/settings-offer.ts +168 -0
- package/src/sync/setup-questions.ts +70 -0
- package/src/sync/setup.ts +52 -0
- package/src/sync/sync-plan.ts +120 -0
- package/src/sync/sync-result.ts +9 -0
- package/src/sync/sync.ts +307 -0
- package/src/sync/tools.ts +52 -0
- package/src/terminal/herdr.ts +91 -0
- package/src/terminal/index.ts +13 -0
- package/src/terminal/none.ts +7 -0
- package/src/terminal/open.ts +39 -0
- package/src/terminal/text.ts +159 -0
- package/src/terminal/types.ts +30 -0
- package/src/terminal/width.ts +90 -0
- package/src/update/command.ts +65 -0
- package/src/update/package-manager.ts +56 -0
- package/src/update/update.ts +105 -0
- package/src/version/installed-version.ts +32 -0
- package/src/version/release.ts +22 -0
- package/src/version/skill-diff.ts +63 -0
- package/src/version/started-kit.ts +14 -0
- package/src/vocabulary/index.ts +132 -0
- package/src/watch/base-tip.ts +18 -0
- package/src/watch/command.ts +919 -0
- package/src/watch/coordinator-fill.ts +76 -0
- package/src/watch/feedback-read.ts +73 -0
- package/src/watch/feedback.ts +308 -0
- package/src/watch/gh.ts +22 -0
- package/src/watch/github-read.ts +175 -0
- package/src/watch/herdr.ts +11 -0
- package/src/watch/json-guards.ts +9 -0
- package/src/watch/kit-update.ts +18 -0
- package/src/watch/lane-fill.ts +36 -0
- package/src/watch/lane-message.ts +99 -0
- package/src/watch/lane-progress.ts +44 -0
- package/src/watch/lane-report-file.ts +53 -0
- package/src/watch/lane-report.ts +270 -0
- package/src/watch/lane-search.ts +41 -0
- package/src/watch/lane-sources.ts +46 -0
- package/src/watch/lane-watch.ts +121 -0
- package/src/watch/monitor-tool.ts +19 -0
- package/src/watch/options.ts +79 -0
- package/src/watch/plan-badges-read.ts +30 -0
- package/src/watch/plan-badges.ts +66 -0
- package/src/watch/plan-views.ts +59 -0
- package/src/watch/preview-read.ts +60 -0
- package/src/watch/previews.ts +234 -0
- package/src/watch/pull-requests.ts +117 -0
- package/src/watch/single-report.ts +15 -0
- package/src/watch/skill-change.ts +61 -0
- package/src/watch/source-dry-run-command.ts +35 -0
- package/src/watch/source-dry-run.ts +124 -0
- package/src/watch/source-fields.ts +9 -0
- package/src/watch/source-path.ts +40 -0
- package/src/watch/source-run.ts +90 -0
- package/src/watch/sources.ts +344 -0
- package/src/watch/stream-output.ts +14 -0
- package/src/watch/texts.ts +76 -0
- package/src/watch/watch-events.ts +399 -0
- package/src/watch/watcher-lock.ts +145 -0
- package/src/watch/workflow-runs.ts +96 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
import { type ChecksState, checksOf, type GhPullRequest, isGhPullRequest } from '../plans/pull-requests'
|
|
3
|
+
import { isRecord, parseArray } from './json-guards'
|
|
4
|
+
|
|
5
|
+
/** The states of a pull request in `gh pr list --json state`. */
|
|
6
|
+
export const PULL_REQUEST_STATE = { open: 'OPEN', merged: 'MERGED', closed: 'CLOSED' } as const
|
|
7
|
+
|
|
8
|
+
export type PullRequestLifecycle = (typeof PULL_REQUEST_STATE)[keyof typeof PULL_REQUEST_STATE]
|
|
9
|
+
|
|
10
|
+
/** A pull request as the lane watcher follows it, across open, merged and closed. */
|
|
11
|
+
export type WatchedPullRequest = {
|
|
12
|
+
number: number
|
|
13
|
+
branch: string
|
|
14
|
+
state: PullRequestLifecycle
|
|
15
|
+
checks: ChecksState
|
|
16
|
+
headCommit: string
|
|
17
|
+
mergeCommit: string | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The rollup is optional: the light list has none. */
|
|
21
|
+
type GhWatchedPullRequest = Omit<GhPullRequest, 'statusCheckRollup'> & {
|
|
22
|
+
statusCheckRollup?: GhPullRequest['statusCheckRollup']
|
|
23
|
+
state: PullRequestLifecycle
|
|
24
|
+
headRefOid: string
|
|
25
|
+
mergeCommit: { oid: string } | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The fields of the light list, `gh pr list --state all --json`: everything the watcher follows except the checks.
|
|
30
|
+
* `statusCheckRollup` is what made the old single call too heavy for GitHub's GraphQL (a 504 after ~10 s).
|
|
31
|
+
*/
|
|
32
|
+
export const WATCHED_PULL_REQUEST_FIELDS = 'number,title,url,headRefName,isDraft,state,headRefOid,mergeCommit'
|
|
33
|
+
|
|
34
|
+
/** The fields of the list of open pull requests, which is the only one that asks for their checks. */
|
|
35
|
+
export const OPEN_PULL_REQUEST_FIELDS = `${WATCHED_PULL_REQUEST_FIELDS},statusCheckRollup`
|
|
36
|
+
|
|
37
|
+
const GH_PR_LIST = 'gh pr list'
|
|
38
|
+
|
|
39
|
+
const isMergeCommit = (value: unknown): boolean => value === null || (isRecord(value) && typeof value.oid === 'string')
|
|
40
|
+
|
|
41
|
+
/** An entry of the light list has no `statusCheckRollup` at all; it reads as one with a null rollup. */
|
|
42
|
+
const withNullRollup = (value: unknown): unknown =>
|
|
43
|
+
isRecord(value) && !('statusCheckRollup' in value) ? { ...value, statusCheckRollup: null } : value
|
|
44
|
+
|
|
45
|
+
const isGhWatchedPullRequest = (value: unknown, host: string): value is GhWatchedPullRequest => {
|
|
46
|
+
if (!isGhPullRequest(value, host)) return false
|
|
47
|
+
const candidate = value as Record<string, unknown>
|
|
48
|
+
return (
|
|
49
|
+
(Object.values(PULL_REQUEST_STATE) as unknown[]).includes(candidate.state) &&
|
|
50
|
+
typeof candidate.headRefOid === 'string' &&
|
|
51
|
+
isMergeCommit(candidate.mergeCommit)
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const watchedPullRequestsOf = (
|
|
56
|
+
json: string,
|
|
57
|
+
host: string,
|
|
58
|
+
prepare: (entry: unknown) => unknown,
|
|
59
|
+
): WatchedPullRequest[] =>
|
|
60
|
+
parseArray(
|
|
61
|
+
json,
|
|
62
|
+
(entry): entry is GhWatchedPullRequest => isGhWatchedPullRequest(prepare(entry), host),
|
|
63
|
+
GH_PR_LIST,
|
|
64
|
+
).map((pullRequest) => ({
|
|
65
|
+
number: pullRequest.number,
|
|
66
|
+
branch: terminalSafe(pullRequest.headRefName),
|
|
67
|
+
state: pullRequest.state,
|
|
68
|
+
checks: checksOf(pullRequest.statusCheckRollup ?? []),
|
|
69
|
+
headCommit: pullRequest.headRefOid,
|
|
70
|
+
mergeCommit: pullRequest.mergeCommit?.oid ?? null,
|
|
71
|
+
}))
|
|
72
|
+
|
|
73
|
+
const asIs = (entry: unknown): unknown => entry
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reads the JSON of `gh pr list --json <WATCHED_PULL_REQUEST_FIELDS>`, checked like the plans view's
|
|
77
|
+
* `parsePullRequests`; throws on anything else. The list carries no rollup, so its checks are `none`.
|
|
78
|
+
*/
|
|
79
|
+
export const parseWatchedPullRequests = (json: string, host = 'github.com'): WatchedPullRequest[] =>
|
|
80
|
+
watchedPullRequestsOf(json, host, withNullRollup)
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Reads the JSON of `gh pr list --state open --json <OPEN_PULL_REQUEST_FIELDS>`. The rollup must be there, null or a
|
|
84
|
+
* list: a field that went missing must not turn every check into `none` and silence the watcher; throws instead.
|
|
85
|
+
*/
|
|
86
|
+
export const parseOpenPullRequests = (json: string, host = 'github.com'): WatchedPullRequest[] =>
|
|
87
|
+
watchedPullRequestsOf(json, host, asIs)
|
|
88
|
+
|
|
89
|
+
/** The pull request a lane's status line names: the open one on the branch, else the newest. */
|
|
90
|
+
export const latestPullRequestOf = (
|
|
91
|
+
pullRequests: readonly WatchedPullRequest[],
|
|
92
|
+
branch: string,
|
|
93
|
+
): WatchedPullRequest | null => {
|
|
94
|
+
const onBranch = pullRequests.filter((pullRequest) => pullRequest.branch === branch)
|
|
95
|
+
const newestFirst = [...onBranch].sort((left, right) => right.number - left.number)
|
|
96
|
+
return newestFirst.find((pullRequest) => pullRequest.state === PULL_REQUEST_STATE.open) ?? newestFirst[0] ?? null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The light list and the list of open pull requests as one. An open pull request takes its head commit and checks
|
|
101
|
+
* from the open list, which read them together, so a verdict never belongs to another commit. One the light list
|
|
102
|
+
* already shows merged or closed keeps the light entry with no checks (the two lists are read concurrently, so the open list may still hold a pull request that has ended);
|
|
103
|
+
* one the open list lacks stays as listed (it ended in between and the next read shows it). An open pull request
|
|
104
|
+
* older than the light list's limit is added at the end.
|
|
105
|
+
*/
|
|
106
|
+
export const mergeCheckedPullRequests = (
|
|
107
|
+
listed: readonly WatchedPullRequest[],
|
|
108
|
+
open: readonly WatchedPullRequest[],
|
|
109
|
+
): WatchedPullRequest[] => {
|
|
110
|
+
const openByNumber = new Map(open.map((pullRequest) => [pullRequest.number, pullRequest]))
|
|
111
|
+
const known = new Set(listed.map(({ number }) => number))
|
|
112
|
+
const checked = listed.map((pullRequest) => {
|
|
113
|
+
const withChecks = openByNumber.get(pullRequest.number)
|
|
114
|
+
return pullRequest.state === PULL_REQUEST_STATE.open && withChecks ? withChecks : pullRequest
|
|
115
|
+
})
|
|
116
|
+
return [...checked, ...open.filter(({ number }) => !known.has(number))]
|
|
117
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last line of a report without `--stream`, written for the model that started the call: the
|
|
3
|
+
* watcher is gone after it. With a Monitor tool a stream keeps reporting without a restart; without one
|
|
4
|
+
* (Bedrock, Vertex, Foundry, telemetry off) the restart after every report is the way it works.
|
|
5
|
+
*/
|
|
6
|
+
export const SINGLE_REPORT_NOTICE =
|
|
7
|
+
'watch ended after this report. Start it once with --stream under the Monitor tool: the stream stays armed and reports every change without a restart.'
|
|
8
|
+
|
|
9
|
+
export const NO_MONITOR_NOTICE =
|
|
10
|
+
'watch ended after this report. This session has no Monitor tool, so a single call is the way it works here: start it again as a background Bash call (run_in_background) after you acted on this report.'
|
|
11
|
+
|
|
12
|
+
export const withSingleReportNotice = (lines: readonly string[], monitorAvailable: boolean): string[] => [
|
|
13
|
+
...lines,
|
|
14
|
+
monitorAvailable ? SINGLE_REPORT_NOTICE : NO_MONITOR_NOTICE,
|
|
15
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { errorText } from '../plans/error-text'
|
|
4
|
+
import { changedSections, SKILL_CHANGE, type SkillChange, skillChangeLine } from '../version/skill-diff'
|
|
5
|
+
import type { StartedKit } from '../version/started-kit'
|
|
6
|
+
import { writePrivate } from './watcher-lock'
|
|
7
|
+
|
|
8
|
+
/** The kit's own text of the orchestrate skill, with its placeholders, inside the package. */
|
|
9
|
+
export const orchestrateSkillPath = (packageRoot: string): string =>
|
|
10
|
+
join(packageRoot, 'kit', 'workflow', 'skills', 'orchestrate', 'SKILL.md')
|
|
11
|
+
|
|
12
|
+
/** The orchestrate skill's text in the kit at `packageRoot`; null when it cannot be read. */
|
|
13
|
+
export const readOrchestrateSkill = (packageRoot: string): string | null => {
|
|
14
|
+
try {
|
|
15
|
+
return readFileSync(orchestrateSkillPath(packageRoot), 'utf8')
|
|
16
|
+
} catch {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The change of the orchestrate skill between `snapshot` and the kit at `packageRoot` now: the unified diff
|
|
23
|
+
* (`git diff --no-index`) written to `directory` and the sections it touches, so the coordinator reads the change
|
|
24
|
+
* instead of the whole skill. Unavailable without a snapshot, a readable skill or a diff.
|
|
25
|
+
*/
|
|
26
|
+
export const skillChangeSince = (snapshot: string | null, packageRoot: string, directory: string): SkillChange => {
|
|
27
|
+
if (snapshot === null) return { kind: SKILL_CHANGE.unavailable, reason: 'no snapshot at start' }
|
|
28
|
+
const current = readOrchestrateSkill(packageRoot)
|
|
29
|
+
if (current === null) return { kind: SKILL_CHANGE.unavailable, reason: 'skill not readable' }
|
|
30
|
+
if (current === snapshot) return { kind: SKILL_CHANGE.unchanged }
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
33
|
+
const before = join(directory, 'orchestrate-skill.before.md')
|
|
34
|
+
writePrivate(before, snapshot)
|
|
35
|
+
const diff = Bun.spawnSync([
|
|
36
|
+
'git',
|
|
37
|
+
'diff',
|
|
38
|
+
'--no-index',
|
|
39
|
+
'--unified=3',
|
|
40
|
+
'--',
|
|
41
|
+
before,
|
|
42
|
+
orchestrateSkillPath(packageRoot),
|
|
43
|
+
])
|
|
44
|
+
const text = diff.stdout.toString()
|
|
45
|
+
if (diff.exitCode !== 1 || text === '')
|
|
46
|
+
return { kind: SKILL_CHANGE.unavailable, reason: `git diff exit ${diff.exitCode}` }
|
|
47
|
+
const diffPath = join(directory, 'orchestrate-skill.diff')
|
|
48
|
+
writePrivate(diffPath, text)
|
|
49
|
+
return { kind: SKILL_CHANGE.changed, sections: changedSections(current, text), diffPath }
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return { kind: SKILL_CHANGE.unavailable, reason: errorText(error) }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The line about the orchestrate skill after a kit update, for the kit the watcher started with. */
|
|
56
|
+
export const skillLineFor = (kit: StartedKit | null, snapshot: string | null, directory: string): string =>
|
|
57
|
+
skillChangeLine(
|
|
58
|
+
kit
|
|
59
|
+
? skillChangeSince(snapshot, kit.packageRoot, directory)
|
|
60
|
+
: { kind: SKILL_CHANGE.unavailable, reason: 'kit not located' },
|
|
61
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readResolvedConfig } from '../config/read-config'
|
|
2
|
+
import { type JackherdConfig, parseSourceDraft } from '../config/schema'
|
|
3
|
+
import { mainCheckout, worktreeRoot } from '../plans/git'
|
|
4
|
+
import type { SourceDryRun } from './options'
|
|
5
|
+
import { type DryRunTarget, dryRunExitCode, dryRunLines, dryRunTarget, emptyMapFields } from './source-dry-run'
|
|
6
|
+
import { runSourceCommand, SOURCE_LIMITS, sourceRunOf } from './source-run'
|
|
7
|
+
import type { SourceSettings } from './sources'
|
|
8
|
+
|
|
9
|
+
const settingsOf = async (target: DryRunTarget, config: JackherdConfig): Promise<SourceSettings> => {
|
|
10
|
+
if (target.kind === 'name') {
|
|
11
|
+
const found = config.watch.sources.find(({ name }) => name === target.name)
|
|
12
|
+
if (!found) throw new Error(`no source ${target.name} in watch.sources`)
|
|
13
|
+
return found
|
|
14
|
+
}
|
|
15
|
+
const text = target.kind === 'stdin' ? await Bun.stdin.text() : await Bun.file(target.path).text()
|
|
16
|
+
return parseSourceDraft(JSON.parse(text), target.kind === 'stdin' ? 'stdin' : target.path)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `jh watch source <name|draft.json|-> --dry-run [--raw]`: runs one source once in the main checkout, as the
|
|
21
|
+
* watcher would, and prints what it found and would report; `--raw` adds the command's output. Takes no lock and
|
|
22
|
+
* neither reads nor writes the watcher's state, so a running watcher is not disturbed. Exits 1 when the run failed or a
|
|
23
|
+
* `map` field is empty in every entry.
|
|
24
|
+
*/
|
|
25
|
+
export const sourceDryRun = async ({ target, raw }: SourceDryRun): Promise<number> => {
|
|
26
|
+
const root = mainCheckout(process.cwd())
|
|
27
|
+
const { config } = readResolvedConfig([worktreeRoot(process.cwd()), root])
|
|
28
|
+
const settings = await settingsOf(dryRunTarget(target), config)
|
|
29
|
+
const answer = await runSourceCommand(settings.command, root, SOURCE_LIMITS)
|
|
30
|
+
const run = sourceRunOf(settings, answer)
|
|
31
|
+
const mapCheck = answer.ok ? emptyMapFields(answer.stdout, settings) : null
|
|
32
|
+
const stdout = raw && answer.ok ? answer.stdout : undefined
|
|
33
|
+
console.log(dryRunLines(settings, run, { mapCheck, stdout }).join('\n'))
|
|
34
|
+
return dryRunExitCode(run, mapCheck)
|
|
35
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
import { sourceEventLines } from './lane-report'
|
|
3
|
+
import {
|
|
4
|
+
isIgnoredAuthor,
|
|
5
|
+
listAt,
|
|
6
|
+
type SourceItem,
|
|
7
|
+
type SourceRun,
|
|
8
|
+
type SourceSettings,
|
|
9
|
+
sourceEvents,
|
|
10
|
+
textAt,
|
|
11
|
+
} from './sources'
|
|
12
|
+
|
|
13
|
+
/** What `watch source <target> --dry-run` names: a source of the config, a draft file holding one source, or a draft on stdin. */
|
|
14
|
+
export type DryRunTarget = { kind: 'name'; name: string } | { kind: 'file'; path: string } | { kind: 'stdin' }
|
|
15
|
+
|
|
16
|
+
/** The map fields empty in every one of `total` raw entries, each with its path. */
|
|
17
|
+
export type MapCheck = { total: number; empty: { field: string; path: string }[] }
|
|
18
|
+
|
|
19
|
+
/** What one dry run shows besides the run: the map check (null when the output has no list) and the raw output. */
|
|
20
|
+
export type DryRunExtras = { mapCheck: MapCheck | null; stdout?: string }
|
|
21
|
+
|
|
22
|
+
const STDIN = '-'
|
|
23
|
+
const LISTED_ENTRIES = 10
|
|
24
|
+
const ENTRY_LENGTH = 120
|
|
25
|
+
const RAW_LINES = 40
|
|
26
|
+
const RAW_LINE_LENGTH = 200
|
|
27
|
+
const INDENT = ' '
|
|
28
|
+
const BASELINE = 'first run of this source: baseline, nothing reported'
|
|
29
|
+
const NOT_CHECKED = '0 entries: map not checked'
|
|
30
|
+
const FOOTER = 'watcher state not read or changed; the watcher takes a new source over at its next start'
|
|
31
|
+
|
|
32
|
+
export const dryRunTarget = (value: string): DryRunTarget => {
|
|
33
|
+
if (value === STDIN) return { kind: 'stdin' }
|
|
34
|
+
return value.includes('/') || value.endsWith('.json') ? { kind: 'file', path: value } : { kind: 'name', name: value }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const isEmptyAt = (entry: unknown, path: string): boolean => {
|
|
38
|
+
const text = textAt(entry, path)
|
|
39
|
+
return text === null || text === ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Each `map` field that is empty (missing, null, no text or number, or `''`) in every entry of the list at `items`,
|
|
44
|
+
* read from the raw output, so a run that failed on a missing `id` or `updatedAt` still names it; null when the
|
|
45
|
+
* output has no list there.
|
|
46
|
+
*/
|
|
47
|
+
export const emptyMapFields = (stdout: string, settings: Pick<SourceSettings, 'items' | 'map'>): MapCheck | null => {
|
|
48
|
+
const raw = listAt(stdout, settings.items)
|
|
49
|
+
if (!raw.ok) return null
|
|
50
|
+
const { list } = raw
|
|
51
|
+
const fields = Object.entries(settings.map).flatMap(([field, path]) => (path === undefined ? [] : [{ field, path }]))
|
|
52
|
+
const empty = list.length === 0 ? [] : fields.filter(({ path }) => list.every((entry) => isEmptyAt(entry, path)))
|
|
53
|
+
return { total: list.length, empty }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 1 when the run failed or a map field is empty in every entry, so `/follow` does not write the source; else 0. */
|
|
57
|
+
export const dryRunExitCode = (run: SourceRun, mapCheck: MapCheck | null): number =>
|
|
58
|
+
run.result.ok && (mapCheck?.empty.length ?? 0) === 0 ? 0 : 1
|
|
59
|
+
|
|
60
|
+
/** `map.<field>: in <n> of <n> entries empty (<path>)` per empty field; for no entries, that nothing was checked. */
|
|
61
|
+
const mapCheckLines = (mapCheck: MapCheck | null): string[] => {
|
|
62
|
+
if (!mapCheck) return []
|
|
63
|
+
if (mapCheck.total === 0) return [NOT_CHECKED]
|
|
64
|
+
const { total } = mapCheck
|
|
65
|
+
return mapCheck.empty.map(
|
|
66
|
+
({ field, path }) => `map.${terminalSafe(field)}: in ${total} of ${total} entries empty (${terminalSafe(path)})`,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `<id> · <author> · <title>`, marked when `ignoreAuthors` holds it back. */
|
|
71
|
+
const entryLine = (item: SourceItem, settings: SourceSettings): string => {
|
|
72
|
+
const parts = [item.id, item.author ?? '', item.title].filter((part) => part !== '')
|
|
73
|
+
const ignored = isIgnoredAuthor(item, settings.ignoreAuthors) ? ' (ignoreAuthors)' : ''
|
|
74
|
+
return `${INDENT}${terminalSafe(parts.join(' · ')).slice(0, ENTRY_LENGTH)}${ignored}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const entryLines = (items: readonly SourceItem[], settings: SourceSettings): string[] => {
|
|
78
|
+
const more = items.length - LISTED_ENTRIES
|
|
79
|
+
return [
|
|
80
|
+
`${items.length} entries`,
|
|
81
|
+
...items.slice(0, LISTED_ENTRIES).map((item) => entryLine(item, settings)),
|
|
82
|
+
...(more > 0 ? [`${INDENT}… ${more} more`] : []),
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const cutLine = (line: string): string =>
|
|
87
|
+
line.length > RAW_LINE_LENGTH
|
|
88
|
+
? `${line.slice(0, RAW_LINE_LENGTH)} … +${line.length - RAW_LINE_LENGTH} characters`
|
|
89
|
+
: line
|
|
90
|
+
|
|
91
|
+
/** The command's output line by line, cut to `RAW_LINES` of `RAW_LINE_LENGTH` characters, for finding the paths of `items` and `map`. */
|
|
92
|
+
const rawLines = (stdout: string): string[] => {
|
|
93
|
+
const lines = stdout.replace(/\n$/, '').split('\n')
|
|
94
|
+
const more = lines.length - RAW_LINES
|
|
95
|
+
return [
|
|
96
|
+
'output:',
|
|
97
|
+
...lines.slice(0, RAW_LINES).map((line) => `${INDENT}${cutLine(terminalSafe(line))}`),
|
|
98
|
+
...(more > 0 ? [`${INDENT}… ${more} more lines`] : []),
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The events of the run against a source whose baseline holds nothing: every entry not held back as `new`. */
|
|
103
|
+
const eventsOf = (settings: SourceSettings, run: SourceRun) =>
|
|
104
|
+
sourceEvents({ [settings.name]: { seen: [], failingSince: null } }, [run], [settings])
|
|
105
|
+
|
|
106
|
+
/** The watcher's first run of a source reports nothing; one entry shows how a later new or changed one would read. */
|
|
107
|
+
const exampleLines = (settings: SourceSettings, run: SourceRun): string[] => {
|
|
108
|
+
const example = eventsOf(settings, run).find((event) => 'id' in event)
|
|
109
|
+
if (!example) return [`${BASELINE}; no entry here would report`]
|
|
110
|
+
return [`${BASELINE}; later a new or changed entry reports like this:`, ...sourceEventLines(example)]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What one dry run of a source shows: its command, `stdout` when asked for (`--raw`), the entries after `map` as a short
|
|
115
|
+
* list (`<id> · <author> · <title>`), that the watcher's first run of the source is a baseline and reports nothing, and
|
|
116
|
+
* one entry as a later report would show it; a failure as the watcher would report it. The `map` fields empty in every
|
|
117
|
+
* entry come after the raw output. Nothing of the watcher's state is read or written.
|
|
118
|
+
*/
|
|
119
|
+
export const dryRunLines = (settings: SourceSettings, run: SourceRun, extras: DryRunExtras): string[] => {
|
|
120
|
+
const head = `dry run source ${terminalSafe(settings.name)}: ${terminalSafe(JSON.stringify(settings.command))}`
|
|
121
|
+
const top = [head, ...(extras.stdout === undefined ? [] : rawLines(extras.stdout)), ...mapCheckLines(extras.mapCheck)]
|
|
122
|
+
if (!run.result.ok) return [...top, ...eventsOf(settings, run).flatMap(sourceEventLines), FOOTER]
|
|
123
|
+
return [...top, ...entryLines(run.result.items, settings), ...exampleLines(settings, run), FOOTER]
|
|
124
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { isRecord } from './json-guards'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a field sits in a source's JSON output: `.` is the whole output, else a chain of `.key`, `[n]` and `[-n]`
|
|
5
|
+
* (counted from the end), such as `.fields.comment.comments[-1].body`. No jq: nothing else is understood.
|
|
6
|
+
*/
|
|
7
|
+
export type SourcePath = string
|
|
8
|
+
|
|
9
|
+
export const ROOT_PATH = '.'
|
|
10
|
+
|
|
11
|
+
const PATH_STEP = /\.([^\s.[\]|]+)|\[(-?\d+)\]/y
|
|
12
|
+
|
|
13
|
+
type PathStep = { key: string } | { index: number }
|
|
14
|
+
|
|
15
|
+
/** The steps of `path`, or null when it is no source path. */
|
|
16
|
+
const pathSteps = (path: string): PathStep[] | null => {
|
|
17
|
+
if (path === ROOT_PATH) return []
|
|
18
|
+
const steps: PathStep[] = []
|
|
19
|
+
PATH_STEP.lastIndex = 0
|
|
20
|
+
while (PATH_STEP.lastIndex < path.length) {
|
|
21
|
+
const match = PATH_STEP.exec(path)
|
|
22
|
+
if (!match) return null
|
|
23
|
+
steps.push(match[1] === undefined ? { index: Number(match[2]) } : { key: match[1] })
|
|
24
|
+
}
|
|
25
|
+
return steps.length > 0 ? steps : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const isSourcePath = (path: string): boolean => pathSteps(path) !== null
|
|
29
|
+
|
|
30
|
+
const stepInto = (value: unknown, step: PathStep): unknown => {
|
|
31
|
+
if ('key' in step) return isRecord(value) && Object.hasOwn(value, step.key) ? value[step.key] : undefined
|
|
32
|
+
return Array.isArray(value) ? value.at(step.index) : undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The value at `path` in `value`; undefined when a step does not fit or the path is invalid. */
|
|
36
|
+
export const selectPath = (value: unknown, path: SourcePath): unknown =>
|
|
37
|
+
(pathSteps(path) ?? [undefined]).reduce<unknown>(
|
|
38
|
+
(current, step) => (step === undefined ? undefined : stepInto(current, step)),
|
|
39
|
+
value,
|
|
40
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { exitFailure, parseSourceOutput, SOURCE_FAILURE, type SourceRun, type SourceSettings } from './sources'
|
|
2
|
+
|
|
3
|
+
/** How long a source's command may run and how much it may print before it is stopped. */
|
|
4
|
+
export type SourceLimits = { timeoutMs: number; maxBytes: number }
|
|
5
|
+
|
|
6
|
+
export type SourceAnswer = { ok: true; stdout: string } | { ok: false; reason: string }
|
|
7
|
+
|
|
8
|
+
export const SOURCE_LIMITS: SourceLimits = { timeoutMs: 30_000, maxBytes: 1_000_000 }
|
|
9
|
+
|
|
10
|
+
type Child = ReturnType<typeof Bun.spawn<'ignore', 'pipe', 'ignore'>>
|
|
11
|
+
|
|
12
|
+
const TIMED_OUT = Symbol('timed out')
|
|
13
|
+
const TIMED_OUT_ANSWER: SourceAnswer = { ok: false, reason: SOURCE_FAILURE.timeout }
|
|
14
|
+
const TOO_LARGE_ANSWER: SourceAnswer = { ok: false, reason: SOURCE_FAILURE.tooLarge }
|
|
15
|
+
const UNREADABLE_ANSWER: SourceAnswer = { ok: false, reason: SOURCE_FAILURE.unreadable }
|
|
16
|
+
const READ_FAILED = Symbol('read failed')
|
|
17
|
+
|
|
18
|
+
const answerOf = (stdout: string, exitCode: number): SourceAnswer =>
|
|
19
|
+
exitCode === 0 ? { ok: true, stdout } : { ok: false, reason: exitFailure(exitCode) }
|
|
20
|
+
|
|
21
|
+
/** The run a command's answer makes: its output over `items` and `map`, or the reason it failed. */
|
|
22
|
+
export const sourceRunOf = (settings: SourceSettings, answer: SourceAnswer): SourceRun => ({
|
|
23
|
+
name: settings.name,
|
|
24
|
+
result: answer.ok ? parseSourceOutput(answer.stdout, settings) : answer,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/** Reads stdout up to `maxBytes`; null once it went past them. */
|
|
28
|
+
const readCapped = async (child: Child, maxBytes: number): Promise<string | null> => {
|
|
29
|
+
const chunks: Uint8Array[] = []
|
|
30
|
+
let size = 0
|
|
31
|
+
for await (const chunk of child.stdout) {
|
|
32
|
+
size += chunk.byteLength
|
|
33
|
+
if (size > maxBytes) return null
|
|
34
|
+
chunks.push(chunk)
|
|
35
|
+
}
|
|
36
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Started in a process group of its own, so a timeout reaches what the command started too (`curl` behind `sh`). */
|
|
40
|
+
const spawned = (command: readonly string[], cwd: string): Child | null => {
|
|
41
|
+
try {
|
|
42
|
+
return Bun.spawn([...command], { cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'ignore', detached: true })
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Kills the command's whole process group, best effort: a group that ended (ESRCH) or cannot be signalled is left alone. */
|
|
49
|
+
const killGroup = (child: Child) => {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(-child.pid, 'SIGKILL')
|
|
52
|
+
} catch {
|
|
53
|
+
// Nothing left to kill, or nothing this process may kill; the run's answer stands either way.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Runs `command` as given, without a shell, in `cwd` with the user's environment; never rejects. stderr is not read,
|
|
59
|
+
* so nothing a failing tool prints there (a token, a URL with credentials) reaches the report. Past `timeoutMs` or
|
|
60
|
+
* `maxBytes` the command's process group is killed, and the answer never waits past `timeoutMs` for a pipe a leftover
|
|
61
|
+
* process keeps open; the group is also killed after a normal end, so a source leaves no process behind that stays in its group.
|
|
62
|
+
*/
|
|
63
|
+
export const runSourceCommand = async (
|
|
64
|
+
command: readonly string[],
|
|
65
|
+
cwd: string,
|
|
66
|
+
limits: SourceLimits,
|
|
67
|
+
): Promise<SourceAnswer> => {
|
|
68
|
+
const child = spawned(command, cwd)
|
|
69
|
+
if (!child) return { ok: false, reason: SOURCE_FAILURE.notStarted }
|
|
70
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
71
|
+
const deadline = new Promise<typeof TIMED_OUT>((resolve) => {
|
|
72
|
+
timer = setTimeout(() => resolve(TIMED_OUT), limits.timeoutMs)
|
|
73
|
+
})
|
|
74
|
+
try {
|
|
75
|
+
const reading = readCapped(child, limits.maxBytes).catch((): typeof READ_FAILED => READ_FAILED)
|
|
76
|
+
const stdout = await Promise.race([reading, deadline])
|
|
77
|
+
if (stdout === TIMED_OUT) return TIMED_OUT_ANSWER
|
|
78
|
+
if (stdout === null) return TOO_LARGE_ANSWER
|
|
79
|
+
if (stdout === READ_FAILED) return UNREADABLE_ANSWER
|
|
80
|
+
const exitCode = await Promise.race([child.exited, deadline])
|
|
81
|
+
return exitCode === TIMED_OUT ? TIMED_OUT_ANSWER : answerOf(stdout, exitCode)
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(timer)
|
|
84
|
+
killGroup(child)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One run of a source: its command, then its output over `items` and `map`. */
|
|
89
|
+
export const runSource = async (settings: SourceSettings, cwd: string, limits: SourceLimits): Promise<SourceRun> =>
|
|
90
|
+
sourceRunOf(settings, await runSourceCommand(settings.command, cwd, limits))
|