deepflow 0.1.107 → 0.1.109

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.
@@ -123,12 +123,23 @@ function computeLayer(content) {
123
123
  * @param {string} content - The raw markdown content of the spec file.
124
124
  * @param {object} opts
125
125
  * @param {'interactive'|'auto'} opts.mode
126
+ * @param {string|null} opts.filename - Optional filename (basename) used for stem validation.
126
127
  * @returns {{ hard: string[], advisory: string[] }}
127
128
  */
128
- function validateSpec(content, { mode = 'interactive', specsDir = null } = {}) {
129
+ function validateSpec(content, { mode = 'interactive', specsDir = null, filename = null } = {}) {
129
130
  const hard = [];
130
131
  const advisory = [];
131
132
 
133
+ // ── Spec filename stem validation ────────────────────────────────────
134
+ if (filename !== null) {
135
+ let stem = path.basename(filename, '.md');
136
+ stem = stem.replace(/^(doing-|done-)/, '');
137
+ const SAFE_STEM = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
138
+ if (!SAFE_STEM.test(stem)) {
139
+ hard.push(`Spec filename stem contains unsafe characters: "${stem}"`);
140
+ }
141
+ }
142
+
132
143
  // ── Frontmatter: parse and validate derives-from ─────────────────────
133
144
  const { frontmatter } = parseFrontmatter(content);
134
145
  if (frontmatter['derives-from'] !== undefined) {
@@ -339,7 +350,7 @@ if (require.main === module) {
339
350
  const content = fs.readFileSync(filePath, 'utf8');
340
351
  const mode = process.argv.includes('--auto') ? 'auto' : 'interactive';
341
352
  const specsDir = path.resolve(path.dirname(filePath));
342
- const result = validateSpec(content, { mode, specsDir });
353
+ const result = validateSpec(content, { mode, specsDir, filename: path.basename(filePath) });
343
354
 
344
355
  if (result.hard.length > 0) {
345
356
  console.error('HARD invariant failures:');
@@ -410,3 +410,136 @@ describe('derives-from validation', () => {
410
410
  assert.deepEqual(resultWith.hard, resultWithout.hard);
411
411
  });
412
412
  });
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // validateSpec — spec filename stem validation
416
+ // ---------------------------------------------------------------------------
417
+
418
+ describe('validateSpec stem validation', () => {
419
+ test('valid plain name passes', () => {
420
+ const result = validateSpec(fullSpec(), { filename: 'my-spec.md' });
421
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
422
+ assert.equal(stemErrors.length, 0);
423
+ });
424
+
425
+ test('valid name with numbers passes', () => {
426
+ const result = validateSpec(fullSpec(), { filename: 'spec-v2-fix.md' });
427
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
428
+ assert.equal(stemErrors.length, 0);
429
+ });
430
+
431
+ test('single character name passes', () => {
432
+ const result = validateSpec(fullSpec(), { filename: 'a.md' });
433
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
434
+ assert.equal(stemErrors.length, 0);
435
+ });
436
+
437
+ test('doing- prefix is stripped before validation', () => {
438
+ const result = validateSpec(fullSpec(), { filename: 'doing-my-spec.md' });
439
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
440
+ assert.equal(stemErrors.length, 0);
441
+ });
442
+
443
+ test('done- prefix is stripped before validation', () => {
444
+ const result = validateSpec(fullSpec(), { filename: 'done-my-spec.md' });
445
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
446
+ assert.equal(stemErrors.length, 0);
447
+ });
448
+
449
+ test('filename with dollar sign is rejected as hard failure', () => {
450
+ const result = validateSpec(fullSpec(), { filename: 'spec-$bad.md' });
451
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
452
+ assert.equal(stemErrors.length, 1);
453
+ });
454
+
455
+ test('filename with backtick is rejected as hard failure', () => {
456
+ const result = validateSpec(fullSpec(), { filename: 'spec-`bad.md' });
457
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
458
+ assert.equal(stemErrors.length, 1);
459
+ });
460
+
461
+ test('filename with pipe character is rejected as hard failure', () => {
462
+ const result = validateSpec(fullSpec(), { filename: 'spec|bad.md' });
463
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
464
+ assert.equal(stemErrors.length, 1);
465
+ });
466
+
467
+ test('filename with semicolon is rejected as hard failure', () => {
468
+ const result = validateSpec(fullSpec(), { filename: 'spec;bad.md' });
469
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
470
+ assert.equal(stemErrors.length, 1);
471
+ });
472
+
473
+ test('filename with ampersand is rejected as hard failure', () => {
474
+ const result = validateSpec(fullSpec(), { filename: 'spec&bad.md' });
475
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
476
+ assert.equal(stemErrors.length, 1);
477
+ });
478
+
479
+ test('filename with space is rejected as hard failure', () => {
480
+ const result = validateSpec(fullSpec(), { filename: 'spec bad.md' });
481
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
482
+ assert.equal(stemErrors.length, 1);
483
+ });
484
+
485
+ test('filename with path traversal (..) is rejected as hard failure', () => {
486
+ const result = validateSpec(fullSpec(), { filename: '..evil.md' });
487
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
488
+ assert.equal(stemErrors.length, 1);
489
+ });
490
+
491
+ test('filename with leading hyphen is rejected as hard failure', () => {
492
+ const result = validateSpec(fullSpec(), { filename: '-leading.md' });
493
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
494
+ assert.equal(stemErrors.length, 1);
495
+ });
496
+
497
+ test('filename with trailing hyphen is rejected as hard failure', () => {
498
+ const result = validateSpec(fullSpec(), { filename: 'trailing-.md' });
499
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
500
+ assert.equal(stemErrors.length, 1);
501
+ });
502
+
503
+ test('empty stem (only prefix) is rejected as hard failure', () => {
504
+ // A filename of just "doing-.md" strips to empty string
505
+ const result = validateSpec(fullSpec(), { filename: 'doing-.md' });
506
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
507
+ assert.equal(stemErrors.length, 1);
508
+ });
509
+
510
+ test('empty filename stem (.md only) is rejected as hard failure', () => {
511
+ const result = validateSpec(fullSpec(), { filename: '.md' });
512
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
513
+ assert.equal(stemErrors.length, 1);
514
+ });
515
+
516
+ test('stem validation failure is in hard array, not advisory', () => {
517
+ const result = validateSpec(fullSpec(), { filename: 'spec$bad.md' });
518
+ const hardErrors = result.hard.filter((m) => m.includes('unsafe characters'));
519
+ const advisoryErrors = result.advisory.filter((m) => m.includes('unsafe characters'));
520
+ assert.equal(hardErrors.length, 1);
521
+ assert.equal(advisoryErrors.length, 0);
522
+ });
523
+
524
+ test('no filename passed (null) skips stem validation', () => {
525
+ // No filename option — stem check should not run
526
+ const result = validateSpec(fullSpec());
527
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
528
+ assert.equal(stemErrors.length, 0);
529
+ });
530
+
531
+ test('all existing repo spec names pass validation', () => {
532
+ const existingNames = [
533
+ 'done-dashboard-model-cost-fixes.md',
534
+ 'done-orchestrator-v2.md',
535
+ 'done-plan-cleanup.md',
536
+ 'done-plan-fanout.md',
537
+ 'done-quality-gates.md',
538
+ ];
539
+ for (const filename of existingNames) {
540
+ const result = validateSpec(fullSpec(), { filename });
541
+ const stemErrors = result.hard.filter((m) => m.includes('unsafe characters'));
542
+ assert.equal(stemErrors.length, 0, `Expected ${filename} to pass but got stem errors`);
543
+ }
544
+ });
545
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepflow",
3
- "version": "0.1.107",
3
+ "version": "0.1.109",
4
4
  "description": "Doing reveals what thinking can't predict — spec-driven iterative development for Claude Code",
5
5
  "keywords": [
6
6
  "claude",
@@ -42,5 +42,8 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "playwright": "^1.58.2"
45
+ },
46
+ "devDependencies": {
47
+ "typescript": "^6.0.2"
45
48
  }
46
49
  }
@@ -44,6 +44,14 @@ Shell: `` !`cat .deepflow/checkpoint.json 2>/dev/null || echo 'NOT_FOUND'` `` /
44
44
 
45
45
  Require clean HEAD. Derive SPEC_NAME from `specs/doing-*.md`. Create `.deepflow/worktrees/{spec}` on branch `df/{spec}`. Reuse if exists; `--fresh` deletes first. If `worktree.sparse_paths` non-empty: `git worktree add --no-checkout`, `sparse-checkout set {paths}`, checkout.
46
46
 
47
+ ### 1.5.1. SYMLINK DEPENDENCIES
48
+
49
+ After worktree creation, symlink `node_modules` from the main repo so TypeScript/LSP/build can resolve dependencies without a full install:
50
+ ```bash
51
+ node "${HOME}/.claude/bin/worktree-deps.js" --source "$(git rev-parse --show-toplevel)" --worktree "${WORKTREE_PATH}"
52
+ ```
53
+ The script finds `node_modules` at root and inside monorepo directories (`packages/`, `apps/`, etc.) and creates symlinks in the worktree. Outputs JSON: `{"linked": N, "total": M}`. Errors are non-fatal — log and continue.
54
+
47
55
  ### 1.6. RATCHET SNAPSHOT
48
56
 
49
57
  Snapshot pre-existing test files — only these count for ratchet (agent-created excluded):
@@ -159,7 +167,7 @@ The script handles all health checks internally and outputs structured JSON:
159
167
  **Broken-tests policy:** Updating pre-existing tests requires a separate dedicated task in PLAN.md with explicit justification — never inline during execution.
160
168
 
161
169
  **Orchestrator response by exit code:**
162
- - **Exit 0 (PASS):** Commit stands. TaskUpdate(status: "completed"), update PLAN.md [x] + commit hash.
170
+ - **Exit 0 (PASS):** Commit stands. **AC coverage check** (see §5.5.1). TaskUpdate(status: "completed"), update PLAN.md [x] + commit hash. **Extract decisions** (see §5.5.2).
163
171
  - **Exit 1 (FAIL):** Script already reverted. Set `TaskUpdate(status: "pending")`. Recompute remaining waves:
164
172
  ```
165
173
  WAVE_JSON=!`node "${HOME}/.claude/bin/wave-runner.js" --json --plan PLAN.md --recalc --failed T{N} 2>/dev/null || echo 'WAVE_ERROR'`
@@ -168,6 +176,32 @@ The script handles all health checks internally and outputs structured JSON:
168
176
  Report: `"✗ T{n}: reverted"`.
169
177
  - **Exit 2 (SALVAGEABLE):** Spawn `Agent(model="sonnet")` to fix lint/typecheck issues. Re-run `node "${HOME}/.claude/bin/ratchet.js"`. If still non-zero → revert both commits, set status pending.
170
178
 
179
+ #### 5.5.1. AC COVERAGE CHECK (after ratchet pass)
180
+
181
+ After ratchet PASS (exit 0), run AC coverage check to verify agent reported all acceptance criteria:
182
+ ```bash
183
+ node "${HOME}/.claude/bin/hooks/ac-coverage.js" --spec {spec_path} --output-file {agent_output_file} --status pass
184
+ ```
185
+
186
+ where `{spec_path}` is the path to `specs/doing-{spec_name}.md` and `{agent_output_file}` is the task agent's full output transcript (from TaskOutput or notification context).
187
+
188
+ **Exit codes from ac-coverage.js:**
189
+ - **Exit 0:** All ACs covered or no ACs in spec. Status remains PASS. Proceed to decision extraction (§5.5.2).
190
+ - **Exit 2 (SALVAGEABLE):** Missed ACs detected despite agent reporting TASK_STATUS:pass. Script outputs summary: `[ac-coverage] N/M ACs covered — missed: AC-X, AC-Y; ...`. Override final status to SALVAGEABLE. Commit stands. TaskUpdate(status: "completed") with note that ACs are incomplete.
191
+ - **Exit 1 (script error):** Log error, do not change status. Proceed as if ratchet PASS (exit 0 from ac-coverage).
192
+
193
+ #### 5.5.2. DECISION EXTRACTION (on ratchet pass)
194
+
195
+ Parse the agent's response for `DECISIONS:` line. If present:
196
+ 1. Split by ` | ` to get individual decisions
197
+ 2. Each decision has format `[TAG] description — rationale` where TAG ∈ {APPROACH, PROVISIONAL, ASSUMPTION, FUTURE, UPDATE}
198
+ 3. Append to `.deepflow/decisions.md` under `### {date} — {spec_name}` header (create header if first decision for this spec today, reuse if exists)
199
+ 4. Format: `- [TAG] description — rationale`
200
+
201
+ If no `DECISIONS:` line in agent output → skip silently (mechanical tasks don't produce decisions).
202
+
203
+ **This runs on every ratchet pass, not just at verify time.** Decisions are captured incrementally as tasks complete, so they're never lost even if verify fails or merge is manual.
204
+
171
205
  **Edit scope validation:** `git diff HEAD~1 --name-only` vs allowed globs. Violation → revert, report.
172
206
  **Impact completeness:** diff vs Impact callers/duplicates. Gap → advisory warning (no revert).
173
207
 
@@ -304,6 +338,25 @@ TASK_DETAIL=!`cat .deepflow/plans/doing-{task_id}.md 2>/dev/null || echo 'NOT_FO
304
338
  ```
305
339
  If `TASK_DETAIL` is not `NOT_FOUND`, use it as the full Middle section (Steps, ACs, Impact) in the agent prompt, overriding the inline PLAN.md block. If `NOT_FOUND`, fall back to the inline PLAN.md task block.
306
340
 
341
+ **Pre-prompt type context extraction (before building agent prompt):**
342
+
343
+ Run LSP `documentSymbol` on the task's `files` list to collect existing type definitions. This runs BEFORE prompt construction so the result can be injected as `EXISTING_TYPES`.
344
+
345
+ <!-- AC-7: No new tool calls or latency added when context sources are empty -->
346
+ **Early exit (AC-7):** If the task's `Files:` list is empty, skip all `documentSymbol` calls entirely. Set `EXISTING_TYPES` to empty string immediately and proceed to prompt construction.
347
+
348
+ Steps (only when `Files:` list is non-empty):
349
+ 1. Cap the file list at 10 files (take the first 10 from the task's `Files:` list).
350
+ 2. For each file (up to the cap), call `documentSymbol` via LSP.
351
+ 3. Filter results: keep only symbols with kind ∈ {Class, Interface, Enum, TypeAlias} (LSP SymbolKind values 5, 11, 10, 26 respectively).
352
+ 4. For each matching symbol, extract the source range (`range.start.line` to `range.end.line`) — read those lines from the file.
353
+ 5. Accumulate extracted lines with a **120-line total budget** — stop adding symbols once the budget is reached.
354
+ 6. Join all extracted ranges into a single string: `EXISTING_TYPES`.
355
+
356
+ **AC-8 — graceful no-op:** If no matching symbols are found across all processed files (either `documentSymbol` returns nothing or no Class/Interface/Enum/TypeAlias symbols exist), set `EXISTING_TYPES` to empty string. No context block is added to the prompt.
357
+
358
+ <!-- AC-6: Backward-compatible no-op — when neither Domain Model section exists in the spec nor Existing Types extraction yields content (EXISTING_TYPES is empty string), the Standard Task prompt contains no extra context blocks and is identical to the pre-injection baseline. Zero prompt overhead, zero tool calls for tasks that lack these context sources. -->
359
+
307
360
  **Standard Task** (`Agent(model="{Model}", ...)`):
308
361
  ```
309
362
  --- START ---
@@ -317,6 +370,16 @@ spike_results:
317
370
  insight: {insight from probe_learnings}
318
371
  }
319
372
  Success criteria: {ACs from spec relevant to this task}
373
+ {If spec contains ## Domain Model section:
374
+ --- CONTEXT: Domain Model ---
375
+ {Domain Model section content from doing-*.md, extracted via shell injection:
376
+ DOMAIN_MODEL=!`sed -n '/^## Domain Model$/,/^## [^D]/p' specs/doing-{spec_name}.md | head -n -1 2>/dev/null || echo 'NOT_FOUND'`
377
+ }
378
+ }
379
+ {If EXISTING_TYPES is non-empty:
380
+ --- CONTEXT: Existing Types ---
381
+ {EXISTING_TYPES}
382
+ }
320
383
  --- MIDDLE (omit for low effort; omit deps for medium) ---
321
384
  {TASK_DETAIL if available, else inline block:}
322
385
  Impact: Callers: {file} ({why}) | Duplicates: [active→consolidate] [dead→DELETE] | Data flow: {consumers}
@@ -324,12 +387,58 @@ Prior tasks: {dep_id}: {summary}
324
387
  Steps: 1. chub search/get for APIs 2. LSP findReferences, add unlisted callers 3. LSP documentSymbol on Impact files → Read with offset/limit on relevant ranges only (never read full files) 4. Implement 5. Commit
325
388
  --- END ---
326
389
  Duplicates: [active]→consolidate [dead]→DELETE. ONLY job: code+commit. No merge/rename/checkout.
390
+ **Acceptance Criteria Coverage:** If the spec has acceptance criteria (AC-N), emit this block:
391
+ ```
392
+ AC_COVERAGE:
393
+ AC-1:done
394
+ AC-2:skip:reason here (if applicable)
395
+ AC_COVERAGE_END
396
+ ```
397
+ Format: one line per AC with either `AC-N:done` or `AC-N:skip:reason`. Omit this block if the spec has no acceptance criteria.
398
+ DECISIONS: If you made non-obvious choices, append to the LAST LINE BEFORE TASK_STATUS:
399
+ DECISIONS: [TAG] {decision} — {rationale} | [TAG] {decision2} — {rationale2}
400
+ Tags:
401
+ [APPROACH] — chose X over Y (architectural/design choice)
402
+ [PROVISIONAL] — works for now but won't scale / needs revisit
403
+ [ASSUMPTION] — assumed X is true; if wrong, Y breaks
404
+ [FUTURE] — deferred X because Y; revisit when Z
405
+ [UPDATE] — changed prior decision from X to Y because Z
406
+ Skip for trivial/mechanical changes.
327
407
  Last line of your response MUST be: TASK_STATUS:pass (if successful) or TASK_STATUS:fail (if failed) or TASK_STATUS:revert (if reverted)
328
408
  ```
329
409
 
410
+ **Integration Task** (`Agent(model="opus")`):
411
+ ```
412
+ --- START ---
413
+ {task_id} [INTEGRATION]: Verify contracts between {spec_a} ↔ {spec_b}
414
+ Integration ACs: {list from PLAN.md}
415
+ --- MIDDLE ---
416
+ Specs involved: {spec file paths}
417
+ Interface Map: {from integration task detail}
418
+ Contract Risks: {from integration task detail}
419
+ --- END ---
420
+ RULES:
421
+ - Fix the CONSUMER to match the PRODUCER's declared interface. Never weaken the producer.
422
+ - Each fix must reference the specific contract being repaired.
423
+ - If a migration conflict exists, make ALL migrations idempotent (IF NOT EXISTS, IF NOT COLUMN, etc.)
424
+ - Do NOT create new variables or intermediate adapters to paper over mismatches. Fix the actual call site.
425
+ - Do NOT modify acceptance criteria or spec definitions.
426
+ - Commit as fix({spec}): {contract description}. One commit per contract fix.
427
+ **Acceptance Criteria Coverage:** If the spec has acceptance criteria (AC-N), emit this block:
428
+ ```
429
+ AC_COVERAGE:
430
+ AC-1:done
431
+ AC-2:skip:reason here (if applicable)
432
+ AC_COVERAGE_END
433
+ ```
434
+ Format: one line per AC with either `AC-N:done` or `AC-N:skip:reason`. Omit this block if the spec has no acceptance criteria.
435
+ DECISIONS: Report each contract fix as: [TAG] {what was mismatched} — {which side changed and why}. Use [APPROACH] for definitive fixes, [PROVISIONAL] if the fix is a workaround, [UPDATE] if changing a prior decision.
436
+ Last line: TASK_STATUS:pass or TASK_STATUS:fail
437
+ ```
438
+
330
439
  **Bootstrap:** `BOOTSTRAP: Write tests for edit_scope files. Do NOT change implementation. Commit as test({spec}): bootstrap. Last line: TASK_STATUS:pass or TASK_STATUS:fail`
331
440
 
332
- **Spike:** `{task_id} [SPIKE]: {hypothesis}. Files+Spec. {reverted warnings}. Minimal spike. Commit as spike({spec}): {desc}. Last line: TASK_STATUS:pass or TASK_STATUS:fail`
441
+ **Spike:** `{task_id} [SPIKE]: {hypothesis}. Files+Spec. {reverted warnings}. Minimal spike. Commit as spike({spec}): {desc}. If you discovered constraints, rejected approaches, or made assumptions, report: DECISIONS: [TAG] {finding} — {why it matters} (use PROVISIONAL for "works but needs revisit", ASSUMPTION for "assumed X; if wrong Y breaks", APPROACH for definitive choices). Last line: TASK_STATUS:pass or TASK_STATUS:fail`
333
442
 
334
443
  **Optimize Task** (`Agent(model="opus")`):
335
444
  ```
@@ -399,6 +508,7 @@ Reverted task: `TaskUpdate(status: "pending")`, dependents stay blocked. Repeate
399
508
 
400
509
  | Rule | Detail |
401
510
  |------|--------|
511
+ | Integration tasks run last | [INTEGRATION] tasks execute after all blocked-by tasks complete. Fix tasks from integration failures are prescriptive (name the contract, producer, consumer, and which side to change). Never weaken the producer's declared interface — prefer fixing the consumer. |
402
512
  | Zero tests → bootstrap first | Sole task when snapshot empty |
403
513
  | 1 task = 1 agent = 1 commit | `atomic-commits` skill |
404
514
  | 1 file = 1 writer | Sequential on conflict |