@erclx/aitk 0.63.0 → 0.63.2

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.
@@ -1,7 +1,14 @@
1
1
  import { relative } from 'node:path'
2
- import { $ } from 'bun'
3
2
  import type { Command } from 'commander'
4
3
  import { type ArchiveOutcome, archiveTask } from '@/tasks/archive'
4
+ import {
5
+ type CloseOutcome,
6
+ closeOutcomes,
7
+ type PullRequestOutcome,
8
+ type RecordRefused,
9
+ type RecordSelector,
10
+ recordPullRequest,
11
+ } from '@/tasks/record'
5
12
  import {
6
13
  type Finding,
7
14
  type ValidateOutcome,
@@ -18,6 +25,7 @@ import {
18
25
  outro,
19
26
  pipeOutput,
20
27
  } from '@/ui'
28
+ import { mainWorktreeRoot } from '@/worktree'
21
29
 
22
30
  /** Returned when the board carries a finding, which is the gating result. */
23
31
  const EXIT_FINDINGS = 2
@@ -33,22 +41,17 @@ interface ValidateCommandOptions {
33
41
  readonly root?: string
34
42
  }
35
43
 
36
- /**
37
- * The board is shared scratch at the main worktree root, and `git worktree
38
- * list` puts that root first. A pull inside a linked worktree fires the same
39
- * hook, so trusting the working directory would write a second board nothing
40
- * else reads.
41
- */
42
- async function mainWorktreeRoot(): Promise<string> {
43
- const result = await $`git worktree list --porcelain`.quiet().nothrow()
44
- if (result.exitCode !== 0) return process.cwd()
45
-
46
- const line = result.stdout
47
- .toString()
48
- .split('\n')
49
- .find((entry) => entry.startsWith('worktree '))
44
+ interface PullRequestCommandOptions {
45
+ readonly json?: boolean
46
+ readonly plan?: string
47
+ readonly root?: string
48
+ }
50
49
 
51
- return line ? line.slice('worktree '.length).trim() : process.cwd()
50
+ interface OutcomeCommandOptions {
51
+ readonly close?: readonly string[]
52
+ readonly json?: boolean
53
+ readonly plan?: string
54
+ readonly root?: string
52
55
  }
53
56
 
54
57
  export function register(program: Command): void {
@@ -121,6 +124,298 @@ export function register(program: Command): void {
121
124
  .action(async (opts: ValidateCommandOptions) => {
122
125
  process.exitCode = await runValidate(opts)
123
126
  })
127
+
128
+ tasks
129
+ .command('pull-request')
130
+ .description('Record a pull request number on the task a branch closes')
131
+ .argument('<number>', 'Pull request number, without the #')
132
+ .argument('[task]', 'Task filename stem, as in v28.1-trigger-escalation')
133
+ .helpOption('-h, --help', 'Show this help message')
134
+ .option('--plan <slug>', 'Select the task whose Plan line names this plan')
135
+ .option('--json', 'Emit a machine-readable record on stdout')
136
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
137
+ .addHelpText(
138
+ 'after',
139
+ [
140
+ '',
141
+ 'Exit codes:',
142
+ ' 0 the line was added, corrected, or already correct',
143
+ ' 1 refused, with the reason on stderr or in the JSON record',
144
+ '',
145
+ 'It adds Pull request: #NNN under the origin lines the task carries,',
146
+ 'and corrects the number in place when the line exists. The board is',
147
+ 'shared scratch, so a linked worktree records against the same board.',
148
+ '',
149
+ 'Examples:',
150
+ ' aitk tasks pull-request 673 v28.1-trigger-escalation',
151
+ ' aitk tasks pull-request 673 --plan worktree-scratch-routing --json',
152
+ '',
153
+ ].join('\n'),
154
+ )
155
+ .action(
156
+ async (
157
+ number: string,
158
+ task: string | undefined,
159
+ opts: PullRequestCommandOptions,
160
+ ) => {
161
+ process.exitCode = await runPullRequest(number, task, opts)
162
+ },
163
+ )
164
+
165
+ tasks
166
+ .command('outcome')
167
+ .description('Mark outcomes closed on a task by their position')
168
+ .argument('[task]', 'Task filename stem, as in v28.1-trigger-escalation')
169
+ .helpOption('-h, --help', 'Show this help message')
170
+ .option(
171
+ '--close <position>',
172
+ 'Outcome to mark [x], 1-based, repeatable',
173
+ collectPosition,
174
+ [] as string[],
175
+ )
176
+ .option('--plan <slug>', 'Select the task whose Plan line names this plan')
177
+ .option('--json', 'Emit a machine-readable record on stdout')
178
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
179
+ .addHelpText(
180
+ 'after',
181
+ [
182
+ '',
183
+ 'Exit codes:',
184
+ ' 0 every named outcome is closed',
185
+ ' 1 refused, with the reason on stderr or in the JSON record',
186
+ '',
187
+ 'Positions count every outcome checkbox in file order, starting at 1.',
188
+ 'An outcome already closed is reported rather than refused, so a rerun',
189
+ 'against the same positions is safe.',
190
+ '',
191
+ 'Examples:',
192
+ ' aitk tasks outcome v28.1-trigger-escalation --close 1 --close 3',
193
+ ' aitk tasks outcome --plan worktree-scratch-routing --close 2 --json',
194
+ '',
195
+ ].join('\n'),
196
+ )
197
+ .action(async (task: string | undefined, opts: OutcomeCommandOptions) => {
198
+ process.exitCode = await runOutcome(task, opts)
199
+ })
200
+ }
201
+
202
+ function collectPosition(value: string, previous: string[]): string[] {
203
+ return [...previous, value]
204
+ }
205
+
206
+ /**
207
+ * Both record verbs name a task the same two ways, and naming it neither way or
208
+ * both ways is the same refusal in each. Both are `bad-input` rather than
209
+ * `ambiguous` or `no-match`, since those two describe the board and these
210
+ * describe the command line that reached it.
211
+ */
212
+ function selectorFor(
213
+ task: string | undefined,
214
+ plan: string | undefined,
215
+ ): RecordSelector | RecordRefused {
216
+ if (task && plan) {
217
+ return {
218
+ ok: false,
219
+ reason: 'bad-input',
220
+ message: 'Name a task or a plan, not both.',
221
+ detail: [],
222
+ }
223
+ }
224
+
225
+ if (task) return { kind: 'stem', stem: task }
226
+ if (plan) return { kind: 'plan', plan }
227
+
228
+ return {
229
+ ok: false,
230
+ reason: 'bad-input',
231
+ message: 'No task named. Pass a filename stem or --plan <slug>.',
232
+ detail: [],
233
+ }
234
+ }
235
+
236
+ async function runPullRequest(
237
+ number: string,
238
+ task: string | undefined,
239
+ opts: PullRequestCommandOptions,
240
+ ): Promise<number> {
241
+ const emitJson = opts.json ?? false
242
+ const selector = selectorFor(task, opts.plan)
243
+
244
+ if ('ok' in selector) {
245
+ return reportRecord(
246
+ 'aitk tasks pull-request',
247
+ selector,
248
+ emitJson,
249
+ process.cwd(),
250
+ )
251
+ }
252
+
253
+ if (!/^\d+$/.test(number)) {
254
+ return reportRecord(
255
+ 'aitk tasks pull-request',
256
+ {
257
+ ok: false,
258
+ reason: 'bad-input',
259
+ message: `Not a pull request number: ${number}`,
260
+ detail: [],
261
+ },
262
+ emitJson,
263
+ process.cwd(),
264
+ )
265
+ }
266
+
267
+ const root = opts.root ?? (await mainWorktreeRoot())
268
+ const outcome = await recordPullRequest(root, selector, Number(number))
269
+
270
+ return reportPullRequest(outcome, emitJson, root)
271
+ }
272
+
273
+ async function runOutcome(
274
+ task: string | undefined,
275
+ opts: OutcomeCommandOptions,
276
+ ): Promise<number> {
277
+ const emitJson = opts.json ?? false
278
+ const selector = selectorFor(task, opts.plan)
279
+
280
+ if ('ok' in selector) {
281
+ return reportRecord('aitk tasks outcome', selector, emitJson, process.cwd())
282
+ }
283
+
284
+ const raw = opts.close ?? []
285
+
286
+ if (raw.length === 0) {
287
+ return reportRecord(
288
+ 'aitk tasks outcome',
289
+ {
290
+ ok: false,
291
+ reason: 'bad-input',
292
+ message: 'No outcome named. Pass --close <position>.',
293
+ detail: [],
294
+ },
295
+ emitJson,
296
+ process.cwd(),
297
+ )
298
+ }
299
+
300
+ const invalid = raw.filter((value) => !/^\d+$/.test(value))
301
+
302
+ if (invalid.length > 0) {
303
+ return reportRecord(
304
+ 'aitk tasks outcome',
305
+ {
306
+ ok: false,
307
+ reason: 'bad-input',
308
+ message: `Not an outcome position: ${invalid.join(', ')}`,
309
+ detail: invalid,
310
+ },
311
+ emitJson,
312
+ process.cwd(),
313
+ )
314
+ }
315
+
316
+ const root = opts.root ?? (await mainWorktreeRoot())
317
+ const outcome = await closeOutcomes(root, selector, raw.map(Number))
318
+
319
+ return reportOutcome(outcome, emitJson, root)
320
+ }
321
+
322
+ function reportRecord(
323
+ title: string,
324
+ refused: RecordRefused,
325
+ emitJson: boolean,
326
+ root: string,
327
+ ): number {
328
+ // The framed branch below already reaches stderr through logError, so the
329
+ // bare write is what keeps the JSON mode from reporting the reason on stdout
330
+ // alone.
331
+ if (emitJson) {
332
+ process.stderr.write(`${refused.message}\n`)
333
+ process.stdout.write(
334
+ `${JSON.stringify({
335
+ ok: false,
336
+ root,
337
+ reason: refused.reason,
338
+ message: refused.message,
339
+ detail: refused.detail,
340
+ })}\n`,
341
+ )
342
+ return 1
343
+ }
344
+
345
+ intro(title)
346
+ logStep('Refused')
347
+ logError(refused.message)
348
+ if (refused.detail.length > 0) pipeOutput(refused.detail.join('\n'))
349
+ outro()
350
+
351
+ return 1
352
+ }
353
+
354
+ function reportPullRequest(
355
+ outcome: PullRequestOutcome,
356
+ emitJson: boolean,
357
+ root: string,
358
+ ): number {
359
+ if (!outcome.ok) {
360
+ return reportRecord('aitk tasks pull-request', outcome, emitJson, root)
361
+ }
362
+
363
+ if (emitJson) {
364
+ process.stdout.write(
365
+ `${JSON.stringify({
366
+ ok: true,
367
+ root,
368
+ task: outcome.stem,
369
+ path: relative(root, outcome.path),
370
+ pullRequest: outcome.number,
371
+ action: outcome.action,
372
+ })}\n`,
373
+ )
374
+ return 0
375
+ }
376
+
377
+ intro('aitk tasks pull-request')
378
+ logStep(outcome.action === 'unchanged' ? 'Already recorded' : 'Recorded')
379
+ logInfo(`${outcome.stem} names pull request #${outcome.number}`)
380
+ if (outcome.action !== 'unchanged') logAdd(relative(root, outcome.path))
381
+ outro()
382
+
383
+ return 0
384
+ }
385
+
386
+ function reportOutcome(
387
+ outcome: CloseOutcome,
388
+ emitJson: boolean,
389
+ root: string,
390
+ ): number {
391
+ if (!outcome.ok) {
392
+ return reportRecord('aitk tasks outcome', outcome, emitJson, root)
393
+ }
394
+
395
+ if (emitJson) {
396
+ process.stdout.write(
397
+ `${JSON.stringify({
398
+ ok: true,
399
+ root,
400
+ task: outcome.stem,
401
+ path: relative(root, outcome.path),
402
+ closed: outcome.closed,
403
+ alreadyClosed: outcome.alreadyClosed,
404
+ })}\n`,
405
+ )
406
+ return 0
407
+ }
408
+
409
+ intro('aitk tasks outcome')
410
+ logStep(outcome.closed.length > 0 ? 'Closed' : 'Nothing to close')
411
+
412
+ for (const closed of outcome.closed) logAdd(closed)
413
+ for (const already of outcome.alreadyClosed) logInfo(`${already} (already)`)
414
+
415
+ if (outcome.closed.length > 0) logInfo(relative(root, outcome.path))
416
+ outro()
417
+
418
+ return 0
124
419
  }
125
420
 
126
421
  async function runValidate(opts: ValidateCommandOptions): Promise<number> {
@@ -196,7 +491,7 @@ async function runArchive(
196
491
  return report(
197
492
  {
198
493
  ok: false,
199
- reason: 'ambiguous',
494
+ reason: 'bad-input',
200
495
  message: 'Name a task or a pull request, not both.',
201
496
  detail: [],
202
497
  },
@@ -209,7 +504,7 @@ async function runArchive(
209
504
  return report(
210
505
  {
211
506
  ok: false,
212
- reason: 'no-match',
507
+ reason: 'bad-input',
213
508
  message:
214
509
  'No task named. Pass a filename stem or --pull-request <number>.',
215
510
  detail: [],
@@ -225,7 +520,7 @@ async function runArchive(
225
520
  return report(
226
521
  {
227
522
  ok: false,
228
- reason: 'no-match',
523
+ reason: 'bad-input',
229
524
  message: `Not a pull request number: ${opts.pullRequest}`,
230
525
  detail: [],
231
526
  },
@@ -3,7 +3,13 @@ import { relative } from 'node:path'
3
3
  import type { AuditedFolder } from '@/context/folders'
4
4
  import { isStubSeed } from '@/seed-marker'
5
5
 
6
- /** Checkpoints quoted from `standards/context.md`. Neither is a cap. */
6
+ /**
7
+ * Checkpoints quoted from the standard stating each. Neither is a cap.
8
+ *
9
+ * Entry length rests on one entry per domain, which is a domain fact, so
10
+ * `standards/context.md` keeps it. Run depth reads the same over any markdown
11
+ * file, so `standards/markdown.md` states it at the attribute tier.
12
+ */
7
13
  export const LENGTH_CHECKPOINT = 150
8
14
  export const RUN_CHECKPOINT = 40
9
15
 
@@ -29,7 +35,7 @@ export const RENDER_WIDTH = 80
29
35
  export const PEER_BULLET_CHECKPOINT = 130
30
36
 
31
37
  /**
32
- * Characters a bullet carries before it holds more than the decision itself.
38
+ * Characters a top-level bullet carries before its overflow belongs in prose.
33
39
  *
34
40
  * Unlike the peer-list checkpoint above, this corpus has no gap behind the
35
41
  * number. Bullet weight decays smoothly from a median near 170 with the
@@ -95,25 +101,21 @@ const PROVENANCE: readonly { kind: ProvenanceKind; pattern: RegExp }[] = [
95
101
  *
96
102
  * `standards/context.md` opens its scope by handing diagrams and wireframes to
97
103
  * `diagrams.md` and `wireframes.md`, so a marker reported in either would cite
98
- * a rule that entry's own standard routes elsewhere. The length, depth, and
99
- * table checkpoints are quoted from the same standard and keep reaching every
100
- * audited folder, because a threshold on how far a reader travels generalizes
101
- * across entry types while a rule about what an entry may say does not.
104
+ * a rule that entry's own standard routes elsewhere. The length and table
105
+ * checkpoints are quoted from the same standard and keep reaching every audited
106
+ * folder, because a threshold on how far a reader travels generalizes across
107
+ * entry types while a rule about what an entry may say does not.
102
108
  *
103
- * Bullet weight sits on this side of the split with provenance, which the
104
- * measure alone would not predict. What decides it is whether the remedy is
105
- * actionable: subdividing a run and splitting a file mean something in any
106
- * entry, while moving an incident out of a bullet and keeping the decision
107
- * means nothing in a folder whose entries carry no decisions to keep. No
108
- * diagram entry declares `## Decisions` or `## Gotchas`, so the scope of the
109
- * rule is what narrows the finding rather than the shape of the number.
109
+ * What gates here is a rule only this standard states. Bullet weight does not,
110
+ * since `standards/markdown.md` owns that checkpoint across document types and
111
+ * its remedy sends the overflow to prose, which any entry type can act on.
112
+ * `standards/context.md` specializes that remedy for an entry carrying
113
+ * decisions, and specializing a rule narrows the advice rather than the measure.
110
114
  *
111
- * Restating the exclusion in the sibling standards was the alternative. It
112
- * duplicates one knowledge item across three surfaces, which the root
113
- * instruction file forbids, and pointing is not available because the surface
114
- * they would point at is the one disclaiming them. Should a diagram entry ever
115
- * accumulate narration, the escalation is an attribute standard owning the rule
116
- * across document types, not restoring this reach without an owner.
115
+ * Restating the exclusion in the sibling standards was the alternative for what
116
+ * gates here. It duplicates one knowledge item across three surfaces, which the
117
+ * root instruction file forbids, and pointing is not available because the
118
+ * surface they would point at is the one disclaiming them.
117
119
  */
118
120
  export const PROVENANCE_FOLDER = 'context'
119
121
 
@@ -159,7 +161,7 @@ export interface EntryReport {
159
161
  readonly catalogTables: readonly TableFinding[]
160
162
  /** Empty for an entry no standard bans a change narrative in. */
161
163
  readonly provenance: readonly ProvenanceFinding[]
162
- /** Empty for the same reason `provenance` is, and under the same folder. */
164
+ /** Measured in every audited folder, since an attribute standard states it. */
163
165
  readonly heavyBullets: readonly BulletFinding[]
164
166
  /**
165
167
  * Required sections this entry declares, in the standard's order, and empty
@@ -549,7 +551,7 @@ export function measureEntry(
549
551
  longestRunLine: run.line,
550
552
  catalogTables: catalogTables(lines),
551
553
  provenance: governsContent ? provenance(lines) : [],
552
- heavyBullets: governsContent ? heavyBullets(lines) : [],
554
+ heavyBullets: heavyBullets(lines),
553
555
  sections: governsContent ? declaredSections(lines) : [],
554
556
  stub: isStubSeed(source),
555
557
  }
@@ -15,6 +15,12 @@ const PLANS_DIR = join('.claude', 'plans')
15
15
  */
16
16
  export const RESERVED_STEMS = ['index', 'priority', 'session'] as const
17
17
 
18
+ /**
19
+ * `bad-input` describes the command line rather than the board, which is the
20
+ * split `record.ts` draws for the same reason. A caller naming two selectors
21
+ * answered as `ambiguous` sends a session to repair a task citation that is
22
+ * fine, so the three task verbs answer one mistake one way.
23
+ */
18
24
  export const ARCHIVE_REFUSALS = [
19
25
  'no-board',
20
26
  'no-match',
@@ -22,6 +28,7 @@ export const ARCHIVE_REFUSALS = [
22
28
  'no-outcomes',
23
29
  'open-outcomes',
24
30
  'plan-unswept',
31
+ 'bad-input',
25
32
  ] as const
26
33
 
27
34
  export type ArchiveRefusal = (typeof ARCHIVE_REFUSALS)[number]
@@ -61,6 +68,26 @@ export function archiveDir(root: string): string {
61
68
  return join(root, ARCHIVE_DIR)
62
69
  }
63
70
 
71
+ export const OUTCOME_PATTERN = /^- \[([ xX])\] ?(.*)$/
72
+
73
+ /**
74
+ * Marks the lines sitting inside a fenced block, the fence delimiters included.
75
+ * A checkbox in a sample a task displays is not an outcome the task claims, and
76
+ * counting one shifts every position after it. Every reader of the outcome list
77
+ * shares this so none of them can disagree about what the list holds.
78
+ */
79
+ export function fenceMask(lines: readonly string[]): boolean[] {
80
+ let inside = false
81
+
82
+ return lines.map((line) => {
83
+ if (/^\s*(?:```|~~~)/.test(line)) {
84
+ inside = !inside
85
+ return true
86
+ }
87
+ return inside
88
+ })
89
+ }
90
+
64
91
  /**
65
92
  * Splits a task's outcome list by checkbox state. The board format puts every
66
93
  * outcome at the top level of `## Outcomes`, so an anchored match is enough and
@@ -69,9 +96,13 @@ export function archiveDir(root: string): string {
69
96
  export function readOutcomes(text: string): TaskOutcomes {
70
97
  const open: string[] = []
71
98
  const closed: string[] = []
99
+ const lines = text.split('\n')
100
+ const fenced = fenceMask(lines)
101
+
102
+ for (const [index, line] of lines.entries()) {
103
+ if (fenced[index]) continue
72
104
 
73
- for (const line of text.split('\n')) {
74
- const match = /^- \[([ xX])\] ?(.*)$/.exec(line)
105
+ const match = OUTCOME_PATTERN.exec(line)
75
106
  if (!match) continue
76
107
 
77
108
  const [, box, body] = match
@@ -157,7 +188,7 @@ function isLivePlan(target: string, dir: string, root: string): boolean {
157
188
  )
158
189
  }
159
190
 
160
- async function listTaskStems(dir: string): Promise<string[]> {
191
+ export async function listTaskStems(dir: string): Promise<string[]> {
161
192
  const entries = await readdir(dir)
162
193
  const reserved: readonly string[] = RESERVED_STEMS
163
194