pan-wizard 3.5.1 → 3.7.10

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 (93) hide show
  1. package/README.md +10 -10
  2. package/agents/pan-executor.md +18 -0
  3. package/agents/pan-experiment-runner.md +126 -0
  4. package/agents/pan-phase-researcher.md +16 -0
  5. package/agents/pan-plan-checker.md +80 -0
  6. package/agents/pan-planner.md +19 -0
  7. package/agents/pan-reviewer.md +2 -0
  8. package/agents/pan-verifier.md +41 -0
  9. package/bin/install-lib.cjs +55 -0
  10. package/bin/install.js +71 -22
  11. package/commands/pan/debug.md +1 -1
  12. package/commands/pan/experiment.md +219 -0
  13. package/commands/pan/health.md +1 -1
  14. package/commands/pan/learn.md +15 -1
  15. package/commands/pan/optimize.md +13 -0
  16. package/commands/pan/patches.md +10 -1
  17. package/commands/pan/phase-tests.md +1 -4
  18. package/commands/pan/todo-add.md +1 -1
  19. package/commands/pan/todo-check.md +1 -1
  20. package/hooks/dist/pan-cost-logger.js +54 -4
  21. package/hooks/dist/pan-trace-logger.js +72 -3
  22. package/package.json +67 -66
  23. package/pan-wizard-core/bin/lib/commands.cjs +8 -0
  24. package/pan-wizard-core/bin/lib/config.cjs +13 -2
  25. package/pan-wizard-core/bin/lib/context-budget.cjs +73 -0
  26. package/pan-wizard-core/bin/lib/core.cjs +13 -0
  27. package/pan-wizard-core/bin/lib/doc-lint/frontmatter.js +270 -0
  28. package/pan-wizard-core/bin/lib/doc-lint/reporter.js +45 -0
  29. package/pan-wizard-core/bin/lib/doc-lint/schema.js +202 -0
  30. package/pan-wizard-core/bin/lib/doc-lint/validate.js +190 -0
  31. package/pan-wizard-core/bin/lib/doc-lint/walk.js +135 -0
  32. package/pan-wizard-core/bin/lib/doc-lint.cjs +287 -0
  33. package/pan-wizard-core/bin/lib/experiment.cjs +501 -0
  34. package/pan-wizard-core/bin/lib/learn-index.cjs +235 -0
  35. package/pan-wizard-core/bin/lib/learn-lint.cjs +292 -0
  36. package/pan-wizard-core/bin/lib/optimize.cjs +474 -1
  37. package/pan-wizard-core/bin/lib/runner.cjs +472 -0
  38. package/pan-wizard-core/bin/pan-tools.cjs +222 -2
  39. package/pan-wizard-core/learnings/README.md +70 -0
  40. package/pan-wizard-core/learnings/index.json +540 -0
  41. package/pan-wizard-core/learnings/internal/.gitkeep +2 -0
  42. package/pan-wizard-core/learnings/internal/experiment-runner.md +81 -0
  43. package/pan-wizard-core/learnings/internal/external-research.md +93 -0
  44. package/pan-wizard-core/learnings/internal/loop-design.md +33 -0
  45. package/pan-wizard-core/learnings/internal/pan-dev-bugs.md +181 -0
  46. package/pan-wizard-core/learnings/universal/.gitkeep +2 -0
  47. package/pan-wizard-core/learnings/universal/atomic-state.md +21 -0
  48. package/pan-wizard-core/learnings/universal/binary-io.md +21 -0
  49. package/pan-wizard-core/learnings/universal/comment-syntax.md +21 -0
  50. package/pan-wizard-core/learnings/universal/composition.md +33 -0
  51. package/pan-wizard-core/learnings/universal/concurrency.md +33 -0
  52. package/pan-wizard-core/learnings/universal/dag-scheduler.md +33 -0
  53. package/pan-wizard-core/learnings/universal/data-driven-design.md +21 -0
  54. package/pan-wizard-core/learnings/universal/design-process.md +21 -0
  55. package/pan-wizard-core/learnings/universal/empirical-spike.md +21 -0
  56. package/pan-wizard-core/learnings/universal/error-handling.md +23 -0
  57. package/pan-wizard-core/learnings/universal/error-paths.md +21 -0
  58. package/pan-wizard-core/learnings/universal/glob-semantics.md +21 -0
  59. package/pan-wizard-core/learnings/universal/idempotency.md +21 -0
  60. package/pan-wizard-core/learnings/universal/invariants.md +21 -0
  61. package/pan-wizard-core/learnings/universal/io-patterns.md +21 -0
  62. package/pan-wizard-core/learnings/universal/numeric-edge-cases.md +21 -0
  63. package/pan-wizard-core/learnings/universal/output-conventions.md +21 -0
  64. package/pan-wizard-core/learnings/universal/parser-design.md +21 -0
  65. package/pan-wizard-core/learnings/universal/phase-locking.md +21 -0
  66. package/pan-wizard-core/learnings/universal/pipe-friendly-cli.md +21 -0
  67. package/pan-wizard-core/learnings/universal/schema-design.md +21 -0
  68. package/pan-wizard-core/learnings/universal/secret-handling.md +21 -0
  69. package/pan-wizard-core/learnings/universal/streaming-io.md +21 -0
  70. package/pan-wizard-core/learnings/universal/test-patterns.md +57 -0
  71. package/pan-wizard-core/learnings/universal/test-strategy.md +33 -0
  72. package/pan-wizard-core/learnings/universal/unicode.md +21 -0
  73. package/pan-wizard-core/learnings/universal/vendor-pattern.md +21 -0
  74. package/pan-wizard-core/references/guardrails.md +58 -0
  75. package/pan-wizard-core/references/handoff-decisions.md +156 -0
  76. package/pan-wizard-core/references/schemas/pan-command.schema.yml +39 -0
  77. package/pan-wizard-core/references/verification-patterns.md +31 -0
  78. package/pan-wizard-core/templates/config.json +2 -1
  79. package/pan-wizard-core/templates/idea.md +52 -0
  80. package/pan-wizard-core/templates/summary-complex.md +14 -5
  81. package/pan-wizard-core/templates/summary-minimal.md +6 -0
  82. package/pan-wizard-core/templates/summary-standard.md +14 -3
  83. package/pan-wizard-core/workflows/discuss-phase.md +108 -1
  84. package/pan-wizard-core/workflows/exec-phase.md +37 -1
  85. package/pan-wizard-core/workflows/execute-plan.md +14 -0
  86. package/pan-wizard-core/workflows/health.md +23 -0
  87. package/pan-wizard-core/workflows/new-project.md +65 -81
  88. package/pan-wizard-core/workflows/plan-phase.md +58 -0
  89. package/pan-wizard-core/workflows/transition.md +102 -7
  90. package/pan-wizard-core/workflows/verify-phase.md +14 -0
  91. package/scripts/build-hooks.js +7 -1
  92. package/scripts/generate-skills-docs.py +10 -8
  93. package/scripts/release-check.js +184 -0
