jackherd 0.0.1 → 1.0.1
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 +65 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1122 -3
- 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 +63 -5
- 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,344 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
|
+
import { isRecord } from './json-guards'
|
|
4
|
+
import { previewUrlOf } from './previews'
|
|
5
|
+
import { SOURCE_FIELD } from './source-fields'
|
|
6
|
+
import { ROOT_PATH, type SourcePath, selectPath } from './source-path'
|
|
7
|
+
|
|
8
|
+
export const SOURCE_EVENT = 'source'
|
|
9
|
+
|
|
10
|
+
export const SOURCE_CHANGE = { new: 'new', updated: 'updated' } as const
|
|
11
|
+
|
|
12
|
+
export type SourceChange = (typeof SOURCE_CHANGE)[keyof typeof SOURCE_CHANGE]
|
|
13
|
+
|
|
14
|
+
/** Why a run of a source failed, as a class only: never its stderr, which may carry a token. `exit <code>` comes from `exitFailure`. */
|
|
15
|
+
export const SOURCE_FAILURE = {
|
|
16
|
+
timeout: 'timeout',
|
|
17
|
+
tooLarge: 'output too large',
|
|
18
|
+
unreadable: 'output unreadable',
|
|
19
|
+
notStarted: 'not started',
|
|
20
|
+
noJson: 'no JSON',
|
|
21
|
+
shape: 'unexpected shape',
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
/** The paths of an entry's fields: `id` and `updatedAt`, the content fields, and any further named field. */
|
|
25
|
+
export type SourceMap = {
|
|
26
|
+
id: SourcePath
|
|
27
|
+
updatedAt: SourcePath
|
|
28
|
+
title?: SourcePath
|
|
29
|
+
url?: SourcePath
|
|
30
|
+
body?: SourcePath
|
|
31
|
+
author?: SourcePath
|
|
32
|
+
} & Record<string, SourcePath | undefined>
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One `watch.sources` entry as the watcher uses it; `every` is in minutes, `items` the path of the list (the root
|
|
36
|
+
* without it), `reportLimit` the most entries one report shows of it.
|
|
37
|
+
*/
|
|
38
|
+
export type SourceSettings = {
|
|
39
|
+
name: string
|
|
40
|
+
command: readonly string[]
|
|
41
|
+
every: number
|
|
42
|
+
items?: SourcePath
|
|
43
|
+
map: SourceMap
|
|
44
|
+
ignoreAuthors: readonly string[]
|
|
45
|
+
reportLimit: number
|
|
46
|
+
lane?: string
|
|
47
|
+
/** The mapped fields whose change reports, in place of the content fingerprint; without it the fingerprint decides. */
|
|
48
|
+
reportOn?: readonly string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One entry of a source's output after `map`; `url` is a checked https URL or null, a missing text field is empty.
|
|
53
|
+
* `fingerprint` stands for its content (`contentFingerprint`): it changes with the text, not with the time alone.
|
|
54
|
+
*/
|
|
55
|
+
export type SourceItem = {
|
|
56
|
+
id: string
|
|
57
|
+
updatedAt: string
|
|
58
|
+
title: string
|
|
59
|
+
url: string | null
|
|
60
|
+
body: string
|
|
61
|
+
author: string | null
|
|
62
|
+
fingerprint: string
|
|
63
|
+
/** The `reportOn` fields as the state keeps them (`storedValue`); empty without `reportOn`. */
|
|
64
|
+
values: Record<string, string>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type SourceResult = { ok: true; items: SourceItem[] } | { ok: false; reason: string }
|
|
68
|
+
|
|
69
|
+
/** The outcome of one run of the source `name`. */
|
|
70
|
+
export type SourceRun = { name: string; result: SourceResult }
|
|
71
|
+
|
|
72
|
+
/** `[id, fingerprint]`, or with `reportOn` `[id, fingerprint, values]`: the stored value of each `reportOn` field. */
|
|
73
|
+
export type SeenEntry = [string, string] | [string, string, Record<string, string>]
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What the watcher keeps per source: `seen` holds a `SeenEntry` per id, the entries seen most recently last, or null
|
|
77
|
+
* before the first successful run (which is a baseline); `failingSince` is the epoch milliseconds of the first failed
|
|
78
|
+
* run of the current streak, null while the source answers. Of an entry's text only the `reportOn` fields are kept.
|
|
79
|
+
*/
|
|
80
|
+
export type SourceRecord = { seen: SeenEntry[] | null; failingSince: number | null }
|
|
81
|
+
|
|
82
|
+
export type SourceState = Record<string, SourceRecord>
|
|
83
|
+
|
|
84
|
+
/** A `reportOn` field whose stored value changed; an empty value is `''`. */
|
|
85
|
+
export type FieldChange = { field: string; from: string; to: string }
|
|
86
|
+
|
|
87
|
+
export type SourceItemEvent = {
|
|
88
|
+
kind: typeof SOURCE_EVENT
|
|
89
|
+
name: string
|
|
90
|
+
lane: string | null
|
|
91
|
+
id: string
|
|
92
|
+
title: string
|
|
93
|
+
change: SourceChange
|
|
94
|
+
body: string
|
|
95
|
+
url: string | null
|
|
96
|
+
/** With `reportOn`, the fields an `updated` reports. */
|
|
97
|
+
changes?: FieldChange[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type SourceFailureEvent = { kind: typeof SOURCE_EVENT; name: string; lane: string | null; failure: string }
|
|
101
|
+
|
|
102
|
+
/** The entries of one run past `reportLimit`: seen and recorded, only counted in the report. */
|
|
103
|
+
export type SourceOverflowEvent = { kind: typeof SOURCE_EVENT; name: string; lane: string | null; more: number }
|
|
104
|
+
|
|
105
|
+
export type SourceEvent = SourceItemEvent | SourceFailureEvent | SourceOverflowEvent
|
|
106
|
+
|
|
107
|
+
const SEEN_LIMIT = 1000
|
|
108
|
+
const FINGERPRINT_LENGTH = 16
|
|
109
|
+
const STORED_VALUE_LENGTH = 200
|
|
110
|
+
const STORED_PREFIX_LENGTH = 180
|
|
111
|
+
const VALUE_HASH_LENGTH = 8
|
|
112
|
+
const MINUTE_MS = 60_000
|
|
113
|
+
|
|
114
|
+
export const exitFailure = (code: number): string => `exit ${code}`
|
|
115
|
+
|
|
116
|
+
export const emptySourceState = (): SourceState => ({})
|
|
117
|
+
|
|
118
|
+
const scalarText = (value: unknown): string | null => {
|
|
119
|
+
if (typeof value === 'string') return value
|
|
120
|
+
return typeof value === 'number' && Number.isFinite(value) ? String(value) : null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The text at `path` of an entry: a string, or a finite number as text; null for anything else or no path. */
|
|
124
|
+
export const textAt = (entry: unknown, path: SourcePath | undefined): string | null =>
|
|
125
|
+
path === undefined ? null : scalarText(selectPath(entry, path))
|
|
126
|
+
|
|
127
|
+
type MappedFields = Omit<SourceItem, 'fingerprint' | 'values'>
|
|
128
|
+
|
|
129
|
+
const hashOf = (text: string, length: number): string =>
|
|
130
|
+
createHash('sha256').update(text).digest('hex').slice(0, length)
|
|
131
|
+
|
|
132
|
+
/** A value as the state keeps it: whole up to 200 characters, else its first 180, `…` and a hash of the whole. */
|
|
133
|
+
export const storedValue = (text: string): string =>
|
|
134
|
+
text.length <= STORED_VALUE_LENGTH
|
|
135
|
+
? text
|
|
136
|
+
: `${text.slice(0, STORED_PREFIX_LENGTH)}…${hashOf(text, VALUE_HASH_LENGTH)}`
|
|
137
|
+
|
|
138
|
+
const hasContentField = (map: SourceMap): boolean =>
|
|
139
|
+
[map.title, map.author, map.body, map.url].some((path) => path !== undefined)
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A short hash of what a reader sees of an entry: title, author, body and URL, so a change of the time alone (a label,
|
|
143
|
+
* a status) is no change. Without any of them mapped it is `updatedAt`, else the entry could never change.
|
|
144
|
+
*/
|
|
145
|
+
const contentFingerprint = (fields: MappedFields, map: SourceMap): string => {
|
|
146
|
+
const content = hasContentField(map) ? [fields.title, fields.author, fields.body, fields.url] : [fields.updatedAt]
|
|
147
|
+
return hashOf(JSON.stringify(content), FINGERPRINT_LENGTH)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A value as one line: whitespace runs one space, control characters gone, trimmed. */
|
|
151
|
+
export const lineText = (text: string): string => terminalSafe(text.replace(/\s+/g, ' ')).trim()
|
|
152
|
+
|
|
153
|
+
/** The value of a `reportOn` field: the checked URL for `url`, the mapped text otherwise, `''` when missing. */
|
|
154
|
+
const reportedValue = (entry: unknown, field: string, fields: MappedFields, map: SourceMap): string => {
|
|
155
|
+
if (field === SOURCE_FIELD.url) return fields.url ?? ''
|
|
156
|
+
return Object.hasOwn(map, field) ? (textAt(entry, map[field]) ?? '') : ''
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const valuesOf = (
|
|
160
|
+
entry: unknown,
|
|
161
|
+
fields: MappedFields,
|
|
162
|
+
settings: Pick<SourceSettings, 'map' | 'reportOn'>,
|
|
163
|
+
): Record<string, string> =>
|
|
164
|
+
Object.fromEntries(
|
|
165
|
+
(settings.reportOn ?? []).map((field) => [
|
|
166
|
+
field,
|
|
167
|
+
storedValue(lineText(reportedValue(entry, field, fields, settings.map))),
|
|
168
|
+
]),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
/** One entry after `map`, or null when its id or updatedAt is missing or no text. */
|
|
172
|
+
const sourceItemOf = (entry: unknown, settings: Pick<SourceSettings, 'map' | 'reportOn'>): SourceItem | null => {
|
|
173
|
+
const { map } = settings
|
|
174
|
+
const id = textAt(entry, map.id)
|
|
175
|
+
const updatedAt = textAt(entry, map.updatedAt)
|
|
176
|
+
if (id === null || id === '' || updatedAt === null) return null
|
|
177
|
+
const url = textAt(entry, map.url)
|
|
178
|
+
const fields = {
|
|
179
|
+
id,
|
|
180
|
+
updatedAt,
|
|
181
|
+
title: textAt(entry, map.title) ?? '',
|
|
182
|
+
url: url === null ? null : previewUrlOf(url, undefined),
|
|
183
|
+
body: textAt(entry, map.body) ?? '',
|
|
184
|
+
author: textAt(entry, map.author),
|
|
185
|
+
}
|
|
186
|
+
return { ...fields, fingerprint: contentFingerprint(fields, map), values: valuesOf(entry, fields, settings) }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The items once each: an id listed twice counts with its last entry. */
|
|
190
|
+
const uniqueById = (items: readonly SourceItem[]): SourceItem[] => [
|
|
191
|
+
...new Map(items.map((item) => [item.id, item])).values(),
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
const parseJson = (text: string): { ok: true; value: unknown } | { ok: false } => {
|
|
195
|
+
try {
|
|
196
|
+
return { ok: true, value: JSON.parse(text) }
|
|
197
|
+
} catch {
|
|
198
|
+
return { ok: false }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The raw entries of a source's stdout, the list at `items`; `no JSON` or `unexpected shape` when there is none. */
|
|
203
|
+
export const listAt = (
|
|
204
|
+
stdout: string,
|
|
205
|
+
items: SourcePath | undefined,
|
|
206
|
+
): { ok: true; list: unknown[] } | { ok: false; reason: string } => {
|
|
207
|
+
const parsed = parseJson(stdout)
|
|
208
|
+
if (!parsed.ok) return { ok: false, reason: SOURCE_FAILURE.noJson }
|
|
209
|
+
const list = selectPath(parsed.value, items ?? ROOT_PATH)
|
|
210
|
+
return Array.isArray(list) ? { ok: true, list } : { ok: false, reason: SOURCE_FAILURE.shape }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A source's stdout read over `items` and `map`; `no JSON` or `unexpected shape` when it cannot be. */
|
|
214
|
+
export const parseSourceOutput = (
|
|
215
|
+
stdout: string,
|
|
216
|
+
settings: Pick<SourceSettings, 'items' | 'map' | 'reportOn'>,
|
|
217
|
+
): SourceResult => {
|
|
218
|
+
const raw = listAt(stdout, settings.items)
|
|
219
|
+
if (!raw.ok) return raw
|
|
220
|
+
const items = raw.list.map((entry) => sourceItemOf(entry, settings))
|
|
221
|
+
if (items.some((item) => item === null)) return { ok: false, reason: SOURCE_FAILURE.shape }
|
|
222
|
+
return { ok: true, items: uniqueById(items as SourceItem[]) }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Whether a source last started at `lastRunAt` (undefined: never in this process) is due again at `now`. */
|
|
226
|
+
export const isSourceDue = (lastRunAt: number | undefined, everyMinutes: number, now: number): boolean =>
|
|
227
|
+
lastRunAt === undefined || now - lastRunAt >= everyMinutes * MINUTE_MS
|
|
228
|
+
|
|
229
|
+
/** Whether `ignoreAuthors` holds the item back; case is ignored. */
|
|
230
|
+
export const isIgnoredAuthor = (item: SourceItem, ignoreAuthors: readonly string[]): boolean =>
|
|
231
|
+
item.author !== null && ignoreAuthors.some((author) => author.toLowerCase() === item.author?.toLowerCase())
|
|
232
|
+
|
|
233
|
+
type ItemChange = { change: SourceChange; changes?: FieldChange[] }
|
|
234
|
+
|
|
235
|
+
/** The `reportOn` fields whose stored value differs; a field without a stored value is a baseline, not a change. */
|
|
236
|
+
const fieldChanges = (stored: Record<string, string>, item: SourceItem): FieldChange[] =>
|
|
237
|
+
Object.entries(item.values).flatMap(([field, to]) => {
|
|
238
|
+
const from = Object.hasOwn(stored, field) ? stored[field] : undefined
|
|
239
|
+
return from === undefined || from === to ? [] : [{ field, from, to }]
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
/** With `reportOn` the stored values decide (an entry stored without them is a baseline), else the fingerprint. */
|
|
243
|
+
const changeOf = (
|
|
244
|
+
seen: ReadonlyMap<string, SeenEntry>,
|
|
245
|
+
item: SourceItem,
|
|
246
|
+
settings: SourceSettings,
|
|
247
|
+
): ItemChange | null => {
|
|
248
|
+
const known = seen.get(item.id)
|
|
249
|
+
if (known === undefined) return { change: SOURCE_CHANGE.new }
|
|
250
|
+
if (settings.reportOn === undefined) return known[1] === item.fingerprint ? null : { change: SOURCE_CHANGE.updated }
|
|
251
|
+
const changes = known[2] === undefined ? [] : fieldChanges(known[2], item)
|
|
252
|
+
return changes.length === 0 ? null : { change: SOURCE_CHANGE.updated, changes }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const itemEvents = (
|
|
256
|
+
record: SourceRecord | undefined,
|
|
257
|
+
items: readonly SourceItem[],
|
|
258
|
+
settings: SourceSettings,
|
|
259
|
+
): SourceEvent[] => {
|
|
260
|
+
if (!record?.seen) return []
|
|
261
|
+
const seen = new Map(record.seen.map((entry) => [entry[0], entry]))
|
|
262
|
+
const lane = settings.lane ?? null
|
|
263
|
+
const events = items.flatMap((item): SourceItemEvent[] => {
|
|
264
|
+
const found = changeOf(seen, item, settings)
|
|
265
|
+
if (found === null || isIgnoredAuthor(item, settings.ignoreAuthors)) return []
|
|
266
|
+
const { id, title, body, url } = item
|
|
267
|
+
return [{ kind: SOURCE_EVENT, name: settings.name, lane, id, title, body, url, ...found }]
|
|
268
|
+
})
|
|
269
|
+
const more = events.length - settings.reportLimit
|
|
270
|
+
if (more <= 0) return events
|
|
271
|
+
return [...events.slice(0, settings.reportLimit), { kind: SOURCE_EVENT, name: settings.name, lane, more }]
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The events of `runs` against `previous`: per item `new` for an unknown id, `updated` for a changed fingerprint (with
|
|
276
|
+
* `reportOn`: a changed stored value of a listed field, named in `changes`), none
|
|
277
|
+
* for an author in `ignoreAuthors`, at most `reportLimit` per source and the rest counted in one event; a source's
|
|
278
|
+
* first successful run is a baseline. A failure fires once per streak.
|
|
279
|
+
*/
|
|
280
|
+
export const sourceEvents = (
|
|
281
|
+
previous: SourceState,
|
|
282
|
+
runs: readonly SourceRun[],
|
|
283
|
+
settings: readonly SourceSettings[],
|
|
284
|
+
): SourceEvent[] =>
|
|
285
|
+
runs.flatMap(({ name, result }): SourceEvent[] => {
|
|
286
|
+
const source = settings.find((candidate) => candidate.name === name)
|
|
287
|
+
if (!source) return []
|
|
288
|
+
const record = previous[name]
|
|
289
|
+
if (result.ok) return itemEvents(record, result.items, source)
|
|
290
|
+
const known = record?.failingSince != null
|
|
291
|
+
return known ? [] : [{ kind: SOURCE_EVENT, name, lane: source.lane ?? null, failure: result.reason }]
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
/** `[id, fingerprint]`, with the `reportOn` values as a third part when there are any. */
|
|
295
|
+
const seenEntryOf = ({ id, fingerprint, values }: SourceItem): SeenEntry =>
|
|
296
|
+
Object.keys(values).length === 0 ? [id, fingerprint] : [id, fingerprint, values]
|
|
297
|
+
|
|
298
|
+
/** The seen entries with `items` moved to the end, the most recent `SEEN_LIMIT` kept. */
|
|
299
|
+
const seenAfter = (seen: readonly SeenEntry[], items: readonly SourceItem[]): SeenEntry[] => {
|
|
300
|
+
const current = new Set(items.map(({ id }) => id))
|
|
301
|
+
const kept = seen.filter(([id]) => !current.has(id))
|
|
302
|
+
return [...kept, ...items.map(seenEntryOf)].slice(-SEEN_LIMIT)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const recordOf = (record: SourceRecord | undefined, result: SourceResult, now: number): SourceRecord =>
|
|
306
|
+
result.ok
|
|
307
|
+
? { seen: seenAfter(record?.seen ?? [], result.items), failingSince: null }
|
|
308
|
+
: { seen: record?.seen ?? null, failingSince: record?.failingSince ?? now }
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The state after `runs` for the sources configured as `names`: a run records its outcome, a source without a run keeps
|
|
312
|
+
* its record, a source no longer configured is dropped. Recording the same runs twice gives the same state.
|
|
313
|
+
*/
|
|
314
|
+
export const recordSources = (
|
|
315
|
+
previous: SourceState,
|
|
316
|
+
runs: readonly SourceRun[],
|
|
317
|
+
names: readonly string[],
|
|
318
|
+
now: number,
|
|
319
|
+
): SourceState =>
|
|
320
|
+
Object.fromEntries(
|
|
321
|
+
names.flatMap((name) => {
|
|
322
|
+
const run = runs.find((candidate) => candidate.name === name)
|
|
323
|
+
const record = run ? recordOf(previous[name], run.result, now) : previous[name]
|
|
324
|
+
return record ? [[name, record]] : []
|
|
325
|
+
}),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
const isStoredValues = (value: unknown): boolean =>
|
|
329
|
+
isRecord(value) && Object.values(value).every((part) => typeof part === 'string')
|
|
330
|
+
|
|
331
|
+
const isSeenEntry = (value: unknown): boolean =>
|
|
332
|
+
Array.isArray(value) &&
|
|
333
|
+
(value.length === 2 || value.length === 3) &&
|
|
334
|
+
typeof value[0] === 'string' &&
|
|
335
|
+
typeof value[1] === 'string' &&
|
|
336
|
+
(value.length === 2 || isStoredValues(value[2]))
|
|
337
|
+
|
|
338
|
+
const isSourceRecord = (value: unknown): boolean =>
|
|
339
|
+
isRecord(value) &&
|
|
340
|
+
(value.seen === null || (Array.isArray(value.seen) && value.seen.every(isSeenEntry))) &&
|
|
341
|
+
(value.failingSince === null || typeof value.failingSince === 'number')
|
|
342
|
+
|
|
343
|
+
export const isSourceState = (value: unknown): value is SourceState =>
|
|
344
|
+
isRecord(value) && Object.values(value).every(isSourceRecord)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
import { type LaneSnapshot, NO_AGENT } from './lane-watch'
|
|
3
|
+
import { WATCH_EVENT } from './watch-events'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The heartbeat of a stream, as one line, since every line of a stream reaches the coordinator as a
|
|
7
|
+
* message of its own: `=== heartbeat <time> · <lane> <status> · …`.
|
|
8
|
+
*/
|
|
9
|
+
export const streamHeartbeatLine = (time: string, lanes: readonly string[], current: LaneSnapshot): string => {
|
|
10
|
+
const states = lanes.map((lane) => `${terminalSafe(lane)} ${terminalSafe(current[lane]?.status ?? NO_AGENT)}`)
|
|
11
|
+
return [`=== ${WATCH_EVENT.heartbeat} ${terminalSafe(time)}`, ...(states.length > 0 ? states : ['no lanes'])].join(
|
|
12
|
+
' · ',
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
2
|
+
|
|
3
|
+
/** The words of the watcher's status and duty lines, in the project's response language. */
|
|
4
|
+
export type WatchTexts = {
|
|
5
|
+
noBranch: string
|
|
6
|
+
noPullRequest: string
|
|
7
|
+
openPhase: string
|
|
8
|
+
/** The current phase ticked to the end in the lane's worktree, its ticks not yet on the base branch. */
|
|
9
|
+
tickedUnmerged: string
|
|
10
|
+
phase: string
|
|
11
|
+
startsAfter: string
|
|
12
|
+
planFinished: string
|
|
13
|
+
noPlan: string
|
|
14
|
+
noName: string
|
|
15
|
+
noValue: string
|
|
16
|
+
unnamedPane: (name: string) => string
|
|
17
|
+
noAgentNamed: (name: string) => string
|
|
18
|
+
/** The prefix of a merge's duty line, with the pull request number. */
|
|
19
|
+
afterMerge: (number: number) => string
|
|
20
|
+
mergedLaneDuty: string
|
|
21
|
+
otherLaneDuty: string
|
|
22
|
+
blockedDuty: string
|
|
23
|
+
/** What a lane whose context reached `alert` asks of the coordinator. */
|
|
24
|
+
laneAlertDuty: string
|
|
25
|
+
/** The reminder after the coordinator line: `<term>: <count> rows only in the checkout`. */
|
|
26
|
+
checkoutRows: (term: string, count: number) => string
|
|
27
|
+
/** The head of the status line of the user's own open pull requests without a lane. */
|
|
28
|
+
otherPullRequests: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const WATCH_TEXTS: Record<PlanLanguage, WatchTexts> = {
|
|
32
|
+
[PLAN_LANGUAGE.de]: {
|
|
33
|
+
noBranch: 'kein Branch',
|
|
34
|
+
noPullRequest: 'kein PR',
|
|
35
|
+
openPhase: 'offene Phase',
|
|
36
|
+
tickedUnmerged: 'abgehakt, nicht gemergt',
|
|
37
|
+
phase: 'Phase',
|
|
38
|
+
startsAfter: 'Startet nach',
|
|
39
|
+
planFinished: 'Plan fertig',
|
|
40
|
+
noPlan: 'kein Plan',
|
|
41
|
+
noName: 'kein Name',
|
|
42
|
+
noValue: 'kein Wert',
|
|
43
|
+
unnamedPane: (name) => `Pane ohne Namen, herdr agent rename "$HERDR_PANE_ID" ${name}`,
|
|
44
|
+
noAgentNamed: (name) => `kein Agent namens ${name}`,
|
|
45
|
+
afterMerge: (number) => `nach Merge #${number}`,
|
|
46
|
+
mergedLaneDuty: 'nächste Phase starten oder Spur schließen',
|
|
47
|
+
otherLaneDuty: 'rebasen, dann nächste Phase starten oder Grund nennen',
|
|
48
|
+
blockedDuty:
|
|
49
|
+
'Frage gegen Plan und Regeln prüfen, in der Spur beantworten oder per Dialog an den User; nie das Pane weiterreichen',
|
|
50
|
+
laneAlertDuty: 'Übergabe an der nächsten Phasengrenze anfordern (§ 8)',
|
|
51
|
+
checkoutRows: (term, count) => `${term}: ${count} ${count === 1 ? 'Zeile' : 'Zeilen'} nur im Checkout`,
|
|
52
|
+
otherPullRequests: 'andere PRs',
|
|
53
|
+
},
|
|
54
|
+
[PLAN_LANGUAGE.en]: {
|
|
55
|
+
noBranch: 'no branch',
|
|
56
|
+
noPullRequest: 'no PR',
|
|
57
|
+
openPhase: 'open phase',
|
|
58
|
+
tickedUnmerged: 'ticked, not merged',
|
|
59
|
+
phase: 'phase',
|
|
60
|
+
startsAfter: 'starts after',
|
|
61
|
+
planFinished: 'plan finished',
|
|
62
|
+
noPlan: 'no plan',
|
|
63
|
+
noName: 'no name',
|
|
64
|
+
noValue: 'no value',
|
|
65
|
+
unnamedPane: (name) => `pane without a name, herdr agent rename "$HERDR_PANE_ID" ${name}`,
|
|
66
|
+
noAgentNamed: (name) => `no agent named ${name}`,
|
|
67
|
+
afterMerge: (number) => `after merge #${number}`,
|
|
68
|
+
mergedLaneDuty: 'start the next phase or close the lane',
|
|
69
|
+
otherLaneDuty: 'rebase, then start the next phase or name the reason to wait',
|
|
70
|
+
blockedDuty:
|
|
71
|
+
'vet the question against the plan and the rules, answer it in the lane or ask the user in a dialog; never hand the pane over',
|
|
72
|
+
laneAlertDuty: 'request the hand-over at the next phase boundary (§ 8)',
|
|
73
|
+
checkoutRows: (term, count) => `${term}: ${count} ${count === 1 ? 'row' : 'rows'} only in the checkout`,
|
|
74
|
+
otherPullRequests: 'other PRs',
|
|
75
|
+
},
|
|
76
|
+
}
|