@chief-clancy/plan 0.1.0 → 0.3.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.
@@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest';
11
11
 
12
12
  const WORKFLOWS_DIR = fileURLToPath(new URL('.', import.meta.url));
13
13
 
14
- const EXPECTED_WORKFLOWS = ['board-setup.md', 'plan.md'];
14
+ const EXPECTED_WORKFLOWS = ['approve-plan.md', 'board-setup.md', 'plan.md'];
15
15
 
16
16
  describe('workflows directory structure', () => {
17
17
  it('contains exactly the expected workflow files', () => {
@@ -120,6 +120,56 @@ describe('board-setup workflow', () => {
120
120
  // plan.md content assertions
121
121
  // ---------------------------------------------------------------------------
122
122
 
123
+ // ---------------------------------------------------------------------------
124
+ // --from flag: parsing and validation
125
+ // ---------------------------------------------------------------------------
126
+
127
+ describe('--from flag parsing', () => {
128
+ const content = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
129
+
130
+ it('documents --from input mode in Step 2', () => {
131
+ expect(content).toContain('--from');
132
+ expect(content).toContain('local brief file');
133
+ });
134
+
135
+ it('cannot combine --from with ticket key', () => {
136
+ expect(content).toContain('Cannot use both a ticket reference and --from');
137
+ });
138
+
139
+ it('cannot combine --from with batch mode', () => {
140
+ expect(content).toContain('Cannot use batch mode with --from');
141
+ });
142
+
143
+ it('validates file exists', () => {
144
+ expect(content).toContain('File not found');
145
+ });
146
+
147
+ it('validates file is not empty', () => {
148
+ expect(content).toContain('File is empty');
149
+ });
150
+
151
+ it('warns on large files (>50KB)', () => {
152
+ expect(content).toContain('50KB');
153
+ });
154
+ });
155
+
156
+ describe('--from brief validation', () => {
157
+ const content = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
158
+
159
+ it('validates file is a Clancy brief', () => {
160
+ expect(content).toContain('does not appear to be a Clancy brief');
161
+ });
162
+
163
+ it('checks for Problem Statement or Ticket Decomposition', () => {
164
+ expect(content).toContain('## Problem Statement');
165
+ expect(content).toContain('## Ticket Decomposition');
166
+ });
167
+
168
+ it('points to /clancy:brief --from for raw files', () => {
169
+ expect(content).toContain('/clancy:brief --from');
170
+ });
171
+ });
172
+
123
173
  describe('three-state mode detection', () => {
124
174
  const content = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
125
175
 
@@ -150,6 +200,29 @@ describe('three-state mode detection', () => {
150
200
  );
151
201
  });
152
202
 
203
+ it('--from mode gathers from local brief file', () => {
204
+ expect(content).toContain('Step 3a');
205
+ expect(content).toContain('Gather from local brief');
206
+ });
207
+
208
+ it('--from parses Source field from brief', () => {
209
+ expect(content).toContain('**Source:**');
210
+ });
211
+
212
+ it('--from extracts Problem Statement and Goals', () => {
213
+ expect(content).toContain('## Problem Statement');
214
+ expect(content).toContain('## Goals');
215
+ });
216
+
217
+ it('--from reads Ticket Decomposition for context', () => {
218
+ expect(content).toContain('## Ticket Decomposition');
219
+ });
220
+
221
+ it('--from mode bypasses standalone board-ticket guard', () => {
222
+ expect(content).toContain('--from');
223
+ expect(content).toContain('bypasses the standalone board-ticket guard');
224
+ });
225
+
153
226
  it('standalone guard mentions /clancy:board-setup', () => {
154
227
  expect(content).toContain('Standalone board-ticket guard');
155
228
  expect(content).toContain('Board credentials not found');
@@ -166,3 +239,404 @@ describe('three-state mode detection', () => {
166
239
  expect(content).toContain('npx chief-clancy');
167
240
  });
168
241
  });
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Local plan output (--from mode)
245
+ // ---------------------------------------------------------------------------
246
+
247
+ describe('local plan output', () => {
248
+ const content = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
249
+
250
+ it('saves plans to .clancy/plans/ directory', () => {
251
+ expect(content).toContain('.clancy/plans/');
252
+ });
253
+
254
+ it('creates .clancy/plans/ directory if needed', () => {
255
+ expect(content).toContain('Create `.clancy/plans/` directory');
256
+ });
257
+
258
+ it('generates slug from brief filename', () => {
259
+ expect(content).toContain('YYYY-MM-DD-');
260
+ expect(content).toContain('date prefix');
261
+ });
262
+
263
+ it('checks for existing local plan by filename', () => {
264
+ expect(content).toContain('.clancy/plans/{slug}-{row-number}.md');
265
+ });
266
+
267
+ it('--fresh overwrites existing local plan', () => {
268
+ expect(content).toContain('--fresh');
269
+ expect(content).toContain('overwrite');
270
+ });
271
+
272
+ it('stops if plan exists without --fresh', () => {
273
+ expect(content).toContain('Already planned');
274
+ });
275
+
276
+ it('local plan header includes Source and Brief fields', () => {
277
+ expect(content).toContain('**Source:**');
278
+ expect(content).toContain('**Brief:**');
279
+ });
280
+
281
+ it('offers to post as comment when board credentials available', () => {
282
+ expect(content).toContain('board credentials ARE available');
283
+ });
284
+ });
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // --from mode Step 4 adaptations
288
+ // ---------------------------------------------------------------------------
289
+
290
+ describe('--from Step 4 adaptations', () => {
291
+ const content = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
292
+
293
+ it('documents Step 4 adaptations for --from mode', () => {
294
+ expect(content).toContain('--from mode Step 4 adaptations');
295
+ });
296
+
297
+ it('uses slug as display identifier instead of ticket key', () => {
298
+ expect(content).toContain('Use the slug');
299
+ expect(content).toContain('wherever board mode uses `{KEY}`');
300
+ });
301
+
302
+ it('skips board comment posting for infeasible tickets', () => {
303
+ expect(content).toContain(
304
+ 'do NOT post a "Clancy skipped" comment to any board',
305
+ );
306
+ });
307
+
308
+ it('skips QA return detection in --from mode', () => {
309
+ expect(content).toContain('Skip entirely in `--from` mode');
310
+ });
311
+ });
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // --from mode Step 6 log entries
315
+ // ---------------------------------------------------------------------------
316
+
317
+ describe('--from Step 6 log entries', () => {
318
+ const content = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
319
+
320
+ it('defines --from mode log format with slug', () => {
321
+ expect(content).toContain('LOCAL_PLAN');
322
+ expect(content).toContain('{slug}');
323
+ });
324
+ });
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Summary update for --from mode
328
+ // ---------------------------------------------------------------------------
329
+
330
+ describe('--from summary output', () => {
331
+ const content = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
332
+
333
+ it('shows local file path instead of Comment posted', () => {
334
+ expect(content).toContain('Saved to .clancy/plans/');
335
+ });
336
+
337
+ it('mentions --from in standalone guard hint', () => {
338
+ expect(content).toContain('/clancy:plan --from');
339
+ });
340
+ });
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Row selection + multi-row planning (--from mode)
344
+ // ---------------------------------------------------------------------------
345
+
346
+ describe('decomposition table parsing', () => {
347
+ const content = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
348
+
349
+ it('parses decomposition table rows', () => {
350
+ expect(content).toContain('row number (column 1)');
351
+ expect(content).toContain('title (column 2)');
352
+ });
353
+
354
+ it('validates rows have minimum required fields', () => {
355
+ expect(content).toContain('valid row must have');
356
+ });
357
+
358
+ it('skips malformed rows with warning', () => {
359
+ expect(content).toContain('Skipping malformed row');
360
+ });
361
+
362
+ it('falls back to single planning unit when table is missing', () => {
363
+ expect(content).toContain('Planning the brief as a single item');
364
+ });
365
+
366
+ it('falls back to single unit when all rows are malformed', () => {
367
+ expect(content).toContain('ALL rows are malformed');
368
+ });
369
+
370
+ it('rows are 1-indexed from data rows', () => {
371
+ expect(content).toContain('1-indexed');
372
+ expect(content).toContain('excluding header and separator');
373
+ });
374
+ });
375
+
376
+ describe('planned marker tracking', () => {
377
+ const content = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
378
+
379
+ it('tracks planned rows via HTML comment marker', () => {
380
+ expect(content).toContain('<!-- planned:');
381
+ });
382
+
383
+ it('updates marker after planning a row', () => {
384
+ expect(content).toContain('<!-- planned:1,2,3 -->');
385
+ expect(content).toContain('<!-- planned:1,2,3,4 -->');
386
+ });
387
+
388
+ it('places marker before trailing --- or at EOF', () => {
389
+ expect(content).toContain('trailing `---`');
390
+ });
391
+
392
+ it('stops when all rows are planned', () => {
393
+ expect(content).toContain('All decomposition rows have been planned');
394
+ });
395
+
396
+ it('notes concurrency limitation', () => {
397
+ expect(content).toContain('not concurrency-safe');
398
+ });
399
+ });
400
+
401
+ describe('row targeting with --from path N', () => {
402
+ const content = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
403
+
404
+ it('bare integer after --from selects a specific row', () => {
405
+ expect(content).toContain('selects row N');
406
+ });
407
+
408
+ it('defaults to first unplanned row without a number', () => {
409
+ expect(content).toContain('first unplanned row');
410
+ });
411
+
412
+ it('errors if targeted row is already planned without --fresh', () => {
413
+ expect(content).toContain('already planned');
414
+ });
415
+
416
+ it('validates row number is a positive integer', () => {
417
+ expect(content).toContain('Row number must be a positive integer');
418
+ });
419
+
420
+ it('validates row number exists in decomposition table', () => {
421
+ expect(content).toContain('Row {N} not found');
422
+ expect(content).toContain('decomposition rows');
423
+ });
424
+
425
+ it('bare integer with --from is always a row number, not batch', () => {
426
+ expect(content).toContain(
427
+ 'bare integer is always interpreted as a row number',
428
+ );
429
+ });
430
+ });
431
+
432
+ describe('--afk multi-row and single-row default', () => {
433
+ const content = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
434
+
435
+ it('--afk plans all unplanned rows in sequence', () => {
436
+ expect(content).toContain('all unplanned rows');
437
+ expect(content).toContain('sequentially');
438
+ });
439
+
440
+ it('without --afk plans exactly one row', () => {
441
+ expect(content).toContain('exactly one row');
442
+ });
443
+
444
+ it('--fresh with specific row overwrites plan file', () => {
445
+ expect(content).toContain('marker is not modified');
446
+ });
447
+
448
+ it('--fresh + --afk re-plans all rows from scratch', () => {
449
+ expect(content).toContain('clears the planned marker entirely');
450
+ });
451
+ });
452
+
453
+ describe('row-aware plan filename', () => {
454
+ const content = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
455
+
456
+ it('plan filename includes row number', () => {
457
+ expect(content).toContain('{slug}-{row-number}.md');
458
+ });
459
+
460
+ it('plan header includes Row field', () => {
461
+ expect(content).toContain('**Row:**');
462
+ });
463
+ });
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // Local plan feedback loop (--from mode)
467
+ // ---------------------------------------------------------------------------
468
+
469
+ describe('local plan feedback loop', () => {
470
+ const content = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
471
+
472
+ it('detects ## Feedback section in existing plan files', () => {
473
+ expect(content).toContain('## Feedback');
474
+ expect(content).toContain('local plan file');
475
+ });
476
+
477
+ it('revises plan when feedback found', () => {
478
+ expect(content).toContain('Revise:');
479
+ expect(content).toContain('read existing plan + feedback');
480
+ });
481
+
482
+ it('stops on existing plan without feedback or --fresh', () => {
483
+ expect(content).toContain('Add a ## Feedback section to revise');
484
+ });
485
+
486
+ it('prepends Changes From Previous Plan section', () => {
487
+ expect(content).toContain('### Changes From Previous Plan');
488
+ });
489
+
490
+ it('passes feedback to generation step as additional context', () => {
491
+ expect(content).toContain('Pass this feedback to the plan generation');
492
+ });
493
+
494
+ it('plan footer mentions ## Feedback for revision', () => {
495
+ expect(content).toContain('add a ## Feedback section');
496
+ });
497
+
498
+ it('--fresh takes precedence over feedback', () => {
499
+ expect(content).toContain('takes precedence over feedback');
500
+ });
501
+
502
+ it('handles multiple ## Feedback sections by concatenating', () => {
503
+ expect(content).toContain('multiple `## Feedback` sections');
504
+ expect(content).toContain('concatenate all sections');
505
+ });
506
+
507
+ it('matches ## Feedback as line-anchored heading not in code fences', () => {
508
+ expect(content).toContain('start of a line');
509
+ expect(content).toContain('not inside code fences');
510
+ });
511
+
512
+ it('feedback is not carried forward to revised plan', () => {
513
+ expect(content).toContain('NOT carried forward');
514
+ });
515
+
516
+ it('specifies revision procedure for Step 4 sub-steps', () => {
517
+ expect(content).toContain('Skip Step 4a');
518
+ expect(content).toContain('reuse the existing exploration');
519
+ });
520
+
521
+ it('--afk multi-row includes rows with feedback', () => {
522
+ expect(content).toContain('(unplanned rows) ∪ (rows with feedback)');
523
+ });
524
+
525
+ it('default row selection prefers rows with feedback first', () => {
526
+ expect(content).toContain(
527
+ 'first row with feedback if any, otherwise first unplanned row',
528
+ );
529
+ });
530
+
531
+ it('revised local plans use LOCAL_REVISED log entry', () => {
532
+ expect(content).toContain('LOCAL_REVISED');
533
+ });
534
+
535
+ it('Changes From Previous Plan is positioned for local template', () => {
536
+ expect(content).toContain('after the local header block');
537
+ });
538
+ });
539
+
540
+ // ---------------------------------------------------------------------------
541
+ // --list flag: plan inventory
542
+ // ---------------------------------------------------------------------------
543
+
544
+ describe('--list flag handling', () => {
545
+ const content = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
546
+
547
+ it('documents --list flag in Step 2 arg list', () => {
548
+ expect(content).toContain('**`--list`:** display the plan inventory');
549
+ });
550
+
551
+ it('--list short-circuits Step 1 preflight', () => {
552
+ expect(content).toContain('### 0. Short-circuit on `--list`');
553
+ expect(content).toContain(
554
+ 'skip the rest of Step 1 entirely (no installation detection, no `git fetch`',
555
+ );
556
+ expect(content).toContain('jump straight to Step 8');
557
+ });
558
+
559
+ it('--list takes precedence over other flags and arguments', () => {
560
+ expect(content).toContain('`--list` always wins over other flags');
561
+ expect(content).toContain(
562
+ 'the inventory is displayed and the other flags are ignored',
563
+ );
564
+ });
565
+
566
+ it('standalone board-ticket guard skips when --list is present', () => {
567
+ expect(content).toContain(
568
+ 'Skip this guard entirely if `--list` was passed',
569
+ );
570
+ });
571
+ });
572
+
573
+ describe('plan inventory step', () => {
574
+ const content = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
575
+
576
+ it('defines a Plan inventory step at Step 8', () => {
577
+ expect(content).toContain('## Step 8 — Plan inventory (`--list`)');
578
+ });
579
+
580
+ it('scans .clancy/plans/ for markdown files', () => {
581
+ expect(content).toContain('Scan `.clancy/plans/` for all `.md` files');
582
+ });
583
+
584
+ it('parses plan headers written by Step 5a', () => {
585
+ // Each header field gets its own line item describing how it is parsed
586
+ // (the `**Foo:**` strings exist elsewhere in the file too — these tests
587
+ // assert the Step 8 parsing intent, not just substring presence).
588
+ expect(content).toContain('value of the `**Brief:**` line');
589
+ expect(content).toContain('value of the `**Row:**` line');
590
+ expect(content).toContain('value of the `**Source:**` line');
591
+ expect(content).toContain('value of the `**Planned:**` line');
592
+ });
593
+
594
+ it('Plan ID is the filename minus .md, not the brief slug', () => {
595
+ expect(content).toContain(
596
+ '**Plan ID** — the plan filename minus the `.md` extension',
597
+ );
598
+ expect(content).toContain('first column in the listing is the Plan ID');
599
+ });
600
+
601
+ it('reserves a Status column for the future approve-plan PR', () => {
602
+ expect(content).toContain('**Status**');
603
+ expect(content).toContain('always `Planned`');
604
+ expect(content).toContain('`.approved` marker');
605
+ });
606
+
607
+ it('sort is deterministic with explicit tie-breakers', () => {
608
+ expect(content).toContain('newest first');
609
+ expect(content).toContain(
610
+ 'Tie-break on same date by Plan ID, alphabetical ascending',
611
+ );
612
+ expect(content).toContain(
613
+ 'Files with a missing or unparseable date sort last',
614
+ );
615
+ expect(content).toContain('deterministic across runs');
616
+ });
617
+
618
+ it('handles empty or missing .clancy/plans/ directory', () => {
619
+ expect(content).toContain(
620
+ 'If `.clancy/plans/` does not exist or contains no `.md` files',
621
+ );
622
+ expect(content).toContain('No plans found');
623
+ });
624
+
625
+ it('describes how missing header fields are rendered', () => {
626
+ expect(content).toContain(
627
+ 'Display the literal `?` if the line is absent or empty',
628
+ );
629
+ });
630
+
631
+ it('inventory step is filesystem-only (no API/board access)', () => {
632
+ expect(content).toContain(
633
+ 'filesystem-only — no API calls, no board access, no `.clancy/.env` required',
634
+ );
635
+ });
636
+
637
+ it('--list never writes to progress.txt or any other file', () => {
638
+ expect(content).toContain(
639
+ 'never logs to `.clancy/progress.txt` and never modifies any file',
640
+ );
641
+ });
642
+ });