package/package.json CHANGED
@@ -1,66 +1,67 @@
1
- {
2
- "name": "pan-wizard",
3
- "version": "3.5.1",
4
- "description": "A lightweight workflow automation and context engineering system for Claude Code, OpenCode, Gemini CLI, Codex, and Copilot CLI.",
5
- "bin": {
6
- "pan-wizard": "bin/install.js"
7
- },
8
- "files": [
9
- "bin",
10
- "commands",
11
- "pan-wizard-core",
12
- "agents",
13
- "hooks/dist",
14
- "scripts",
15
- "assets"
16
- ],
17
- "keywords": [
18
- "claude",
19
- "claude-code",
20
- "ai",
21
- "workflow-automation",
22
- "context-engineering",
23
- "project-automation",
24
- "gemini",
25
- "gemini-cli",
26
- "codex",
27
- "codex-cli",
28
- "copilot",
29
- "copilot-cli",
30
- "github-copilot"
31
- ],
32
- "author": "PAN Wizard Contributors",
33
- "contributors": [
34
- {
35
- "name": "oharms",
36
- "url": "https://github.com/oharms"
37
- }
38
- ],
39
- "license": "MIT",
40
- "repository": {
41
- "type": "git",
42
- "url": "git+https://github.com/oharms/PanWizard.git"
43
- },
44
- "homepage": "https://github.com/oharms/PanWizard#readme",
45
- "bugs": {
46
- "url": "https://github.com/oharms/PanWizard/issues"
47
- },
48
- "engines": {
49
- "node": ">=16.7.0"
50
- },
51
- "devDependencies": {
52
- "@playwright/test": "^1.58.2",
53
- "@vscode/test-electron": "^2.5.2",
54
- "esbuild": "^0.24.0"
55
- },
56
- "scripts": {
57
- "build:hooks": "node scripts/build-hooks.js",
58
- "prepublishOnly": "npm run build:hooks",
59
- "test": "node --test tests/*.test.cjs",
60
- "test:scenarios": "node --test tests/scenarios/*.test.cjs",
61
- "test:all": "node --test tests/*.test.cjs tests/scenarios/*.test.cjs",
62
- "test:e2e": "node --test tests/scenarios/*.test.cjs",
63
- "test:vscode": "npx playwright test --config tests/e2e/playwright.config.mjs",
64
- "test:watch": "node --test --watch tests/*.test.cjs"
65
- }
66
- }
1
+ {
2
+ "name": "pan-wizard",
3
+ "version": "3.7.10",
4
+ "description": "A lightweight workflow automation and context engineering system for Claude Code, OpenCode, Gemini CLI, Codex, and Copilot CLI.",
5
+ "bin": {
6
+ "pan-wizard": "bin/install.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "commands",
11
+ "pan-wizard-core",
12
+ "agents",
13
+ "hooks/dist",
14
+ "scripts",
15
+ "assets"
16
+ ],
17
+ "keywords": [
18
+ "claude",
19
+ "claude-code",
20
+ "ai",
21
+ "workflow-automation",
22
+ "context-engineering",
23
+ "project-automation",
24
+ "gemini",
25
+ "gemini-cli",
26
+ "codex",
27
+ "codex-cli",
28
+ "copilot",
29
+ "copilot-cli",
30
+ "github-copilot"
31
+ ],
32
+ "author": "PAN Wizard Contributors",
33
+ "contributors": [
34
+ {
35
+ "name": "oharms",
36
+ "url": "https://github.com/oharms"
37
+ }
38
+ ],
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/oharms/PanWizard.git"
43
+ },
44
+ "homepage": "https://github.com/oharms/PanWizard#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/oharms/PanWizard/issues"
47
+ },
48
+ "engines": {
49
+ "node": ">=16.7.0"
50
+ },
51
+ "devDependencies": {
52
+ "@playwright/test": "^1.58.2",
53
+ "@vscode/test-electron": "^2.5.2",
54
+ "esbuild": "^0.24.0"
55
+ },
56
+ "scripts": {
57
+ "build:hooks": "node scripts/build-hooks.js",
58
+ "release:check": "node scripts/release-check.js",
59
+ "prepublishOnly": "node scripts/release-check.js",
60
+ "test": "node --test tests/*.test.cjs",
61
+ "test:scenarios": "node --test tests/scenarios/*.test.cjs",
62
+ "test:all": "node --test tests/*.test.cjs tests/scenarios/*.test.cjs",
63
+ "test:e2e": "node --test tests/scenarios/*.test.cjs",
64
+ "test:vscode": "npx playwright test --config tests/e2e/playwright.config.mjs",
65
+ "test:watch": "node --test --watch tests/*.test.cjs"
66
+ }
67
+ }
@@ -379,6 +379,11 @@ function runCommitSafetyChecks(cwd, config, force) {
379
379
  function cmdCommit(cwd, message, files, raw, amend, opts) {
380
380
  const commitType = opts && opts.type;
381
381
  const force = opts && opts.force;
382
+ // P-EXP-001 follow-up (v3.7.10): when failOnError is true, commit_failed
383
+ // exits non-zero so callers (especially autonomous loops) detect the silent-
384
+ // failure case where git refused (e.g. missing identity) and the loop would
385
+ // otherwise keep going thinking the artifact landed.
386
+ const failOnError = opts && opts.failOnError;
382
387
 
383
388
  if (!isGitRepo(cwd)) {
384
389
  output({ committed: false, hash: null, reason: 'not_a_git_repo', hint: 'Run git init to initialize a repository' }, raw, 'not a git repo');
@@ -425,6 +430,9 @@ function cmdCommit(cwd, message, files, raw, amend, opts) {
425
430
  output({ committed: false, hash: null, reason: 'nothing_to_commit' }, raw, 'nothing');
426
431
  return;
427
432
  }
433
+ if (failOnError) {
434
+ error('commit_failed: ' + (commitResult.stderr || 'unknown git error').trim());
435
+ }
428
436
  output({ committed: false, hash: null, reason: 'commit_failed', error: commitResult.stderr }, raw, 'failed');
429
437
  return;
430
438
  }
@@ -52,6 +52,7 @@ function buildConfigDefaults(hasBraveSearch, userDefaults) {
52
52
  plan_check: true,
53
53
  verifier: true,
54
54
  nyquist_validation: false,
55
+ phase_record_compact: false,
55
56
  },
56
57
  parallelization: true,
57
58
  brave_search: hasBraveSearch,
@@ -422,17 +423,27 @@ function cmdStandardsRecommend(cwd, raw) {
422
423
  if (/\b(enterprise|togaf|architecture\s*governance|compliance)\b/.test(lower)) detectedTypes.push('enterprise');
423
424
  if (/\b(cli|command.line|terminal|shell|argv)\b/.test(lower)) detectedTypes.push('cli');
424
425
 
425
- if (detectedTypes.length === 0) detectedTypes.push('general');
426
+ const explicitlyDetected = detectedTypes.length > 0;
427
+ if (!explicitlyDetected) detectedTypes.push('general');
426
428
 
427
429
  const seen = new Set();
428
430
  const recommendations = [];
429
431
  for (const type of detectedTypes) {
430
432
  const recs = STANDARDS_RECOMMENDATIONS[type] || [];
433
+ let perTypeIndex = 0;
431
434
  for (const id of recs) {
432
435
  if (seen.has(id)) continue;
433
436
  seen.add(id);
434
437
  const s = STANDARDS_CATALOG[id];
435
- recommendations.push({ id, name: s.name, reason: type + ' project detected', priority: recommendations.length < 3 ? 'high' : 'medium' });
438
+ const isHigh = explicitlyDetected && type !== 'general' && perTypeIndex === 0;
439
+ recommendations.push({
440
+ id,
441
+ name: s.name,
442
+ reason: type + ' project detected',
443
+ priority: isHigh ? 'high' : 'medium',
444
+ source_type: type,
445
+ });
446
+ perTypeIndex += 1;
436
447
  }
437
448
  }
438
449
 
@@ -21,6 +21,51 @@ function estimateTokens(text) {
21
21
  return Math.ceil(text.length / CHARS_PER_TOKEN);
22
22
  }
23
23
 
24
+ /**
25
+ * Estimate a relevance signal for per-phase markdown content (P-RES-002).
26
+ *
27
+ * Per Chroma's "Context Rot" research (Hong & Huber, July 2025): a single
28
+ * semantically-similar-but-irrelevant distractor degrades performance even
29
+ * at modest context sizes. Distractor density matters more than token count.
30
+ *
31
+ * Computing TRUE topic-relevance requires embeddings or keyword analysis
32
+ * we can't do cheaply at zero deps. This v0 heuristic reports a simpler
33
+ * signal: structure-vs-content ratio. Markdown files heavy on headers,
34
+ * separators, empty bullet lists, and placeholder text are LESS dense in
35
+ * actual signal than files of equal length with concrete prose. The ratio
36
+ * isn't true distractor density but is correlated with it for the
37
+ * "thin/template-only context" failure mode.
38
+ *
39
+ * Returns ratio in [0, 1] where 1 = all content lines, 0 = all structure.
40
+ * Returns null if not enough lines to compute meaningfully.
41
+ *
42
+ * @param {string} text
43
+ * @returns {number|null}
44
+ */
45
+ function estimateRelevanceRatio(text) {
46
+ if (!text) return null;
47
+ const lines = text.split(/\r?\n/);
48
+ if (lines.length < 5) return null;
49
+ let contentLines = 0;
50
+ let totalLines = 0;
51
+ for (const raw of lines) {
52
+ const line = raw.trim();
53
+ if (!line) continue; // skip blank
54
+ totalLines++;
55
+ if (/^#{1,6}\s/.test(line)) continue; // skip header
56
+ if (/^[-*_]{3,}$/.test(line)) continue; // skip separator
57
+ if (/^[-*]\s*$/.test(line)) continue; // skip empty bullet
58
+ if (/^[-*]\s*\[\s*[\]x_]\s*\]\s*$/.test(line)) continue; // empty checkbox
59
+ if (/^\|\s*-+\s*\|/.test(line)) continue; // table separator row
60
+ if (/^>\s*$/.test(line)) continue; // empty blockquote
61
+ if (line.length < 10) continue; // very short — likely scaffolding
62
+ if (/^(TODO|TBD|FIXME|placeholder|todo|tbd|fixme|placeholder|coming soon)\b/i.test(line)) continue;
63
+ contentLines++;
64
+ }
65
+ if (totalLines === 0) return null;
66
+ return Math.round((contentLines / totalLines) * 1000) / 1000;
67
+ }
68
+
24
69
  /**
25
70
  * Compute context budget for the current project state.
26
71
  * @param {string} cwd - Project root directory
@@ -101,6 +146,32 @@ function cmdContextBudget(cwd, raw) {
101
146
  recommendation = `Within budget. ~${additionalPlans} more plans could fit before degradation.`;
102
147
  }
103
148
 
149
+ // P-RES-002 signal: structure-vs-content ratio for per-phase markdown.
150
+ // High structure (lots of empty bullets, headers, placeholders) suggests
151
+ // the per-phase context is thin/templatey rather than substantive — a
152
+ // proxy for "context that's wasting tokens on filler."
153
+ let relevanceSignal = null;
154
+ if (phaseDir) {
155
+ const fullPhasePath = path.join(cwd, phaseDir);
156
+ const samples = [];
157
+ for (const fname of ['research.md', 'context.md']) {
158
+ const candidate = path.join(fullPhasePath, fname);
159
+ const content = safeReadFile(candidate);
160
+ if (content) {
161
+ const ratio = estimateRelevanceRatio(content);
162
+ if (ratio !== null) samples.push({ file: toPosix(path.join(phaseDir, fname)), ratio });
163
+ }
164
+ }
165
+ if (samples.length > 0) {
166
+ const avg = samples.reduce((a, s) => a + s.ratio, 0) / samples.length;
167
+ relevanceSignal = {
168
+ avg_ratio: Math.round(avg * 1000) / 1000,
169
+ samples,
170
+ note: 'P-RES-002 v0 heuristic — structure/content ratio. <0.4 suggests thin per-phase context (heavy on headers + empty buckets + placeholders).',
171
+ };
172
+ }
173
+ }
174
+
104
175
  // E-8: cache metrics — surface how much of the total context would be
105
176
  // served from prompt cache when Opus 4.7 cache_control is active.
106
177
  const { buildCachedContext } = require('./core.cjs');
@@ -141,6 +212,7 @@ function cmdContextBudget(cwd, raw) {
141
212
  contextWindow: CONTEXT_WINDOW,
142
213
  budgetUtilization: Math.round(utilization * 1000) / 1000,
143
214
  cache,
215
+ relevanceSignal,
144
216
  recommendation,
145
217
  };
146
218
 
@@ -174,4 +246,5 @@ function cmdContextBudget(cwd, raw) {
174
246
  module.exports = {
175
247
  cmdContextBudget,
176
248
  estimateTokens,
249
+ estimateRelevanceRatio,
177
250
  };
@@ -48,6 +48,7 @@ const COST_MULTIPLIERS = { reasoning: 15, mid: 3, fast: 1 };
48
48
  // ─── Model Profile Table ─────────────────────────────────────────────────────
49
49
 
50
50
  const MODEL_PROFILES = {
51
+ // Original planning/execution agents (pre-v3.0)
51
52
  'pan-planner': { quality: 'reasoning', balanced: 'reasoning', budget: 'mid' },
52
53
  'pan-roadmapper': { quality: 'reasoning', balanced: 'mid', budget: 'mid' },
53
54
  'pan-executor': { quality: 'reasoning', balanced: 'mid', budget: 'mid' },
@@ -60,6 +61,18 @@ const MODEL_PROFILES = {
60
61
  'pan-plan-checker': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
61
62
  'pan-integration-checker': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
62
63
  'pan-reviewer': { quality: 'reasoning', balanced: 'fast', budget: 'fast' },
64
+ // Spec B v2 agents (v3.0–v3.4) — added v3.7.5 to close MODEL_PROFILES drift
65
+ 'pan-conductor': { quality: 'reasoning', balanced: 'reasoning', budget: 'mid' },
66
+ 'pan-counterfactual': { quality: 'reasoning', balanced: 'mid', budget: 'mid' },
67
+ 'pan-hardener': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
68
+ 'pan-meta-reviewer': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
69
+ 'pan-knowledge': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
70
+ 'pan-previewer': { quality: 'reasoning', balanced: 'fast', budget: 'fast' },
71
+ // v3.5 agents
72
+ 'pan-optimizer': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
73
+ 'pan-distiller': { quality: 'reasoning', balanced: 'fast', budget: 'fast' },
74
+ // v3.7.0 self-improvement loop — observation-only watchdog
75
+ 'pan-experiment-runner': { quality: 'reasoning', balanced: 'fast', budget: 'fast' },
63
76
  };
64
77
 
65
78
  // ─── Output helpers ───────────────────────────────────────────────────────────
@@ -0,0 +1,270 @@
1
+ 'use strict';
2
+ /**
3
+ * frontmatter.js — minimal YAML-ish frontmatter parser.
4
+ *
5
+ * Supports the subset documented in DESIGN_SPEC.md §"YAML subset":
6
+ * - Scalars: strings (quoted or bare), numbers, booleans, null
7
+ * - Flow lists: [a, b, c]
8
+ * - Block maps: key: value (one per line)
9
+ * - Comments: # ... (skipped)
10
+ *
11
+ * Anything beyond this subset → error code 'frontmatter-malformed'.
12
+ *
13
+ * Returns: {data, bodyStart, errors}
14
+ * data — parsed object (empty {} if no frontmatter)
15
+ * bodyStart — line number (1-indexed) where the body starts (after closing ---)
16
+ * Used for line-number arithmetic in violation reports.
17
+ * errors — array of {line, message} for parse problems (NOT validation —
18
+ * that's validate.js's job)
19
+ */
20
+
21
+ const FENCE = '---';
22
+
23
+ function parseFrontmatter(text) {
24
+ if (text == null) return { data: {}, bodyStart: 1, errors: [] };
25
+
26
+ const lines = text.split(/\r?\n/);
27
+ if (lines.length === 0 || lines[0] !== FENCE) {
28
+ // No frontmatter. Body starts at line 1.
29
+ return { data: {}, bodyStart: 1, errors: [], hasFrontmatter: false };
30
+ }
31
+
32
+ // Find closing fence
33
+ let closingIndex = -1;
34
+ for (let i = 1; i < lines.length; i++) {
35
+ if (lines[i] === FENCE) {
36
+ closingIndex = i;
37
+ break;
38
+ }
39
+ }
40
+
41
+ if (closingIndex === -1) {
42
+ return {
43
+ data: {},
44
+ bodyStart: 1,
45
+ errors: [{ line: 1, message: 'frontmatter opening --- has no matching closing ---' }],
46
+ hasFrontmatter: true,
47
+ };
48
+ }
49
+
50
+ const fmLines = lines.slice(1, closingIndex);
51
+ const result = parseFrontmatterBlock(fmLines);
52
+
53
+ return {
54
+ data: result.data,
55
+ bodyStart: closingIndex + 2, // 1-indexed line AFTER the closing ---
56
+ errors: result.errors,
57
+ hasFrontmatter: true,
58
+ };
59
+ }
60
+
61
+ function parseFrontmatterBlock(lines) {
62
+ const data = {};
63
+ const errors = [];
64
+
65
+ // Extended after dogfood gate (see .planning/optimization/traces/.../trace.jsonl event 2026-04-27T11:50:00Z):
66
+ // Block-style lists are the dominant real-world format. The DESIGN_SPEC originally
67
+ // scoped them out — that decision was wrong. Block-list support added per deviation R1.
68
+
69
+ for (let i = 0; i < lines.length; i++) {
70
+ const lineNum = i + 2; // 1-indexed in source file (line 1 is opening ---)
71
+ let line = lines[i];
72
+
73
+ // Strip trailing comments
74
+ line = stripTrailingComment(line);
75
+
76
+ // Skip blank/comment-only lines
77
+ if (line.trim() === '') continue;
78
+
79
+ // Match `key:` (no value) — start of a block list or block map
80
+ const blockKeyMatch = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*$/);
81
+ if (blockKeyMatch) {
82
+ const key = blockKeyMatch[1];
83
+
84
+ // Look ahead: collect subsequent lines that are list items (" - x") or
85
+ // sub-map entries (" k: v") indented under this key. Stop at next top-level
86
+ // key (no leading whitespace) or end of block.
87
+ const childLines = [];
88
+ let j = i + 1;
89
+ while (j < lines.length) {
90
+ const next = lines[j];
91
+ if (next.trim() === '' || next.trim().startsWith('#')) { j++; continue; }
92
+ // Top-level key (no indent) ends the child block
93
+ if (/^[A-Za-z_]/.test(next)) break;
94
+ // Indented line — accumulate
95
+ childLines.push({ raw: next, line: j + 2 });
96
+ j++;
97
+ }
98
+
99
+ // Detect: is this a block list (- items) or a block map (key: val)?
100
+ const looksList = childLines.length > 0 && childLines.every(c => /^\s+-\s+/.test(c.raw));
101
+ if (looksList) {
102
+ const items = [];
103
+ for (const cl of childLines) {
104
+ const itemMatch = cl.raw.match(/^\s+-\s+(.*)$/);
105
+ if (!itemMatch) {
106
+ errors.push({ line: cl.line, message: `expected "- value" in block list, got: ${JSON.stringify(cl.raw)}` });
107
+ continue;
108
+ }
109
+ const itemRaw = stripTrailingComment(itemMatch[1]).trim();
110
+ const parsed = parseScalarOrList(itemRaw, cl.line);
111
+ if (parsed.error) {
112
+ errors.push({ line: cl.line, message: `in list item: ${parsed.error}` });
113
+ continue;
114
+ }
115
+ items.push(parsed.value);
116
+ }
117
+ if (key in data) {
118
+ errors.push({ line: lineNum, message: `duplicate key "${key}"` });
119
+ } else {
120
+ data[key] = items;
121
+ }
122
+ i = j - 1; // resume after the block
123
+ continue;
124
+ }
125
+ // Block map shape: not supported in v0.1, but don't error — treat the
126
+ // key as null-valued and let validation handle it.
127
+ if (childLines.length > 0) {
128
+ // Block-style maps are out of scope per DESIGN_SPEC. Surface a warning
129
+ // rather than a crash so the rest of the file still validates.
130
+ errors.push({ line: lineNum, message: `block-map values not supported in v0.1 for key "${key}" (use a flow map or scalar)` });
131
+ i = j - 1;
132
+ continue;
133
+ }
134
+ // No child lines → just an empty value
135
+ if (key in data) {
136
+ errors.push({ line: lineNum, message: `duplicate key "${key}"` });
137
+ } else {
138
+ data[key] = null;
139
+ }
140
+ continue;
141
+ }
142
+
143
+ // Match `key: value` (scalar / flow list / etc.)
144
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
145
+ if (!m) {
146
+ errors.push({ line: lineNum, message: `expected "key: value", got: ${JSON.stringify(line)}` });
147
+ continue;
148
+ }
149
+
150
+ const key = m[1];
151
+ const rawValue = m[2];
152
+
153
+ if (key in data) {
154
+ errors.push({ line: lineNum, message: `duplicate key "${key}"` });
155
+ continue;
156
+ }
157
+
158
+ const parsed = parseScalarOrList(rawValue, lineNum);
159
+ if (parsed.error) {
160
+ errors.push({ line: lineNum, message: parsed.error });
161
+ continue;
162
+ }
163
+ data[key] = parsed.value;
164
+ }
165
+
166
+ return { data, errors };
167
+ }
168
+
169
+ function stripTrailingComment(line) {
170
+ // Naive: strip from first # not inside quotes. PAN frontmatter doesn't put
171
+ // # in values, so this is safe for our subset. If a user does, they'll see
172
+ // a frontmatter-malformed error and can quote it.
173
+ const inSingle = (s, idx) => {
174
+ let q = 0;
175
+ for (let j = 0; j < idx; j++) if (s[j] === "'") q++;
176
+ return q % 2 === 1;
177
+ };
178
+ const inDouble = (s, idx) => {
179
+ let q = 0;
180
+ for (let j = 0; j < idx; j++) if (s[j] === '"') q++;
181
+ return q % 2 === 1;
182
+ };
183
+ for (let i = 0; i < line.length; i++) {
184
+ if (line[i] === '#' && !inSingle(line, i) && !inDouble(line, i)) {
185
+ return line.slice(0, i).trimEnd();
186
+ }
187
+ }
188
+ return line;
189
+ }
190
+
191
+ function parseScalarOrList(raw, lineNum) {
192
+ const trimmed = raw.trim();
193
+
194
+ if (trimmed === '') return { value: null };
195
+
196
+ // Flow list: [a, b, c]
197
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
198
+ const inner = trimmed.slice(1, -1).trim();
199
+ if (inner === '') return { value: [] };
200
+ const items = splitFlowItems(inner);
201
+ const parsedItems = [];
202
+ for (const item of items) {
203
+ const sub = parseScalarOrList(item, lineNum);
204
+ if (sub.error) return { error: `in list: ${sub.error}` };
205
+ parsedItems.push(sub.value);
206
+ }
207
+ return { value: parsedItems };
208
+ }
209
+
210
+ if (trimmed.startsWith('{')) {
211
+ return { error: `inline maps not supported in v0.1 (got ${JSON.stringify(trimmed)})` };
212
+ }
213
+
214
+ // Quoted strings
215
+ if (
216
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
217
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
218
+ ) {
219
+ if (trimmed.length < 2) return { error: 'unterminated quoted string' };
220
+ return { value: trimmed.slice(1, -1) };
221
+ }
222
+
223
+ // Bare keywords
224
+ if (trimmed === 'true') return { value: true };
225
+ if (trimmed === 'false') return { value: false };
226
+ if (trimmed === 'null' || trimmed === '~') return { value: null };
227
+
228
+ // Numbers
229
+ if (/^-?\d+$/.test(trimmed)) return { value: parseInt(trimmed, 10) };
230
+ if (/^-?\d+\.\d+$/.test(trimmed)) return { value: parseFloat(trimmed) };
231
+
232
+ // Bare string (catch-all)
233
+ return { value: trimmed };
234
+ }
235
+
236
+ function splitFlowItems(inner) {
237
+ // Simple comma-split that respects quoted strings and nested brackets.
238
+ const items = [];
239
+ let depth = 0;
240
+ let inSingle = false;
241
+ let inDouble = false;
242
+ let current = '';
243
+
244
+ for (const ch of inner) {
245
+ if (inSingle) {
246
+ current += ch;
247
+ if (ch === "'") inSingle = false;
248
+ continue;
249
+ }
250
+ if (inDouble) {
251
+ current += ch;
252
+ if (ch === '"') inDouble = false;
253
+ continue;
254
+ }
255
+ if (ch === "'") { inSingle = true; current += ch; continue; }
256
+ if (ch === '"') { inDouble = true; current += ch; continue; }
257
+ if (ch === '[' || ch === '{') { depth++; current += ch; continue; }
258
+ if (ch === ']' || ch === '}') { depth--; current += ch; continue; }
259
+ if (ch === ',' && depth === 0) {
260
+ items.push(current.trim());
261
+ current = '';
262
+ continue;
263
+ }
264
+ current += ch;
265
+ }
266
+ if (current.trim() !== '') items.push(current.trim());
267
+ return items;
268
+ }
269
+
270
+ module.exports = { parseFrontmatter };
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+ /**
3
+ * reporter.js — format violations for output.
4
+ *
5
+ * Two formats per DESIGN_SPEC.md §"CLI surface":
6
+ * - human: <file>:<line> — <code> — <message> (multi-line, colorless for v0.1)
7
+ * - json: NDJSON, one violation per line
8
+ */
9
+
10
+ /**
11
+ * @param {Array<Violation>} violations
12
+ * @returns {string}
13
+ */
14
+ function formatHuman(violations) {
15
+ if (!violations || violations.length === 0) return '';
16
+ const lines = [];
17
+ for (const v of violations) {
18
+ const sev = v.severity === 'warning' ? '[warn]' : '[err] ';
19
+ lines.push(`${sev} ${v.file}:${v.line} — ${v.code} — ${v.message}`);
20
+ }
21
+ return lines.join('\n');
22
+ }
23
+
24
+ /**
25
+ * @param {Array<Violation>} violations
26
+ * @returns {string}
27
+ */
28
+ function formatJson(violations) {
29
+ if (!violations || violations.length === 0) return '';
30
+ return violations.map(v => JSON.stringify(v)).join('\n');
31
+ }
32
+
33
+ /**
34
+ * Returns a one-line summary suitable for end-of-run output.
35
+ * @param {Array<Violation>} violations
36
+ * @param {number} fileCount
37
+ * @returns {string}
38
+ */
39
+ function summaryLine(violations, fileCount) {
40
+ const errors = violations.filter(v => v.severity === 'error').length;
41
+ const warnings = violations.filter(v => v.severity === 'warning').length;
42
+ return `Linted ${fileCount} file(s): ${errors} error(s), ${warnings} warning(s)`;
43
+ }
44
+
45
+ module.exports = { formatHuman, formatJson, summaryLine };