jackherd 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +2 -0
  3. package/README.md +37 -44
  4. package/dist/{chunk-OU2KALXP.js → chunk-MCP4XJQH.js} +15 -0
  5. package/dist/config.d.ts +6 -0
  6. package/dist/config.js +19 -3
  7. package/dist/plan-format.d.ts +19 -2
  8. package/dist/plan-format.js +21 -7
  9. package/herdr-plugin/herdr-plugin.toml +1 -1
  10. package/install.sh +1 -1
  11. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  12. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  13. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  14. package/kit/hooks/hooks/merge-guard/decision.ts +67 -20
  15. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  16. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  17. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  18. package/kit/hooks/hooks/sent-file/store.ts +18 -0
  19. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  20. package/kit/hooks/hooks.json +9 -0
  21. package/kit/hooks/statusline.sh +1 -1
  22. package/kit/workflow/skills/implement/SKILL.md +2 -0
  23. package/kit/workflow/skills/orchestrate/SKILL.md +30 -6
  24. package/package.json +2 -1
  25. package/schemas/jackherd.schema.json +26 -2
  26. package/src/cli.ts +3 -0
  27. package/src/config/schema.ts +22 -2
  28. package/src/context/fill.ts +12 -0
  29. package/src/context/session-log.ts +43 -1
  30. package/src/plan-format/clock.ts +4 -0
  31. package/src/plan-format/dependencies.ts +2 -5
  32. package/src/plan-format/lanes.ts +30 -5
  33. package/src/plan-format/terminal-text.ts +4 -0
  34. package/src/plan-format/waiting.ts +48 -0
  35. package/src/plans/active-tasks.ts +60 -0
  36. package/src/plans/current-phase.ts +17 -7
  37. package/src/plans/current-task.ts +63 -5
  38. package/src/plans/lane-standing.ts +9 -4
  39. package/src/plans/lane-summary.ts +141 -74
  40. package/src/plans/lane-tickets.ts +15 -0
  41. package/src/plans/layout-phase.ts +57 -18
  42. package/src/plans/layout-summary.ts +85 -67
  43. package/src/plans/layout.ts +253 -89
  44. package/src/plans/live.ts +22 -0
  45. package/src/plans/phase-models.ts +1 -1
  46. package/src/plans/phase-ranges.ts +45 -0
  47. package/src/plans/pull-requests.ts +6 -0
  48. package/src/plans/selection.ts +19 -16
  49. package/src/plans/sent-files-read.ts +22 -0
  50. package/src/plans/sent-files.ts +121 -0
  51. package/src/plans/signals-read.ts +54 -0
  52. package/src/plans/snapshot.ts +251 -55
  53. package/src/plans/subagents-read.ts +82 -0
  54. package/src/plans/subagents.ts +132 -0
  55. package/src/plans/texts.ts +16 -11
  56. package/src/plans/unlisted-lanes.ts +10 -5
  57. package/src/plans/view.ts +3 -3
  58. package/src/plans/waiting-click.ts +76 -0
  59. package/src/preview/command.ts +83 -0
  60. package/src/preview/embed.ts +209 -0
  61. package/src/preview/images.ts +89 -0
  62. package/src/preview/open.ts +12 -0
  63. package/src/preview/options.ts +32 -0
  64. package/src/preview/page.ts +89 -0
  65. package/src/preview/render.ts +41 -0
  66. package/src/settled/board-row.ts +1 -2
  67. package/src/shared/is-record.ts +2 -0
  68. package/src/shared/private-records.ts +48 -0
  69. package/src/task-signals/agent.ts +15 -0
  70. package/src/task-signals/claude-read.ts +72 -0
  71. package/src/task-signals/claude-signals.ts +112 -0
  72. package/src/task-signals/command.ts +71 -0
  73. package/src/task-signals/fixtures/session.jsonl +7 -0
  74. package/src/task-signals/matcher.ts +96 -0
  75. package/src/task-signals/record-store.ts +40 -0
  76. package/src/task-signals/record.ts +68 -0
  77. package/src/task-signals/text.ts +13 -0
  78. package/src/task-signals/types.ts +14 -0
  79. package/src/terminal/open.ts +13 -6
  80. package/src/terminal/text.ts +8 -1
  81. package/src/terminal/width.ts +2 -1
  82. package/src/vocabulary/index.ts +28 -0
  83. package/src/waiting/command.ts +35 -0
  84. package/src/watch/appointments.ts +2 -3
  85. package/src/watch/approvals.ts +2 -2
  86. package/src/watch/command.ts +91 -5
  87. package/src/watch/failed-tests.ts +56 -10
  88. package/src/watch/failure-read.ts +18 -6
  89. package/src/watch/failures.ts +29 -5
  90. package/src/watch/feedback.ts +1 -1
  91. package/src/watch/gh-process.ts +9 -3
  92. package/src/watch/json-guards.ts +0 -3
  93. package/src/watch/lane-fill.ts +1 -1
  94. package/src/watch/lane-model.ts +1 -1
  95. package/src/watch/lane-parallel.ts +1 -1
  96. package/src/watch/lane-progress.ts +19 -7
  97. package/src/watch/lane-report.ts +5 -1
  98. package/src/watch/lane-rest.ts +28 -0
  99. package/src/watch/plan-badges.ts +9 -2
  100. package/src/watch/previews.ts +1 -1
  101. package/src/watch/pull-requests.ts +2 -1
  102. package/src/watch/source-path.ts +1 -1
  103. package/src/watch/sources.ts +1 -1
  104. package/src/watch/stream-output.ts +12 -7
  105. package/src/watch/task-parallel-input.ts +118 -0
  106. package/src/watch/task-parallel-read.ts +149 -0
  107. package/src/watch/task-parallel-touched.ts +39 -0
  108. package/src/watch/task-parallel.ts +206 -0
  109. package/src/watch/waiting-line.ts +19 -0
  110. package/src/watch/watch-events.ts +13 -1
  111. package/src/watch/workflow-runs.ts +2 -1
