jackherd 1.8.1 → 1.10.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 +60 -0
- package/INSTALL.md +3 -1
- package/README.md +75 -64
- package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
- package/dist/config.d.ts +45 -4
- package/dist/config.js +84 -27
- package/dist/plan-format.d.ts +63 -7
- package/dist/plan-format.js +66 -12
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +52 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +9 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +21 -4
- package/kit/hooks/statusline.sh +1 -1
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +1 -8
- package/kit/workflow/skills/implement/SKILL.md +10 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +4 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +85 -7
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +4 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +80 -26
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +87 -0
- package/src/lane-prompt/input.ts +116 -0
- package/src/lane-prompt/options.ts +68 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/plan-format/clock.ts +12 -0
- package/src/plan-format/dependencies.ts +9 -9
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +53 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/appointment-rows.ts +59 -0
- package/src/plans/ask-coordinator.ts +77 -0
- package/src/plans/ask-target.ts +25 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/fetch.ts +26 -3
- package/src/plans/git.ts +19 -5
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-standing.ts +9 -4
- package/src/plans/lane-summary.ts +367 -92
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +80 -19
- package/src/plans/layout-summary.ts +163 -74
- package/src/plans/layout.ts +472 -114
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +119 -10
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/pull-requests.ts +23 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/selection.ts +117 -34
- package/src/plans/sent-files-read.ts +13 -0
- package/src/plans/sent-files.ts +114 -0
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +62 -0
- package/src/plans/snapshot.ts +279 -60
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +131 -0
- package/src/plans/subagents.ts +190 -0
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +36 -14
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +20 -12
- package/src/plans/waiting-click.ts +231 -0
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +105 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-location.ts +89 -0
- package/src/shared/private-records.ts +48 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +89 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +16 -4
- package/src/terminal/width.ts +2 -1
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +44 -6
- package/src/waiting/command.ts +50 -0
- package/src/watch/appointments.ts +2 -4
- package/src/watch/approvals.ts +2 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +401 -90
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +58 -14
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +37 -5
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +13 -7
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +8 -3
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/source-path.ts +1 -1
- package/src/watch/sources.ts +7 -4
- package/src/watch/stream-output.ts +14 -8
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +108 -0
- package/src/watch/task-parallel-read.ts +151 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +235 -0
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +36 -1
- package/src/watch/watcher-lock.ts +25 -29
- package/src/watch/workflow-runs.ts +2 -1
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { MAX_TASK_TEXT } from './text'
|
|
2
|
+
import type { TaskSignal } from './types'
|
|
3
|
+
|
|
4
|
+
/** An open task of a plan: where it stands (`phaseId`, index among the phase's items) and its text. */
|
|
5
|
+
export type OpenPlanTask = { phaseId: string; taskIndex: number; text: string }
|
|
6
|
+
|
|
7
|
+
export type MatchedSignal = { signal: TaskSignal; task: OpenPlanTask | null }
|
|
8
|
+
|
|
9
|
+
/** Shorter than this, a text names nothing: it would match any task. */
|
|
10
|
+
const MIN_TEXT = 4
|
|
11
|
+
/** A whole plan task found inside a longer signal must be this long, or "Tests" claims every signal about tests. */
|
|
12
|
+
const MIN_TASK_INSIDE_SIGNAL = 12
|
|
13
|
+
/** Shorter words (`PR`, `und`, `die`, numbers) carry no meaning for similar wording. */
|
|
14
|
+
const MIN_WORD = 4
|
|
15
|
+
/** Two forms of one word share at least this many letters from the start. */
|
|
16
|
+
const MIN_STEM = 4
|
|
17
|
+
/** And differ in at most this many letters at the end of the shorter one: German endings, `-en`, `-er`, `-e`. */
|
|
18
|
+
const MAX_ENDING = 2
|
|
19
|
+
/** Similar wording needs this many of the signal's words in the task, so two shared words claim nothing. */
|
|
20
|
+
const MIN_SIMILAR_WORDS = 3
|
|
21
|
+
/** And this share of them. */
|
|
22
|
+
const MIN_SIMILAR_SHARE = 0.75
|
|
23
|
+
|
|
24
|
+
const normalised = (text: string): string =>
|
|
25
|
+
text
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
28
|
+
.trim()
|
|
29
|
+
|
|
30
|
+
const headOf = (task: OpenPlanTask): string => task.text.slice(0, MAX_TASK_TEXT)
|
|
31
|
+
|
|
32
|
+
const isPrefix = (signalText: string, task: OpenPlanTask): boolean =>
|
|
33
|
+
signalText.length >= MIN_TEXT && headOf(task).startsWith(signalText)
|
|
34
|
+
|
|
35
|
+
const isContained = (signalText: string, task: OpenPlanTask): boolean => {
|
|
36
|
+
const signal = normalised(signalText)
|
|
37
|
+
const head = normalised(headOf(task))
|
|
38
|
+
if (signal.length < MIN_TEXT) return false
|
|
39
|
+
return head.includes(signal) || (head.length >= MIN_TASK_INSIDE_SIGNAL && signal.includes(head))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const wordsOf = (text: string): string[] =>
|
|
43
|
+
normalised(text)
|
|
44
|
+
.split(' ')
|
|
45
|
+
.filter((word) => word.length >= MIN_WORD)
|
|
46
|
+
|
|
47
|
+
const commonPrefix = (first: string, second: string): number => {
|
|
48
|
+
let length = 0
|
|
49
|
+
while (length < first.length && first[length] === second[length]) length += 1
|
|
50
|
+
return length
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One word, two forms: equal, or the same stem with a short ending of its own (`roter`, `roten`; `kompakter`, `kompaktere`). */
|
|
54
|
+
const sameWord = (first: string, second: string): boolean => {
|
|
55
|
+
const shared = commonPrefix(first, second)
|
|
56
|
+
return first === second || (shared >= MIN_STEM && shared >= Math.min(first.length, second.length) - MAX_ENDING)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** How many of the signal's words the task's head holds, and their share of the signal's words. */
|
|
60
|
+
const similarity = (signalWords: readonly string[], task: OpenPlanTask): { matched: number; share: number } => {
|
|
61
|
+
const taskWords = wordsOf(headOf(task))
|
|
62
|
+
const matched = signalWords.filter((word) => taskWords.some((each) => sameWord(word, each))).length
|
|
63
|
+
return { matched, share: signalWords.length === 0 ? 0 : matched / signalWords.length }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The task whose head holds most of the signal's words, each in any inflected form, when at least `MIN_SIMILAR_WORDS`
|
|
68
|
+
* and `MIN_SIMILAR_SHARE` of them; null otherwise. For an agent that names a task in its own words.
|
|
69
|
+
*/
|
|
70
|
+
const similarTask = (tasks: readonly OpenPlanTask[], signalText: string): OpenPlanTask | null => {
|
|
71
|
+
const signalWords = [...new Set(wordsOf(signalText))]
|
|
72
|
+
const scored = tasks
|
|
73
|
+
.map((task) => ({ task, ...similarity(signalWords, task) }))
|
|
74
|
+
.filter(({ matched, share }) => matched >= MIN_SIMILAR_WORDS && share >= MIN_SIMILAR_SHARE)
|
|
75
|
+
return (
|
|
76
|
+
scored.reduce<(typeof scored)[number] | null>(
|
|
77
|
+
(best, each) => (best === null || each.share > best.share ? each : best),
|
|
78
|
+
null,
|
|
79
|
+
)?.task ?? null
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The plan task each signal belongs to, else null (an unplanned task). An exact prefix of a task's text wins over text
|
|
85
|
+
* containment (case, punctuation and spacing ignored, in either direction, on the first 200 characters of the task),
|
|
86
|
+
* and containment over similar wording (`similarTask`). Several signals may match several tasks of several phases,
|
|
87
|
+
* and several signals the same task.
|
|
88
|
+
*/
|
|
89
|
+
export const matchSignals = (tasks: readonly OpenPlanTask[], signals: readonly TaskSignal[]): MatchedSignal[] =>
|
|
90
|
+
signals.map((signal) => ({
|
|
91
|
+
signal,
|
|
92
|
+
task:
|
|
93
|
+
tasks.find((task) => isPrefix(signal.text, task)) ??
|
|
94
|
+
tasks.find((task) => isContained(signal.text, task)) ??
|
|
95
|
+
similarTask(tasks, signal.text),
|
|
96
|
+
}))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { readRecords, writeRecordAtomically } from '../shared/private-records'
|
|
3
|
+
import { isTaskRecord, namesTask, openRecord, type TaskRecord } from './record'
|
|
4
|
+
import { cleanAgentName } from './text'
|
|
5
|
+
|
|
6
|
+
const directoryOf = (root: string): string => join(root, '.jackherd', 'tasks')
|
|
7
|
+
|
|
8
|
+
/** Every well-formed record in the main checkout's `.jackherd/tasks/`, oldest first; nothing when the directory is missing. */
|
|
9
|
+
export const readTaskRecords = (root: string): TaskRecord[] =>
|
|
10
|
+
readRecords(directoryOf(root), isTaskRecord, ({ startedAt }) => startedAt)
|
|
11
|
+
|
|
12
|
+
/** The agent's open record, null when it has none. */
|
|
13
|
+
export const openTaskOf = (root: string, agent: string): TaskRecord | null => {
|
|
14
|
+
const wanted = cleanAgentName(agent)
|
|
15
|
+
return readTaskRecords(root).find((record) => record.agent === wanted && record.closedAt === null) ?? null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Starts a task for `agent`. The agent's one record is reused (its id kept) and reopened with the new task. */
|
|
19
|
+
export const startTask = (
|
|
20
|
+
root: string,
|
|
21
|
+
fields: { agent: string; text: string; cwd: string; newId: () => string },
|
|
22
|
+
now: number,
|
|
23
|
+
): TaskRecord => {
|
|
24
|
+
const previous = readTaskRecords(root).find(({ agent }) => agent === cleanAgentName(fields.agent))
|
|
25
|
+
const record = openRecord({ ...fields, id: previous?.id ?? fields.newId() }, now)
|
|
26
|
+
writeRecordAtomically(directoryOf(root), record)
|
|
27
|
+
return record
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Closes the agent's open task; with `text`, only when it names that task. Returns the closed record, or null when
|
|
32
|
+
* the agent has no open task or the text names another.
|
|
33
|
+
*/
|
|
34
|
+
export const finishTask = (root: string, agent: string, text: string, now: number): TaskRecord | null => {
|
|
35
|
+
const open = openTaskOf(root, agent)
|
|
36
|
+
if (!open || !namesTask(open, text)) return null
|
|
37
|
+
const closed = { ...open, closedAt: now }
|
|
38
|
+
writeRecordAtomically(directoryOf(root), closed)
|
|
39
|
+
return closed
|
|
40
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isRecord } from '../shared/is-record'
|
|
2
|
+
import { cleanAgentName, cleanTaskText, MAX_TASK_TEXT } from './text'
|
|
3
|
+
import type { TaskSignal } from './types'
|
|
4
|
+
|
|
5
|
+
/** What `jh task start` stores in `.jackherd/tasks/<id>.json`: one record per agent, closed by `jh task done`. */
|
|
6
|
+
export type TaskRecord = {
|
|
7
|
+
id: string
|
|
8
|
+
agent: string
|
|
9
|
+
text: string
|
|
10
|
+
startedAt: number
|
|
11
|
+
cwd: string
|
|
12
|
+
closedAt: number | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ID = /^[0-9a-f]{12}$/
|
|
16
|
+
const ID_BYTES = 6
|
|
17
|
+
|
|
18
|
+
/** The id of a record from random bytes the caller drew, so a test reaches the drawing; 12 hex characters. */
|
|
19
|
+
export const buildTaskId = (randomBytes: Uint8Array): string =>
|
|
20
|
+
[...randomBytes.slice(0, ID_BYTES)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
21
|
+
|
|
22
|
+
export const isTaskRecord = (value: unknown): value is TaskRecord => {
|
|
23
|
+
if (!isRecord(value)) return false
|
|
24
|
+
const record = value
|
|
25
|
+
return (
|
|
26
|
+
typeof record.id === 'string' &&
|
|
27
|
+
ID.test(record.id) &&
|
|
28
|
+
typeof record.agent === 'string' &&
|
|
29
|
+
typeof record.text === 'string' &&
|
|
30
|
+
record.text.length <= MAX_TASK_TEXT &&
|
|
31
|
+
typeof record.startedAt === 'number' &&
|
|
32
|
+
typeof record.cwd === 'string' &&
|
|
33
|
+
(record.closedAt === null || typeof record.closedAt === 'number')
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A fresh open record; the text and the agent name are cleaned here, whoever wrote them. */
|
|
38
|
+
export const openRecord = (
|
|
39
|
+
fields: { id: string; agent: string; text: string; cwd: string },
|
|
40
|
+
now: number,
|
|
41
|
+
): TaskRecord => ({
|
|
42
|
+
id: fields.id,
|
|
43
|
+
agent: cleanAgentName(fields.agent),
|
|
44
|
+
text: cleanTaskText(fields.text),
|
|
45
|
+
startedAt: now,
|
|
46
|
+
cwd: fields.cwd,
|
|
47
|
+
closedAt: null,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** Whether `text` names the record's task: empty text names any, else it is the start of the record's text. */
|
|
51
|
+
export const namesTask = (record: TaskRecord, text: string): boolean => {
|
|
52
|
+
const wanted = cleanTaskText(text)
|
|
53
|
+
return wanted === '' || record.text.startsWith(wanted)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The open records as signals; a record cleaned on write is cleaned again on read, as a hand-written file may differ. */
|
|
57
|
+
export const recordSignals = (records: readonly TaskRecord[]): TaskSignal[] =>
|
|
58
|
+
records
|
|
59
|
+
.filter(({ closedAt }) => closedAt === null)
|
|
60
|
+
.map(({ agent, text, startedAt, cwd }) => ({
|
|
61
|
+
agent: cleanAgentName(agent),
|
|
62
|
+
source: 'jh' as const,
|
|
63
|
+
text: cleanTaskText(text),
|
|
64
|
+
startedAt,
|
|
65
|
+
cwd,
|
|
66
|
+
}))
|
|
67
|
+
.filter(({ text }) => text !== '')
|
|
68
|
+
.sort((left, right) => left.startedAt - right.startedAt)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
|
|
3
|
+
/** The longest task text a signal carries; a plan task is matched on its first 200 characters as well. */
|
|
4
|
+
export const MAX_TASK_TEXT = 200
|
|
5
|
+
const MAX_AGENT_NAME = 100
|
|
6
|
+
|
|
7
|
+
const capped = (text: string, max: number): string => terminalSafe(text.replace(/\s+/g, ' ')).trim().slice(0, max)
|
|
8
|
+
|
|
9
|
+
/** Agent-written task text made safe to store and print: no control characters, one line, at most 200 characters. */
|
|
10
|
+
export const cleanTaskText = (text: string): string => capped(text, MAX_TASK_TEXT)
|
|
11
|
+
|
|
12
|
+
/** The same for the name of an agent. */
|
|
13
|
+
export const cleanAgentName = (name: string): string => capped(name, MAX_AGENT_NAME)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Who reported a task: Claude Code's task list, Codex's plan tool, or `jh task start`. */
|
|
2
|
+
export type TaskSource = 'claude' | 'codex' | 'jh'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One task an agent is working on right now. `agent` names the session or subagent that reported it, `startedAt` is
|
|
6
|
+
* epoch milliseconds, `cwd` the directory the agent worked in (its worktree tells the lane).
|
|
7
|
+
*/
|
|
8
|
+
export type TaskSignal = {
|
|
9
|
+
agent: string
|
|
10
|
+
source: TaskSource
|
|
11
|
+
text: string
|
|
12
|
+
startedAt: number
|
|
13
|
+
cwd: string
|
|
14
|
+
}
|
package/src/terminal/open.ts
CHANGED
|
@@ -5,10 +5,16 @@ export type OpenEnvironment = {
|
|
|
5
5
|
start: (command: string, args: string[]) => void
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
const OPENERS: Partial<Record<NodeJS.Platform, string>> = {
|
|
8
|
+
const OPENERS: Partial<Record<NodeJS.Platform, (target: string) => string[]>> = {
|
|
9
|
+
darwin: (target) => ['open', target],
|
|
10
|
+
linux: (target) => ['xdg-open', target],
|
|
11
|
+
/** Not `cmd /c start`: cmd reads `&`, `|`, `%` and `^` in the target as its own syntax; rundll32 takes it as one argument. */
|
|
12
|
+
win32: (target) => ['rundll32', 'url.dll,FileProtocolHandler', target],
|
|
13
|
+
}
|
|
9
14
|
|
|
10
|
-
/** The command that
|
|
11
|
-
export const
|
|
15
|
+
/** The command and arguments that open a file or a link in the default application of the platform, null when there is none. */
|
|
16
|
+
export const openCommandFor = (platform: NodeJS.Platform, target: string): string[] | null =>
|
|
17
|
+
OPENERS[platform]?.(target) ?? null
|
|
12
18
|
|
|
13
19
|
/** Linux opens things in a graphical session only; elsewhere the platform's opener is assumed to work. */
|
|
14
20
|
export const hasDisplay = ({ platform, env }: Pick<OpenEnvironment, 'platform' | 'env'>): boolean =>
|
|
@@ -27,11 +33,12 @@ const systemEnvironment = (): OpenEnvironment => ({
|
|
|
27
33
|
* opener for the platform, no display, the opener could not be started), null when it was started.
|
|
28
34
|
*/
|
|
29
35
|
export const openInBrowser = (target: string, environment: OpenEnvironment = systemEnvironment()): string | null => {
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
36
|
+
const command = openCommandFor(environment.platform, target)
|
|
37
|
+
if (!command) return `no way to open ${target} on ${environment.platform}`
|
|
32
38
|
if (!hasDisplay(environment)) return 'no display'
|
|
39
|
+
const [opener, ...args] = command
|
|
33
40
|
try {
|
|
34
|
-
environment.start(opener,
|
|
41
|
+
environment.start(opener, args)
|
|
35
42
|
return null
|
|
36
43
|
} catch (error) {
|
|
37
44
|
return `${opener} ${target}: ${error instanceof Error ? error.message : String(error)}`
|
package/src/terminal/text.ts
CHANGED
|
@@ -34,6 +34,13 @@ const FAINT_OFF = `${ESC}[22m`
|
|
|
34
34
|
const BAR_WIDTH = 10
|
|
35
35
|
|
|
36
36
|
const style = (code: string) => (text: string) => `${ESC}[${code}m${text}${RESET}`
|
|
37
|
+
const BRAND_TEAL_TRUE_COLOUR = '38;2;3;181;189'
|
|
38
|
+
const BRAND_TEAL_256 = '38;5;37'
|
|
39
|
+
|
|
40
|
+
/** The brand teal `#03B5BD` as true colour where `COLORTERM` says `truecolor` or `24bit`, else 256-colour 37. */
|
|
41
|
+
export const tealCodeOf = (env: Record<string, string | undefined>): string =>
|
|
42
|
+
env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' ? BRAND_TEAL_TRUE_COLOUR : BRAND_TEAL_256
|
|
43
|
+
|
|
37
44
|
export const paint = {
|
|
38
45
|
bold: style('1'),
|
|
39
46
|
dim: style('2'),
|
|
@@ -41,7 +48,7 @@ export const paint = {
|
|
|
41
48
|
green: style('32'),
|
|
42
49
|
yellow: style('33'),
|
|
43
50
|
magenta: style('35'),
|
|
44
|
-
teal: style(
|
|
51
|
+
teal: style(tealCodeOf(process.env)),
|
|
45
52
|
cyan: style('36'),
|
|
46
53
|
inverse: style('7'),
|
|
47
54
|
}
|
|
@@ -71,9 +78,14 @@ export const badge = (url: string, text: string, hint = '', tone: BadgeTone = BA
|
|
|
71
78
|
/** `text` as a badge without a link, for what no click opens: the background of `tone`, a cell of padding either side. */
|
|
72
79
|
export const box = (text: string, tone: BadgeTone = BADGE_TONE.grey): string => `${ESC}[${tone}m ${text} ${RESET}`
|
|
73
80
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
/**
|
|
82
|
+
* A line for output that is not a terminal: styling dropped, each link written out as `text (url)`, except the
|
|
83
|
+
* ones `writesAddress` refuses, which keep the bare text.
|
|
84
|
+
*/
|
|
85
|
+
export const plainText = (text: string, writesAddress: (url: string) => boolean = () => true): string =>
|
|
86
|
+
stripAnsi(
|
|
87
|
+
text.replace(LINK, (_, url, inner) => (writesAddress(url) ? `${stripAnsi(inner)} (${url})` : stripAnsi(inner))),
|
|
88
|
+
)
|
|
77
89
|
|
|
78
90
|
/** The terminal cells a styled line takes: emoji count two. */
|
|
79
91
|
export const visibleLength = (text: string): number => textWidth(stripAnsi(text))
|
package/src/terminal/width.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const VARIATION_SELECTOR = 0xfe0f
|
|
2
|
+
const TEXT_SELECTOR = 0xfe0e
|
|
2
3
|
const ZERO_WIDTH_JOINER = 0x200d
|
|
3
4
|
const COMBINING_MARKS: readonly [number, number][] = [[0x0300, 0x036f]]
|
|
4
5
|
|
|
@@ -59,7 +60,7 @@ const within = (ranges: readonly [number, number][], codePoint: number): boolean
|
|
|
59
60
|
* variation selector (`⏸`) is one cell on its own; `textWidth` counts it as two when the selector follows.
|
|
60
61
|
*/
|
|
61
62
|
export const cellWidth = (codePoint: number): number => {
|
|
62
|
-
if (codePoint === VARIATION_SELECTOR || codePoint === ZERO_WIDTH_JOINER) return 0
|
|
63
|
+
if (codePoint === VARIATION_SELECTOR || codePoint === TEXT_SELECTOR || codePoint === ZERO_WIDTH_JOINER) return 0
|
|
63
64
|
if (within(COMBINING_MARKS, codePoint)) return 0
|
|
64
65
|
return within(WIDE, codePoint) ? 2 : 1
|
|
65
66
|
}
|
|
@@ -6,6 +6,19 @@ export type SkillChange =
|
|
|
6
6
|
| { kind: typeof SKILL_CHANGE.unchanged }
|
|
7
7
|
| { kind: typeof SKILL_CHANGE.unavailable; reason: string }
|
|
8
8
|
|
|
9
|
+
/** How a reference file of the orchestrate skill differs between the kit at the start and the kit now. */
|
|
10
|
+
export const REFERENCE_CHANGE = { changed: 'changed', added: 'added', deleted: 'deleted' } as const
|
|
11
|
+
|
|
12
|
+
type ReferenceChangeKind = (typeof REFERENCE_CHANGE)[keyof typeof REFERENCE_CHANGE]
|
|
13
|
+
|
|
14
|
+
/** A reference that differs, before the diff is written or the path known. */
|
|
15
|
+
export type ReferenceDelta = { name: string; kind: ReferenceChangeKind }
|
|
16
|
+
|
|
17
|
+
export type ReferenceChange =
|
|
18
|
+
| { name: string; kind: typeof REFERENCE_CHANGE.changed; diffPath: string | null }
|
|
19
|
+
| { name: string; kind: typeof REFERENCE_CHANGE.added; path: string }
|
|
20
|
+
| { name: string; kind: typeof REFERENCE_CHANGE.deleted }
|
|
21
|
+
|
|
9
22
|
const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
|
|
10
23
|
const SECTION_HEADING = /^## (.+)$/
|
|
11
24
|
const NUMBERED_SECTION = /^(\d+)\.\s/
|
|
@@ -61,3 +74,36 @@ export const skillChangeLine = (change: SkillChange): string => {
|
|
|
61
74
|
if (change.kind === SKILL_CHANGE.unchanged) return 'orchestrate skill unchanged'
|
|
62
75
|
return `orchestrate skill diff unavailable (${change.reason}): read the skill again`
|
|
63
76
|
}
|
|
77
|
+
|
|
78
|
+
const deltaOf = (name: string, before: string | undefined, after: string | undefined): ReferenceDelta[] => {
|
|
79
|
+
if (before === after) return []
|
|
80
|
+
if (before === undefined) return [{ name, kind: REFERENCE_CHANGE.added }]
|
|
81
|
+
return [{ name, kind: after === undefined ? REFERENCE_CHANGE.deleted : REFERENCE_CHANGE.changed }]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The references (file name → text) that changed, are new or are gone, by name; unchanged ones are left out. */
|
|
85
|
+
export const referenceChanges = (
|
|
86
|
+
before: Readonly<Record<string, string>>,
|
|
87
|
+
after: Readonly<Record<string, string>>,
|
|
88
|
+
): ReferenceDelta[] =>
|
|
89
|
+
[...new Set([...Object.keys(before), ...Object.keys(after)])]
|
|
90
|
+
.sort()
|
|
91
|
+
.flatMap((name) => deltaOf(name, before[name], after[name]))
|
|
92
|
+
|
|
93
|
+
export const referenceChangeLine = (change: ReferenceChange): string => {
|
|
94
|
+
const head = `orchestrate reference ${change.name}`
|
|
95
|
+
if (change.kind === REFERENCE_CHANGE.deleted) return `${head} deleted`
|
|
96
|
+
if (change.kind === REFERENCE_CHANGE.added) return `${head} new · read ${change.path}`
|
|
97
|
+
return change.diffPath === null
|
|
98
|
+
? `${head} changed · diff unavailable: read it again`
|
|
99
|
+
: `${head} changed · diff ${change.diffPath}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The lines about the orchestrate skill after a kit update: the skill's own, then one per reference that changed. An
|
|
104
|
+
* unchanged skill is named only when no reference changed either, so a quiet update stays one line.
|
|
105
|
+
*/
|
|
106
|
+
export const orchestrateChangeLines = (skill: SkillChange, references: readonly ReferenceChange[]): string[] => {
|
|
107
|
+
if (skill.kind === SKILL_CHANGE.unchanged && references.length > 0) return references.map(referenceChangeLine)
|
|
108
|
+
return [skillChangeLine(skill), ...references.map(referenceChangeLine)]
|
|
109
|
+
}
|
package/src/vocabulary/index.ts
CHANGED
|
@@ -39,13 +39,23 @@ export const SELF_MERGE_LABEL: Record<PlanLanguage, string> = {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* The
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* The standing approval: the option that approves (`approve`) and the word between the plan and the end in its
|
|
43
|
+
* question (``Plan `<path>` <until> <YYYY-MM-DD HH:MM>``). The merge guard hook lifts its question for a plain merge of
|
|
44
|
+
* that plan's lanes only when the newest answer to such a question is exactly `approve`, so both are fixed per
|
|
45
|
+
* language and not a config value.
|
|
45
46
|
*/
|
|
46
|
-
export const
|
|
47
|
-
[PLAN_LANGUAGE.en]: { approve: 'Approve
|
|
48
|
-
[PLAN_LANGUAGE.de]: { approve: '
|
|
47
|
+
export const STANDING_APPROVAL_TEXTS: Record<PlanLanguage, { approve: string; until: string }> = {
|
|
48
|
+
[PLAN_LANGUAGE.en]: { approve: 'Approve standing', until: 'until' },
|
|
49
|
+
[PLAN_LANGUAGE.de]: { approve: 'Stehend freigeben', until: 'bis' },
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The words a lane prompt opens with, followed by the lane's name. The ask hint hook and the project's lane exception
|
|
54
|
+
* recognise a lane by them, so they are fixed per language and not a config value.
|
|
55
|
+
*/
|
|
56
|
+
export const LANE_PROMPT_OPENING: Record<PlanLanguage, string> = {
|
|
57
|
+
[PLAN_LANGUAGE.en]: 'You are the lane',
|
|
58
|
+
[PLAN_LANGUAGE.de]: 'Du bist die Spur',
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
/** What the approval gate hook tells the lane when it denies: jackherd's own words, English in every language. */
|
|
@@ -93,6 +103,34 @@ export const APPOINTMENT_VOCABULARY: Record<
|
|
|
93
103
|
},
|
|
94
104
|
}
|
|
95
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The board section of what waits on the user's answer (a sent preview, an open question, a report to accept): its
|
|
108
|
+
* heading, the header names of its columns (since, item, origin, status, found by name in any order; the status
|
|
109
|
+
* column may also carry a name of `statusAlso`) and the lower-case words a status cell of a still open item starts with.
|
|
110
|
+
*/
|
|
111
|
+
export const WAITING_VOCABULARY: Record<
|
|
112
|
+
PlanLanguage,
|
|
113
|
+
{
|
|
114
|
+
heading: string
|
|
115
|
+
columns: { since: string; item: string; origin: string; status: string }
|
|
116
|
+
statusAlso: readonly string[]
|
|
117
|
+
open: readonly string[]
|
|
118
|
+
}
|
|
119
|
+
> = {
|
|
120
|
+
[PLAN_LANGUAGE.de]: {
|
|
121
|
+
heading: 'Wartet auf dich',
|
|
122
|
+
columns: { since: 'Seit', item: 'Punkt', origin: 'Herkunft', status: 'Stand' },
|
|
123
|
+
statusAlso: ['Status'],
|
|
124
|
+
open: ['offen'],
|
|
125
|
+
},
|
|
126
|
+
[PLAN_LANGUAGE.en]: {
|
|
127
|
+
heading: 'Waiting for you',
|
|
128
|
+
columns: { since: 'Since', item: 'Item', origin: 'Origin', status: 'Status' },
|
|
129
|
+
statusAlso: [],
|
|
130
|
+
open: ['open'],
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
|
|
96
134
|
export const isPlanLanguage = (value: string): value is PlanLanguage =>
|
|
97
135
|
(Object.values(PLAN_LANGUAGE) as string[]).includes(value)
|
|
98
136
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
import type { WaitingItem } from '../plan-format/waiting'
|
|
3
|
+
import { mainCheckout } from '../plans/git'
|
|
4
|
+
import { closeAllQuestionRecords, closeQuestionRecords } from '../plans/questions-read'
|
|
5
|
+
import { closeAllSentRecords, closeSentRecords } from '../plans/sent-files-read'
|
|
6
|
+
import { recordWaitingItems } from '../plans/waiting-records'
|
|
7
|
+
import { CLI_NAME } from '../version/release'
|
|
8
|
+
|
|
9
|
+
const USAGE = `usage: ${CLI_NAME} waiting [close <id> ... | close --all]`
|
|
10
|
+
|
|
11
|
+
/** The lines `jh waiting` prints: id, time and text of each item, made safe for the terminal; a note when none waits. */
|
|
12
|
+
export const waitingListing = (items: readonly WaitingItem[]): string[] =>
|
|
13
|
+
items.length === 0
|
|
14
|
+
? ['nothing waits on you']
|
|
15
|
+
: items.map(({ since, item, id }) => terminalSafe(`${id} · ${since} · ${item}`))
|
|
16
|
+
|
|
17
|
+
const CLOSE_ALL = '--all'
|
|
18
|
+
|
|
19
|
+
/** Closes the records the ids name, files and questions alike; how many it closed. */
|
|
20
|
+
export const closeWaiting = (root: string, ids: readonly string[], now: number): number =>
|
|
21
|
+
closeSentRecords(root, ids, now) + closeQuestionRecords(root, ids, now)
|
|
22
|
+
|
|
23
|
+
/** Closes every open record, files and questions alike; how many it closed. */
|
|
24
|
+
export const closeAllWaiting = (root: string, now: number): number =>
|
|
25
|
+
closeAllSentRecords(root, now) + closeAllQuestionRecords(root, now)
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `jh waiting` lists the files sent to the user and the questions left unanswered that no one closed (their id, time
|
|
29
|
+
* and text); `jh waiting close <id> ...` or `close --all` closes them once the user has answered. Exit 0, 1 on a wrong
|
|
30
|
+
* command line.
|
|
31
|
+
*/
|
|
32
|
+
export const run = async (args: string[]): Promise<number> => {
|
|
33
|
+
const root = mainCheckout(process.cwd())
|
|
34
|
+
if (args.length === 0) {
|
|
35
|
+
for (const line of waitingListing(recordWaitingItems(root, Date.now()))) console.log(line)
|
|
36
|
+
return 0
|
|
37
|
+
}
|
|
38
|
+
const [command, ...rest] = args
|
|
39
|
+
if (command !== 'close' || rest.length === 0) {
|
|
40
|
+
console.error(USAGE)
|
|
41
|
+
return 1
|
|
42
|
+
}
|
|
43
|
+
if (rest.includes(CLOSE_ALL) && rest.length > 1) {
|
|
44
|
+
console.error(`${CLOSE_ALL} closes everything and takes no ids\n${USAGE}`)
|
|
45
|
+
return 1
|
|
46
|
+
}
|
|
47
|
+
const closed = rest[0] === CLOSE_ALL ? closeAllWaiting(root, Date.now()) : closeWaiting(root, rest, Date.now())
|
|
48
|
+
console.log(`closed ${closed}`)
|
|
49
|
+
return 0
|
|
50
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { DAY_MS, twoDigits } from '../plan-format/clock'
|
|
1
2
|
import { firstTableWithHeader, sectionLines } from '../plan-format/markdown'
|
|
3
|
+
import { isRecord } from '../shared/is-record'
|
|
2
4
|
import { APPOINTMENT_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
3
|
-
import { isRecord } from './json-guards'
|
|
4
5
|
|
|
5
6
|
export const APPOINTMENT_EVENT = { due: 'due', unreadable: 'appointment unreadable' } as const
|
|
6
7
|
|
|
@@ -30,7 +31,6 @@ type DateParts = { year: number | null; month: number; day: number; hours: numbe
|
|
|
30
31
|
const DAY_FIRST = /^(?:\p{L}{2,3}\.?,?\s+)?(\d{1,2})\.(\d{1,2})\.(\d{4})?\s+(\d{1,2}):(\d{2})$/u
|
|
31
32
|
const ISO = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{1,2}):(\d{2})$/
|
|
32
33
|
const KEY_TIME_LENGTH = 'YYYY-MM-DDThh:mm'.length
|
|
33
|
-
const DAY_MS = 86_400_000
|
|
34
34
|
const KEEP_GONE_MS = 7 * DAY_MS
|
|
35
35
|
|
|
36
36
|
const APPOINTMENT_KINDS: readonly string[] = Object.values(APPOINTMENT_EVENT)
|
|
@@ -90,8 +90,6 @@ export const parseAppointmentTime = (text: string, now: Date): Date | null => {
|
|
|
90
90
|
return parts.year === null ? nearestToClock(parts, now) : localTime(parts, parts.year)
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
const twoDigits = (value: number): string => String(value).padStart(2, '0')
|
|
94
|
-
|
|
95
93
|
/** The local time to the minute and the action: a moved appointment gets a new key, a reworded day name does not. */
|
|
96
94
|
export const appointmentKey = (when: Date, action: string): string =>
|
|
97
95
|
`${when.getFullYear()}-${twoDigits(when.getMonth() + 1)}-${twoDigits(when.getDate())}T${twoDigits(when.getHours())}:${twoDigits(when.getMinutes())} ${action}`
|
package/src/watch/approvals.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
4
|
+
import { isRecord } from '../shared/is-record'
|
|
4
5
|
|
|
5
6
|
export const APPROVAL_EVENT = 'approval'
|
|
6
7
|
|
|
@@ -49,7 +50,6 @@ export const isApprovalRequestFile = (value: unknown): value is ApprovalRequestF
|
|
|
49
50
|
(value.usedAt === null || typeof value.usedAt === 'number')
|
|
50
51
|
|
|
51
52
|
const REPORT_MINUTES = 30
|
|
52
|
-
const MS_PER_MINUTE = 60_000
|
|
53
53
|
|
|
54
54
|
/** One event per open request younger than 30 minutes the watcher has not reported yet; an older one is stale. */
|
|
55
55
|
export const approvalEvents = (
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { questionSections } from './report-questions'
|
|
2
|
+
|
|
3
|
+
/** The questions sections of a report as one string; empty without a report or without an open question. */
|
|
4
|
+
export const questionsFingerprint = (text: string | null): string => {
|
|
5
|
+
if (text === null) return ''
|
|
6
|
+
const lines = text.split('\n')
|
|
7
|
+
return questionSections(lines)
|
|
8
|
+
.map(({ start, end }) => lines.slice(start, end).join('\n'))
|
|
9
|
+
.join('\n')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The working lanes whose report asks something other than what was last remembered for them (`current` and
|
|
14
|
+
* `remembered` are fingerprints per lane): a lane waiting on its background subagents ends no turn, so its question
|
|
15
|
+
* would wait for them. A lane without a remembered entry is unseen and asks nothing; its report is the baseline.
|
|
16
|
+
*/
|
|
17
|
+
export const askingLanes = (
|
|
18
|
+
working: readonly string[],
|
|
19
|
+
current: Readonly<Record<string, string>>,
|
|
20
|
+
remembered: Readonly<Record<string, string>>,
|
|
21
|
+
): string[] =>
|
|
22
|
+
working.filter((lane) => lane in remembered && (current[lane] ?? '') !== '' && current[lane] !== remembered[lane])
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The fingerprints to remember for the watched lanes (the keys of `current`): a lane whose report was `shown`, that
|
|
26
|
+
* asks nothing now or is seen for the first time takes what it has now; any other keeps what it had, so a question
|
|
27
|
+
* not yet shown still fires. A lane no longer watched is forgotten.
|
|
28
|
+
*/
|
|
29
|
+
export const recordReportQuestions = (
|
|
30
|
+
remembered: Readonly<Record<string, string>>,
|
|
31
|
+
current: Readonly<Record<string, string>>,
|
|
32
|
+
shown: readonly string[],
|
|
33
|
+
): Record<string, string> =>
|
|
34
|
+
Object.fromEntries(
|
|
35
|
+
Object.entries(current).map(([lane, questions]) => {
|
|
36
|
+
const kept = remembered[lane]
|
|
37
|
+
const takesCurrent = kept === undefined || questions === '' || shown.includes(lane)
|
|
38
|
+
return [lane, takesCurrent ? questions : kept]
|
|
39
|
+
}),
|
|
40
|
+
)
|
package/src/watch/base-tip.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { pullHeadRef } from '../plans/plan-heads'
|
|
2
|
+
|
|
1
3
|
const COMMIT = /^[0-9a-f]{40}$/
|
|
2
4
|
|
|
3
5
|
/** The commit of `refs/heads/<branch>` in a `git ls-remote` answer; null when it is not listed. */
|
|
@@ -16,3 +18,12 @@ export const remoteTipOf = (lsRemote: string, branch: string): string | null =>
|
|
|
16
18
|
*/
|
|
17
19
|
export const baseFetchNeeded = (remoteTip: string | null, localTip: string | null): boolean =>
|
|
18
20
|
remoteTip !== null && remoteTip !== localTip
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The pull requests whose head moved past the herd's ref of it (`pullHeadRef`), or that have none yet: the
|
|
24
|
+
* watcher's list carries each head commit, so an unchanged head costs no fetch.
|
|
25
|
+
*/
|
|
26
|
+
export const movedHeads = <Head extends { number: number; headCommit: string }>(
|
|
27
|
+
heads: readonly Head[],
|
|
28
|
+
localCommitOf: (ref: string) => string | null,
|
|
29
|
+
): Head[] => heads.filter(({ number, headCommit }) => baseFetchNeeded(headCommit, localCommitOf(pullHeadRef(number))))
|