@@ -178,6 +178,17 @@
178
178
  },
179
179
  "additionalProperties": false
180
180
  },
181
+ "preview": {
182
+ "default": {},
183
+ "type": "object",
184
+ "properties": {
185
+ "publish": {
186
+ "default": true,
187
+ "type": "boolean"
188
+ }
189
+ },
190
+ "additionalProperties": false
191
+ },
181
192
  "plans": {
182
193
  "default": {},
183
194
  "type": "object",
@@ -270,13 +281,13 @@
270
281
  "type": "object",
271
282
  "properties": {
272
283
  "warnTokens": {
273
- "default": 200000,
284
+ "default": 350000,
274
285
  "type": "integer",
275
286
  "exclusiveMinimum": 0,
276
287
  "maximum": 9007199254740991
277
288
  },
278
289
  "alertTokens": {
279
- "default": 300000,
290
+ "default": 500000,
280
291
  "type": "integer",
281
292
  "exclusiveMinimum": 0,
282
293
  "maximum": 9007199254740991
@@ -361,6 +372,19 @@
361
372
  },
362
373
  "additionalProperties": false
363
374
  },
375
+ "parallelTasks": {
376
+ "default": {},
377
+ "type": "object",
378
+ "properties": {
379
+ "maxTokens": {
380
+ "default": 100000,
381
+ "type": "integer",
382
+ "exclusiveMinimum": 0,
383
+ "maximum": 9007199254740991
384
+ }
385
+ },
386
+ "additionalProperties": false
387
+ },
364
388
  "previews": {
365
389
  "type": "object",
366
390
  "properties": {
package/src/cli.ts CHANGED
@@ -24,9 +24,12 @@ export const COMMANDS: CommandTable = {
24
24
  [HERD_COMMAND.name]: () => import('./plans/command'),
25
25
  init: () => import('./sync/init-command'),
26
26
  [HERD_COMMAND.alias]: () => import('./plans/command'),
27
+ preview: () => import('./preview/command'),
27
28
  settled: () => import('./settled/command'),
28
29
  sync: () => import('./sync/command'),
30
+ task: () => import('./task-signals/command'),
29
31
  update: () => import('./update/command'),
32
+ waiting: () => import('./waiting/command'),
30
33
  watch: () => import('./watch/command'),
31
34
  }
32
35
 
@@ -118,6 +118,17 @@ const feedbackSchema = z.strictObject({
118
118
  noFindingTemplates: z.array(z.string().min(1)).default([CLEAN_REVIEW_TEXT]),
119
119
  })
120
120
 
121
+ const DEFAULT_PARALLEL_TASKS_MAX_TOKENS = 100_000
122
+
123
+ const parallelTasksSchema = z.strictObject({
124
+ /**
125
+ * The most the extra agents of a parallel proposal may cost before they do anything: the token baseline of a fresh
126
+ * agent times the agents to add. Above it the watcher does not propose parallel tasks. The baseline is the context
127
+ * the first answer of a lane's session carried.
128
+ */
129
+ maxTokens: z.number().int().positive().default(DEFAULT_PARALLEL_TASKS_MAX_TOKENS),
130
+ })
131
+
121
132
  const checksSchema = z.strictObject({
122
133
  /**
123
134
  * Finds a failed test in a line of a red run's log (`gh run view --log-failed`) after its job, step and timestamp
@@ -228,9 +239,9 @@ const hasUniqueNames = (sources: readonly { name: string }[]): boolean =>
228
239
  const coordinatorSchema = z
229
240
  .strictObject({
230
241
  /** Tokens in the coordinator's context from which the watcher reports `coordinator warn`. */
231
- warnTokens: tokenCount.default(200_000),
242
+ warnTokens: tokenCount.default(350_000),
232
243
  /** Tokens in the coordinator's context from which the watcher reports `coordinator alert`. */
233
- alertTokens: tokenCount.default(300_000),
244
+ alertTokens: tokenCount.default(500_000),
234
245
  })
235
246
  .refine(warnBelowAlert, WARN_BELOW_ALERT)
236
247
 
@@ -410,6 +421,13 @@ export const jackherdConfigSchema = z.strictObject({
410
421
  terminal: z
411
422
  .strictObject({ adapter: z.enum(Object.values(TERMINAL_ADAPTER)).default(TERMINAL_ADAPTER.none) })
412
423
  .prefault({}),
424
+ /** How the coordinator shows a draft for review (`orchestrate` § 4). */
425
+ preview: z
426
+ .strictObject({
427
+ /** Publish the draft as a private Artifact when the session has the tool; `false` sends a file card or `jh preview --open` instead. */
428
+ publish: z.boolean().default(true),
429
+ })
430
+ .prefault({}),
413
431
  plans: z
414
432
  .strictObject({
415
433
  /** Minutes between background fetches of the base branch while `jh herd` runs; 0 turns them off. */
@@ -430,6 +448,7 @@ export const jackherdConfigSchema = z.strictObject({
430
448
  /**
431
449
  * Read by `jh watch`; without `deploy` it reports no deploy runs. `feedback` is on unless set to `false`:
432
450
  * it reports new feedback on the lanes' pull requests except from the account the watcher runs as and `ignore`.
451
+ * `parallelTasks.maxTokens` caps the cost of the extra agents in a proposal of parallel tasks at the heartbeat.
433
452
  * `checks` reads the failed tests of a red lane pull request (`failedTestPattern`), also with `feedback` off.
434
453
  * Without `previews` it reads no deploy preview; with it, one `preview` per new head commit. Each of `sources` is a
435
454
  * command whose JSON output reports `source` for a new or changed entry.
@@ -439,6 +458,7 @@ export const jackherdConfigSchema = z.strictObject({
439
458
  deploy: deploySchema.optional(),
440
459
  feedback: z.union([z.literal(false), feedbackSchema]).prefault({}),
441
460
  checks: checksSchema.prefault({}),
461
+ parallelTasks: parallelTasksSchema.prefault({}),
442
462
  previews: previewsSchema.optional(),
443
463
  sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
444
464
  })
@@ -67,3 +67,15 @@ export const contextFill = (lines: readonly string[], windows: ContextWindows):
67
67
  const window = windowFor(last.model, highest, windows)
68
68
  return { tokens: last.tokens, window, share: Math.min(last.tokens / window, 1), model: last.model }
69
69
  }
70
+
71
+ /**
72
+ * What a fresh agent carries before it has worked: the input of the first main-chain answer in the lines, which holds
73
+ * the system prompt, the project instructions and the first request. Null when no line is a usable answer.
74
+ */
75
+ export const firstAnswerTokens = (lines: readonly string[]): number | null => {
76
+ for (const line of lines) {
77
+ const answer = answerOf(line)
78
+ if (answer) return answer.tokens
79
+ }
80
+ return null
81
+ }
@@ -2,6 +2,7 @@ import { closeSync, existsSync, fstatSync, openSync, readdirSync, readSync, stat
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { CLAUDE_DIRECTORY, SESSION_LOGS_DIRECTORY } from '../claude/paths'
5
+ import { isRecord } from '../shared/is-record'
5
6
 
6
7
  /** How much of the end of a log is read: logs grow to hundreds of megabytes, and the last answers are at the end. */
7
8
  export const TAIL_BYTES = 2 * 1024 * 1024
@@ -40,6 +41,20 @@ export const readTailLines = (path: string, bytes: number): string[] => {
40
41
  }
41
42
  }
42
43
 
44
+ /** The complete lines of the first `bytes` of a file: a cut-off last line is dropped, a file read to its end keeps its last one. */
45
+ export const readHeadLines = (path: string, bytes: number): string[] => {
46
+ const descriptor = openSync(path, 'r')
47
+ try {
48
+ const { size } = fstatSync(descriptor)
49
+ const buffer = Buffer.alloc(Math.min(size, bytes))
50
+ readSync(descriptor, buffer, 0, buffer.length, 0)
51
+ const lines = buffer.toString('utf8').split('\n')
52
+ return size > bytes ? lines.slice(0, -1) : lines
53
+ } finally {
54
+ closeSync(descriptor)
55
+ }
56
+ }
57
+
43
58
  /** The tail of a session's log, read only; null when the session has no log or it cannot be read. */
44
59
  export const sessionLog = (
45
60
  sessionId: string,
@@ -59,7 +74,7 @@ export type AgentSession = { session: string | null; cwd: string | null }
59
74
  const hasAnswer = (lines: readonly string[]): boolean => lines.some((line) => line.includes('"type":"assistant"'))
60
75
 
61
76
  /** Claude Code names a project directory after the working directory, `/` and `.` turned into `-`. */
62
- const projectDirectoryOf = (cwd: string): string => cwd.replace(/[/.]/g, '-')
77
+ export const projectDirectoryOf = (cwd: string): string => cwd.replace(/[/.]/g, '-')
63
78
 
64
79
  const newestLogOf = (cwd: string, projectsDirectory: string): string | null => {
65
80
  const directory = join(projectsDirectory, projectDirectoryOf(cwd))
@@ -89,3 +104,30 @@ export const agentLog = (
89
104
  return reported
90
105
  }
91
106
  }
107
+
108
+ /**
109
+ * The path of the log `agentLog` would read: the reported session's log when it holds an answer, else the newest log
110
+ * of the agent's working directory, else the reported one; null when there is none.
111
+ */
112
+ export const agentLogPath = (
113
+ { session, cwd }: AgentSession,
114
+ projectsDirectory: string = defaultProjectsDirectory(),
115
+ ): string | null => {
116
+ const reported = session ? sessionLogPath(session, projectsDirectory) : null
117
+ try {
118
+ if (reported && hasAnswer(readTailLines(reported, TAIL_BYTES))) return reported
119
+ return (cwd ? newestLogOf(cwd, projectsDirectory) : null) ?? reported
120
+ } catch {
121
+ return reported
122
+ }
123
+ }
124
+
125
+ /** One line of a session log as an object; null for a line that is no JSON or not an object. */
126
+ export const parseLogEntry = (line: string): Record<string, unknown> | null => {
127
+ try {
128
+ const entry: unknown = JSON.parse(line)
129
+ return isRecord(entry) ? entry : null
130
+ } catch {
131
+ return null
132
+ }
133
+ }
@@ -0,0 +1,4 @@
1
+ export const MS_PER_MINUTE = 60_000
2
+
3
+ /** A day, month, hour or minute with a leading zero: `7` → `07`. */
4
+ export const twoDigits = (value: number): string => String(value).padStart(2, '0')
@@ -1,5 +1,5 @@
1
1
  import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
- import { PHASE_ID_PREFIX, phaseLabels } from './lanes'
2
+ import { PHASE_ID_PREFIX, phaseLabels, spannedPhaseIds } from './lanes'
3
3
  import { escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
4
4
  import { type PhaseHeading, parsePlanPhases, phaseHeadings } from './phases'
5
5
  import { isPhaseFinished } from './progress'
@@ -40,7 +40,6 @@ const VERSION = /^v?\d+(?:[.\-+][\w.-]*)?$/i
40
40
  const PACKAGE_SCOPE = /^@[^/]+\//
41
41
  /** The files a release of the package in their directory changes: the changelog and the version in the manifest. */
42
42
  const RELEASE_FILE = /(?:^|\/)(?:CHANGELOG\.md|package\.json)$/
43
- const MAX_RANGE = 50
44
43
 
45
44
  const phaseIdOf = (cell: string): string | null => PHASE_ID.exec(cell)?.[1]?.toLowerCase() ?? null
46
45
 
@@ -63,9 +62,7 @@ const splitOutsideParentheses = (cell: string, separator: string): string[] => {
63
62
  const rangeOf = (entry: string): string[] | null => {
64
63
  const match = PHASE_RANGE.exec(entry)
65
64
  if (!match) return null
66
- const [first, last] = [Number(match[1]), Number(match[2])]
67
- if (first > last || last - first >= MAX_RANGE) return []
68
- return Array.from({ length: last - first + 1 }, (_, offset) => String(first + offset))
65
+ return spannedPhaseIds(Number(match[1]), Number(match[2]))
69
66
  }
70
67
 
71
68
  const dependsOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency, 'after' | 'external'> => {
@@ -32,6 +32,15 @@ export const PHASE_ID_PREFIX = 'P'
32
32
  /** A phase as plans and reports name it: `P4`. */
33
33
  export const phaseLabel = (id: string): string => `${PHASE_ID_PREFIX}${id}`
34
34
 
35
+ /** The widest range a cell may span; a wider one is a typo, not a plan. */
36
+ const MAX_RANGE = 50
37
+
38
+ /** The ids from `first` to `last`, both included; empty when the range runs backwards or spans `MAX_RANGE` or more. */
39
+ export const spannedPhaseIds = (first: number, last: number): string[] =>
40
+ first > last || last - first >= MAX_RANGE
41
+ ? []
42
+ : Array.from({ length: last - first + 1 }, (_, offset) => String(first + offset))
43
+
35
44
  /** Phases as plans and reports name them, joined by `separator`: `P3 P4`, `P19 ∥ P20`. */
36
45
  export const phaseLabels = (ids: readonly string[], separator: string): string => ids.map(phaseLabel).join(separator)
37
46
 
@@ -39,22 +48,32 @@ const PHASES_COLUMN = 3
39
48
  const CLAUSE_SEPARATOR = /([;,]|\.(?=\s))\s*/
40
49
  const ENTRY_END = new Set([';', '.'])
41
50
 
42
- type PhaseIdPatterns = { leading: RegExp; afterConnector: RegExp; connector: RegExp }
51
+ type PhaseIdPatterns = { leading: RegExp; rangeEnd: RegExp; afterConnector: RegExp; connector: RegExp }
43
52
 
44
53
  const phaseIdPatterns = (language: PlanLanguage): PhaseIdPatterns => {
45
54
  const connectors = PLAN_VOCABULARY[language].phaseConnectors.join('|')
46
55
  const id = `(?:\\*\\*)?${PHASE_ID_PREFIX}(\\d+[a-z]?)\\b`
47
56
  return {
48
57
  leading: new RegExp(`^\\s*(?:(?:${connectors})\\s+)*${id}`),
58
+ rangeEnd: new RegExp(`^\\s*[–-]\\s*(?:\\*\\*)?${PHASE_ID_PREFIX}(\\d+)\\b`),
49
59
  afterConnector: new RegExp(`^\\s*(?:${connectors})\\s`),
50
60
  connector: new RegExp(`\\b(?:${connectors})\\s+${id}`, 'g'),
51
61
  }
52
62
  }
53
63
 
64
+ /** The ids an entry starting with `leading` names: the one id, or every id of a range `P17–P25` it opens. */
65
+ const leadingIds = (clause: string, leading: RegExpMatchArray, patterns: PhaseIdPatterns): string[] => {
66
+ const first = leading[1]
67
+ const end = clause.slice(leading[0].length).match(patterns.rangeEnd)
68
+ if (!end || !/^\d+$/.test(first)) return [first]
69
+ const spanned = spannedPhaseIds(Number(first), Number(end[1]))
70
+ return spanned.length > 0 ? spanned : [first]
71
+ }
72
+
54
73
  /**
55
74
  * The phase ids of a phases cell. An id counts where an entry starts: at the start of the cell or of a
56
75
  * sentence, after `;`, after a connector word (`P1 Index und P2 Suche`), or after `,` while a list that began
57
- * with an id is running (`P1 (#<number>), P2 (#<number>)`). A `P6` inside a sentence (`geht in P6 auf`, `abgeräumt,
76
+ * with an id is running (`P1 (#<number>), P2 (#<number>)`); an entry `P17–P25` names every phase it spans. A `P6` inside a sentence (`geht in P6 auf`, `abgeräumt,
58
77
  * P6 startet neu`) is prose.
59
78
  */
60
79
  const phaseIdsOf = (cell: string, patterns: PhaseIdPatterns): string[] => {
@@ -69,7 +88,7 @@ const phaseIdsOf = (cell: string, patterns: PhaseIdPatterns): string[] => {
69
88
  const leading = clause.match(patterns.leading)
70
89
  const startsEntry: boolean = entryStart || listRunning || patterns.afterConnector.test(clause)
71
90
  const counts: boolean = leading !== null && startsEntry
72
- if (leading && counts) ids.push(leading[1])
91
+ if (leading && counts) ids.push(...leadingIds(clause, leading, patterns))
73
92
  ids.push(...[...clause.matchAll(patterns.connector)].map((match) => match[1]))
74
93
  listRunning ||= counts
75
94
  }
@@ -111,7 +130,8 @@ export const withoutRepeats = (lane: SprintLane, step: PlanStep): PlanStep => {
111
130
  /**
112
131
  * The lanes of the sprint document's lanes table (`## Spuren` in `de`) in table order. A row whose
113
132
  * first cell names a lane in backticks belongs to that lane; a `↳` row without a name continues
114
- * the lane above it.
133
+ * the lane above it. A cleaned-up lane stays closed through its `↳` rows, but a later row that names it again
134
+ * without the mark starts the lane anew: the closed rows are history and drop out.
115
135
  */
116
136
  export const parseSprintLanes = (markdown: string, language: PlanLanguage): SprintLane[] => {
117
137
  const lanes = new Map<string, SprintLane>()
@@ -123,7 +143,12 @@ export const parseSprintLanes = (markdown: string, language: PlanLanguage): Spri
123
143
  if (!name) continue
124
144
  const port = Number.parseInt(cells[1] ?? '', 10)
125
145
  const lane = lanes.get(name) ?? { name, port: Number.isNaN(port) ? null : port, steps: [], closed: false }
126
- lane.closed ||= (cells[0] ?? '').includes(laneClosed)
146
+ const marked = (cells[0] ?? '').includes(laneClosed)
147
+ if (lane.closed && !marked && !(cells[0] ?? '').startsWith(CONTINUATION)) {
148
+ lane.closed = false
149
+ lane.steps = []
150
+ }
151
+ lane.closed ||= marked
127
152
  lane.steps.push(withoutRepeats(lane, toStep(cells, phaseId)))
128
153
  lanes.set(name, lane)
129
154
  previous = name
@@ -26,3 +26,7 @@ const UNSAFE_TERMINAL_TEXT = new RegExp(
26
26
  * on the terminal. Apply it where such text enters, before any styling is added.
27
27
  */
28
28
  export const terminalSafe = (text: string): string => text.replace(UNSAFE_TERMINAL_TEXT, '')
29
+
30
+ /** The text cut to `max` characters with an ellipsis as the last one; text that fits stays as it is. */
31
+ export const ellipsize = (text: string, max: number): string =>
32
+ text.length > max ? `${text.slice(0, max - 1)}…` : text
@@ -0,0 +1,48 @@
1
+ import { type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
2
+ import { firstTableWithHeader, sectionLines } from './markdown'
3
+ import { terminalSafe } from './terminal-text'
4
+
5
+ /** One row of the board's section of what waits on the user: when it began (as written), what it is, whether it is open. */
6
+ export type WaitingItem = {
7
+ since: string
8
+ item: string
9
+ open: boolean
10
+ /** A `file:` address the item links to, for a sent file; absent for a row of the board. */
11
+ url?: string
12
+ /** `file` for a file or an Artifact that was sent; absent for a question of the board. */
13
+ kind?: 'file'
14
+ /** The id of the record that closes it (`jh waiting close`); not shown. */
15
+ id?: string
16
+ }
17
+
18
+ const oneLine = (text: string): string => terminalSafe(text).replace(/\s+/g, ' ').trim()
19
+
20
+ /** An empty status counts as open: a row nobody closed is not dropped in silence. */
21
+ const isOpen = (status: string, language: PlanLanguage): boolean => {
22
+ const lower = status.toLowerCase()
23
+ return lower === '' || WAITING_VOCABULARY[language].open.some((word) => lower.startsWith(word))
24
+ }
25
+
26
+ /**
27
+ * The rows of the board's waiting section in `language` (`## Wartet auf dich`, `## Waiting for you`), its columns
28
+ * found by their header names in any order; the origin column is not read. A table without the since, item or status
29
+ * column, a section without a table and a row without an item give nothing.
30
+ */
31
+ export const readWaitingItems = (board: string, language: PlanLanguage): WaitingItem[] => {
32
+ const { heading, columns, statusAlso } = WAITING_VOCABULARY[language]
33
+ const { header, rows } = firstTableWithHeader(sectionLines(board, heading))
34
+ const statusName = [columns.status, ...statusAlso].find((name) => header.includes(name)) ?? columns.status
35
+ const positions = [columns.since, columns.item, statusName].map((name) => header.indexOf(name))
36
+ if (positions.includes(-1)) return []
37
+ const [since, item, status] = positions
38
+ return rows.flatMap((cells) => {
39
+ const text = oneLine(cells[item] ?? '')
40
+ return text === ''
41
+ ? []
42
+ : [{ since: oneLine(cells[since] ?? ''), item: text, open: isOpen(cells[status] ?? '', language) }]
43
+ })
44
+ }
45
+
46
+ /** The open items, in the order of the board. */
47
+ export const openWaitingItems = (board: string, language: PlanLanguage): WaitingItem[] =>
48
+ readWaitingItems(board, language).filter(({ open }) => open)
@@ -0,0 +1,60 @@
1
+ import { sep } from 'node:path'
2
+ import { terminalSafe } from '../plan-format/terminal-text'
3
+ import { matchSignals, type OpenPlanTask } from '../task-signals/matcher'
4
+ import type { TaskSignal } from '../task-signals/types'
5
+ import type { TaskPhase } from './current-task'
6
+
7
+ /**
8
+ * A task an agent is working on: its index among the phase's checkboxes, who works on it (null while only one agent
9
+ * is active in the lane), whether the herd only guesses it from words, and when the agent began it (null for a guess).
10
+ */
11
+ export type ActiveTask = { index: number; agent: string | null; guessed: boolean; startedAt: number | null }
12
+
13
+ /** A task an agent reports that no open task of the plan names. */
14
+ export type UnplannedTask = { text: string; agent: string | null }
15
+
16
+ export type Assignment = { byPhase: ReadonlyMap<string, ActiveTask[]>; unplanned: UnplannedTask[] }
17
+
18
+ const openTasksOf = (phases: readonly TaskPhase[]): OpenPlanTask[] =>
19
+ phases.flatMap(({ id, items }) =>
20
+ items.flatMap((item, taskIndex) => (item.done ? [] : [{ phaseId: id, taskIndex, text: item.text }])),
21
+ )
22
+
23
+ /**
24
+ * Lays the reported `signals` over the plan's open tasks: each task an agent named is active, grouped by phase in the
25
+ * order of the signals, two agents on one task share it; a signal that names no open task is unplanned. Agent text is
26
+ * made terminal safe here, and the agent is named only when more than one agent reports.
27
+ */
28
+ export const assignSignals = (phases: readonly TaskPhase[], signals: readonly TaskSignal[]): Assignment => {
29
+ const named = new Set(signals.map(({ agent }) => terminalSafe(agent))).size > 1
30
+ const nameOf = (agent: string): string | null => (named ? terminalSafe(agent) : null)
31
+ const byPhase = new Map<string, ActiveTask[]>()
32
+ const unplanned: UnplannedTask[] = []
33
+ for (const { signal, task } of matchSignals(openTasksOf(phases), signals)) {
34
+ if (task === null) {
35
+ unplanned.push({ text: terminalSafe(signal.text), agent: nameOf(signal.agent) })
36
+ continue
37
+ }
38
+ const tasks = byPhase.get(task.phaseId) ?? []
39
+ const shared = tasks.find(({ index }) => index === task.taskIndex)
40
+ const agent = nameOf(signal.agent)
41
+ if (shared) shared.agent = shared.agent && agent ? `${shared.agent}, ${agent}` : shared.agent
42
+ else tasks.push({ index: task.taskIndex, agent, guessed: false, startedAt: signal.startedAt })
43
+ byPhase.set(task.phaseId, tasks)
44
+ }
45
+ return { byPhase, unplanned }
46
+ }
47
+
48
+ /**
49
+ * The signals still believed: a `jh task` record counts only while it is younger than the lane's last tick, since a
50
+ * ticked box is the better witness that the agent moved on; Claude's own task list closes itself. Without a tick, all stay.
51
+ */
52
+ export const freshSignals = (signals: readonly TaskSignal[], lastTickAt: number | null): TaskSignal[] =>
53
+ signals.filter(({ source, startedAt }) => source !== 'jh' || lastTickAt === null || startedAt >= lastTickAt)
54
+
55
+ /** The lane a directory belongs to: the first directory below `worktrees` it lies in; null anywhere else. */
56
+ export const laneOfDirectory = (directory: string, worktrees: string): string | null => {
57
+ const prefix = worktrees.endsWith(sep) ? worktrees : `${worktrees}${sep}`
58
+ if (!directory.startsWith(prefix)) return null
59
+ return directory.slice(prefix.length).split(sep)[0] || null
60
+ }
@@ -6,7 +6,7 @@ export type PhaseRef = { planHref: string; phaseId: string }
6
6
  /** Per plan link and phase id, the state of its checkboxes as `1` (ticked) and `0` (open) in plan order. */
7
7
  export type TickSnapshot = Record<string, Record<string, string>>
8
8
 
9
- /** When a phase's ticks or task lines last moved, in milliseconds, by `activityKey`. */
9
+ /** When a phase was last ticked or unticked, in milliseconds, by `activityKey`. */
10
10
  export type ActivityTimes = Readonly<Record<string, number>>
11
11
 
12
12
  /**
@@ -54,20 +54,30 @@ export const movedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRe
54
54
  }),
55
55
  )
56
56
 
57
- /** The phases `next` has in a plan `previous` read already but without them: task lines a lane wrote into a new phase. */
58
- const addedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRef[] =>
57
+ const tickCount = (ticks: string): number => [...ticks].filter((state) => state === '1').length
58
+
59
+ /**
60
+ * The phases whose number of ticks changed between two reads of a plan `previous` read already, a phase new in `next`
61
+ * counted from none: a tick is activity, task lines written into a phase are not, so phases a lane only wrote down
62
+ * never run (17:10). A phase remembered in the format before D67 has no state to move from.
63
+ */
64
+ const tickedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRef[] =>
59
65
  Object.entries(next).flatMap(([planHref, phases]) => {
60
66
  const before = previous[planHref]
61
67
  if (before === undefined) return []
62
- return Object.keys(phases).flatMap((phaseId) => (phaseId in before ? [] : [{ planHref, phaseId }]))
68
+ return Object.entries(phases).flatMap(([phaseId, ticks]) => {
69
+ const old = before[phaseId]
70
+ if (old !== undefined && !old.startsWith(TASK_TICKS)) return []
71
+ return tickCount(old ?? '') === tickCount(ticks) ? [] : [{ planHref, phaseId }]
72
+ })
63
73
  })
64
74
 
65
75
  /** When each plan file was last written, in milliseconds, by plan link; a plan without an entry is bounded by the clock. */
66
76
  export type PlanModified = Readonly<Record<string, number>>
67
77
 
68
78
  /**
69
- * When each phase last moved, after a read at `now` whose ticks are `ticks`: a phase whose ticks or task lines changed
70
- * since `previous`, or that is new in a plan `previous` read already, takes the time its plan file was last written
79
+ * When each phase was last ticked, after a read at `now` whose ticks are `ticks`: a phase whose number of ticks changed
80
+ * since `previous` (`tickedPhases`), a new one in a plan `previous` read already included, takes the time its plan file was last written
71
81
  * (`modified`), bounded by `now`, so a change first seen long after it was made is not taken for a fresh one; the
72
82
  * others keep their time. The first read knows no movement, so without a remembered read no phase has a time.
73
83
  */
@@ -78,7 +88,7 @@ export const nextActivity = (
78
88
  modified: PlanModified = {},
79
89
  ): ActivityTimes => {
80
90
  if (!previous) return {}
81
- const changed = [...movedPhases(previous.ticks, ticks), ...addedPhases(previous.ticks, ticks)]
91
+ const changed = tickedPhases(previous.ticks, ticks)
82
92
  return {
83
93
  ...previous.activity,
84
94
  ...Object.fromEntries(
@@ -1,10 +1,12 @@
1
+ import { parseLogEntry } from '../context/session-log'
1
2
  import type { PlanPhase } from '../plan-format'
3
+ import { isRecord } from '../shared/is-record'
2
4
 
3
5
  /**
4
6
  * A hint that a phase's task is being worked on: `taskIndex` is the task's index in the phase's checkboxes, `at` when
5
- * it was seen, in milliseconds. No source writes one yet, so the heuristic of `currentTasks` decides alone.
7
+ * it was seen, in milliseconds. `taskSignalsOf` derives them from what the lane's agents did lately.
6
8
  */
7
- export type TaskSignal = { phaseId: string; taskIndex: number; at: number }
9
+ export type TaskActivity = { phaseId: string; taskIndex: number; at: number }
8
10
 
9
11
  /** A phase as `currentTasks` reads it: its id and checkboxes. */
10
12
  export type TaskPhase = Pick<PlanPhase, 'id' | 'items'>
@@ -14,14 +16,17 @@ const isOpenTask = (phase: TaskPhase, index: number): boolean => phase.items[ind
14
16
  /** The task the newest signal younger than `staleAfterMs` points at, when it is still open; null otherwise. */
15
17
  const signalledTask = (
16
18
  phase: TaskPhase,
17
- signals: readonly TaskSignal[],
19
+ signals: readonly TaskActivity[],
18
20
  now: number,
19
21
  staleAfterMs: number,
20
22
  ): number | null => {
21
23
  const fresh = signals
22
24
  .filter((signal) => signal.phaseId === phase.id && now - signal.at < staleAfterMs)
23
25
  .filter((signal) => isOpenTask(phase, signal.taskIndex))
24
- const newest = fresh.reduce<TaskSignal | null>((last, signal) => (last && last.at >= signal.at ? last : signal), null)
26
+ const newest = fresh.reduce<TaskActivity | null>(
27
+ (last, signal) => (last && last.at >= signal.at ? last : signal),
28
+ null,
29
+ )
25
30
  return newest?.taskIndex ?? null
26
31
  }
27
32
 
@@ -38,7 +43,7 @@ const firstOpenTask = ({ items }: TaskPhase): number => {
38
43
  */
39
44
  export const currentTasks = (
40
45
  plan: readonly TaskPhase[],
41
- signals: readonly TaskSignal[],
46
+ signals: readonly TaskActivity[],
42
47
  now: number,
43
48
  staleAfterMs: number,
44
49
  ): ReadonlyMap<string, number> =>
@@ -49,3 +54,56 @@ export const currentTasks = (
49
54
  return [[phase.id, signalledTask(phase, signals, now, staleAfterMs) ?? first] as const]
50
55
  }),
51
56
  )
57
+
58
+ const WORD = /[a-zäöüß][a-zäöüß0-9-]{5,}/g
59
+ /** The share of a task's distinctive words the agent used, from which on it counts as working on that task. */
60
+ const SIGNAL_SHARE = 0.4
61
+ const SIGNAL_MIN_WORDS = 3
62
+ /** Only the start of a task's text is read: the rest is reasons and origin. */
63
+ const TASK_TEXT_LENGTH = 200
64
+
65
+ const wordsOf = (text: string): Set<string> => new Set(text.toLowerCase().match(WORD) ?? [])
66
+
67
+ /**
68
+ * The signal each phase gets from what an agent of the lane did lately (`activity`, see `activityTextOf`): the open
69
+ * task of which the agent used at least `SIGNAL_SHARE` of the distinctive words (six letters or more, from the first
70
+ * 200 characters) and at least three, and clearly the most of all open tasks of the phase. Measured against the logs of
71
+ * the lane's agents: the prompt alone names several tasks, the words of the last entries single one out; the first open
72
+ * task stays the fallback when no task leads.
73
+ */
74
+ export const taskSignalsOf = (plan: readonly TaskPhase[], activity: string, at: number): TaskActivity[] => {
75
+ const used = wordsOf(activity)
76
+ if (used.size === 0) return []
77
+ return plan.flatMap((phase) => {
78
+ const scored = phase.items.flatMap((item, taskIndex) => {
79
+ if (item.done) return []
80
+ const words = [...wordsOf(item.text.slice(0, TASK_TEXT_LENGTH))]
81
+ const hits = words.filter((word) => used.has(word)).length
82
+ return words.length > 0 && hits >= SIGNAL_MIN_WORDS && hits / words.length >= SIGNAL_SHARE
83
+ ? [{ taskIndex, hits }]
84
+ : []
85
+ })
86
+ const [best, second] = scored.sort((left, right) => right.hits - left.hits)
87
+ return best && best.hits > (second?.hits ?? 0) ? [{ phaseId: phase.id, taskIndex: best.taskIndex, at }] : []
88
+ })
89
+ }
90
+
91
+ const contentTexts = (content: unknown): string[] => {
92
+ if (typeof content === 'string') return [content]
93
+ if (!Array.isArray(content)) return []
94
+ return content.flatMap((block) => {
95
+ if (typeof block !== 'object' || block === null) return []
96
+ const { text, input } = block as { text?: unknown; input?: unknown }
97
+ return [typeof text === 'string' ? text : '', input === undefined ? '' : JSON.stringify(input)]
98
+ })
99
+ }
100
+
101
+ /** The text of an agent's last `entries` log entries (messages and tool inputs), for `taskSignalsOf`; lines that are no JSON are skipped. */
102
+ export const activityTextOf = (lines: readonly string[], entries: number): string =>
103
+ lines
104
+ .slice(-entries)
105
+ .flatMap((line) => {
106
+ const message = parseLogEntry(line)?.message
107
+ return isRecord(message) ? contentTexts(message.content) : []
108
+ })
109
+ .join(' ')
@@ -45,15 +45,20 @@ const hasOpenWork = (progress: StandingInput['progress']): boolean => {
45
45
  }
46
46
 
47
47
  /**
48
- * Where a lane stands on the board. The cleaned-up mark goes first: it closes a lane, unless an agent, a worktree
49
- * or a pull request is still there, which keeps it listed. Then an agent, an open pull request or
50
- * uncommitted changes in its worktree make a lane active (a small fix has no agent in herdr) and a worktree alone lists it. Without a worktree a lane waits while it has open work, else it is closed.
48
+ * Where a lane stands on the board. An agent, an open pull request or uncommitted changes in its worktree make a
49
+ * lane active (a small fix has no agent in herdr). The cleaned-up mark goes first, but uncommitted changes in the
50
+ * worktree beat it: work is going on, whatever the board says. Otherwise the mark closes a lane, unless an agent, a
51
+ * worktree or a pull request is still there, which keeps it listed. A worktree alone lists a lane. Without a
52
+ * worktree a lane waits while it has open work, else it is closed.
51
53
  */
52
54
  export const laneStanding = (input: StandingInput): LaneStanding => {
53
55
  const { progress, status, pullRequests } = input
54
56
  const working = status.agent !== null || pullRequests.length > 0
55
57
  const editedInWorktree = status.branch !== null && status.dirty > 0
56
- if (progress.lane.closed) return working || status.branch !== null ? LANE_STANDING.noAgent : LANE_STANDING.closed
58
+ if (progress.lane.closed) {
59
+ if (editedInWorktree) return LANE_STANDING.active
60
+ return working || status.branch !== null ? LANE_STANDING.noAgent : LANE_STANDING.closed
61
+ }
57
62
  if (working || editedInWorktree) return LANE_STANDING.active
58
63
  if (status.branch !== null) return LANE_STANDING.noAgent
59
64
  return hasOpenWork(progress) ? LANE_STANDING.waiting : LANE_STANDING.closed