pan-wizard 3.33.0 → 3.34.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.
- package/README.md +23 -20
- package/agents/pan-conductor.md +17 -16
- package/agents/pan-counterfactual.md +2 -2
- package/agents/pan-debugger.md +27 -8
- package/agents/pan-design-checker.md +1 -1
- package/agents/pan-designer.md +2 -2
- package/agents/pan-distiller.md +3 -3
- package/agents/pan-document_code.md +14 -14
- package/agents/pan-executor.md +12 -8
- package/agents/pan-experiment-runner.md +8 -7
- package/agents/pan-integration-checker.md +2 -2
- package/agents/pan-knowledge.md +1 -1
- package/agents/pan-meta-reviewer.md +3 -3
- package/agents/pan-optimizer.md +39 -43
- package/agents/pan-phase-researcher.md +8 -8
- package/agents/pan-plan-checker.md +30 -28
- package/agents/pan-planner.md +54 -43
- package/agents/pan-previewer.md +1 -1
- package/agents/pan-project-researcher.md +11 -11
- package/agents/pan-release.md +1 -1
- package/agents/pan-research-synthesizer.md +2 -2
- package/agents/pan-reviewer.md +24 -2
- package/agents/pan-roadmapper.md +15 -14
- package/agents/pan-verifier.md +32 -24
- package/bin/install-lib.cjs +58 -23
- package/bin/install.js +47 -1
- package/commands/pan/army.md +15 -11
- package/commands/pan/assumptions.md +2 -2
- package/commands/pan/audit-deployment.md +11 -12
- package/commands/pan/cost.md +8 -4
- package/commands/pan/debug.md +10 -7
- package/commands/pan/design-phase.md +3 -3
- package/commands/pan/discuss-phase.md +2 -0
- package/commands/pan/exec-phase.md +21 -37
- package/commands/pan/experiment.md +9 -8
- package/commands/pan/focus-auto.md +22 -20
- package/commands/pan/focus-design.md +7 -6
- package/commands/pan/focus-doc-audit.md +4 -3
- package/commands/pan/focus-drift-walking.md +4 -3
- package/commands/pan/focus-exec.md +6 -6
- package/commands/pan/focus-plan.md +12 -11
- package/commands/pan/focus-scan.md +5 -3
- package/commands/pan/focus-sync.md +3 -2
- package/commands/pan/git.md +8 -8
- package/commands/pan/health.md +2 -2
- package/commands/pan/hud.md +3 -2
- package/commands/pan/hygiene.md +14 -7
- package/commands/pan/knowledge.md +9 -9
- package/commands/pan/learn.md +10 -7
- package/commands/pan/links.md +4 -1
- package/commands/pan/map-codebase.md +1 -1
- package/commands/pan/mcp-bridge.md +18 -18
- package/commands/pan/milestone-done.md +6 -3
- package/commands/pan/milestone-new.md +1 -1
- package/commands/pan/new-project.md +10 -10
- package/commands/pan/optimize.md +16 -14
- package/commands/pan/patches.md +2 -6
- package/commands/pan/pause.md +1 -1
- package/commands/pan/phase-tests.md +2 -1
- package/commands/pan/plan-phase.md +9 -19
- package/commands/pan/preview.md +1 -1
- package/commands/pan/profile.md +2 -2
- package/commands/pan/quick.md +1 -1
- package/commands/pan/remove-phase.md +1 -1
- package/commands/pan/report.md +2 -2
- package/commands/pan/research-phase.md +12 -11
- package/commands/pan/resume.md +9 -11
- package/commands/pan/retro.md +5 -3
- package/commands/pan/review-deep.md +7 -7
- package/commands/pan/settings.md +1 -1
- package/commands/pan/verify-phase.md +0 -11
- package/commands/pan/what-if.md +2 -2
- package/hooks/dist/pan-context-monitor.js +343 -44
- package/hooks/dist/pan-cost-logger.js +30 -0
- package/hooks/dist/pan-statusline.js +68 -22
- package/hooks/dist/pan-stop-guard.js +7 -2
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/agents-md.cjs +44 -6
- package/pan-wizard-core/bin/lib/bridge.cjs +6 -5
- package/pan-wizard-core/bin/lib/campaign.cjs +4 -2
- package/pan-wizard-core/bin/lib/commands-learnings.cjs +19 -2
- package/pan-wizard-core/bin/lib/commands.cjs +33 -4
- package/pan-wizard-core/bin/lib/config.cjs +2 -1
- package/pan-wizard-core/bin/lib/constants.cjs +18 -6
- package/pan-wizard-core/bin/lib/context-budget.cjs +12 -9
- package/pan-wizard-core/bin/lib/core.cjs +53 -19
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +13 -1
- package/pan-wizard-core/bin/lib/cost.cjs +142 -13
- package/pan-wizard-core/bin/lib/distill.cjs +6 -3
- package/pan-wizard-core/bin/lib/doc-lint.cjs +30 -11
- package/pan-wizard-core/bin/lib/findings.cjs +9 -2
- package/pan-wizard-core/bin/lib/focus.cjs +13 -4
- package/pan-wizard-core/bin/lib/frontmatter.cjs +105 -26
- package/pan-wizard-core/bin/lib/hygiene.cjs +178 -17
- package/pan-wizard-core/bin/lib/init.cjs +7 -4
- package/pan-wizard-core/bin/lib/knowledge.cjs +17 -1
- package/pan-wizard-core/bin/lib/learn-index.cjs +111 -10
- package/pan-wizard-core/bin/lib/learn-lint.cjs +30 -3
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +8 -4
- package/pan-wizard-core/bin/lib/memory.cjs +508 -54
- package/pan-wizard-core/bin/lib/milestone.cjs +8 -3
- package/pan-wizard-core/bin/lib/optimize.cjs +84 -16
- package/pan-wizard-core/bin/lib/phase-remove.cjs +85 -21
- package/pan-wizard-core/bin/lib/phase-report.cjs +8 -2
- package/pan-wizard-core/bin/lib/phase.cjs +15 -13
- package/pan-wizard-core/bin/lib/preview.cjs +3 -1
- package/pan-wizard-core/bin/lib/roadmap-compact.cjs +267 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +243 -9
- package/pan-wizard-core/bin/lib/runner.cjs +2 -1
- package/pan-wizard-core/bin/lib/state-compact.cjs +11 -6
- package/pan-wizard-core/bin/lib/state.cjs +43 -28
- package/pan-wizard-core/bin/lib/template.cjs +11 -4
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -13
- package/pan-wizard-core/bin/lib/verify-retro.cjs +5 -3
- package/pan-wizard-core/bin/lib/verify-scope.cjs +5 -2
- package/pan-wizard-core/bin/lib/verify.cjs +30 -28
- package/pan-wizard-core/bin/lib/whatif.cjs +5 -3
- package/pan-wizard-core/bin/pan-tools.cjs +101 -15
- package/pan-wizard-core/learnings/index.json +5 -5
- package/pan-wizard-core/opencode/pan-wizard.js +111 -0
- package/pan-wizard-core/references/checkpoints.md +3 -3
- package/pan-wizard-core/references/continuation-format.md +3 -3
- package/pan-wizard-core/references/decimal-phase-calculation.md +2 -3
- package/pan-wizard-core/references/design-methodology.md +1 -1
- package/pan-wizard-core/references/git-integration.md +4 -6
- package/pan-wizard-core/references/git-planning-commit.md +2 -2
- package/pan-wizard-core/references/guardrails.md +2 -2
- package/pan-wizard-core/references/handoff-decisions.md +3 -3
- package/pan-wizard-core/references/model-profile-resolution.md +5 -5
- package/pan-wizard-core/references/model-profiles.md +14 -9
- package/pan-wizard-core/references/phase-argument-parsing.md +7 -7
- package/pan-wizard-core/references/planning-config.md +4 -4
- package/pan-wizard-core/references/tdd.md +10 -1
- package/pan-wizard-core/references/verification-patterns.md +5 -5
- package/pan-wizard-core/templates/codebase/architecture.md +2 -2
- package/pan-wizard-core/templates/codebase/best-practices.md +2 -2
- package/pan-wizard-core/templates/codebase/concerns.md +3 -3
- package/pan-wizard-core/templates/codebase/integrations.md +6 -6
- package/pan-wizard-core/templates/codebase/relationships.md +2 -2
- package/pan-wizard-core/templates/codebase/stack.md +2 -2
- package/pan-wizard-core/templates/codebase/structure.md +4 -4
- package/pan-wizard-core/templates/codebase/testing.md +4 -4
- package/pan-wizard-core/templates/continue-here.md +1 -1
- package/pan-wizard-core/templates/debug-subagent-prompt.md +3 -2
- package/pan-wizard-core/templates/debug.md +15 -1
- package/pan-wizard-core/templates/milestone-archive.md +6 -6
- package/pan-wizard-core/templates/milestone.md +1 -1
- package/pan-wizard-core/templates/phase-prompt.md +29 -17
- package/pan-wizard-core/templates/planner-subagent-prompt.md +3 -5
- package/pan-wizard-core/templates/research-project/architecture.md +3 -3
- package/pan-wizard-core/templates/research-project/features.md +1 -1
- package/pan-wizard-core/templates/research-project/pitfalls.md +1 -1
- package/pan-wizard-core/templates/research-project/stack.md +3 -3
- package/pan-wizard-core/templates/research.md +5 -5
- package/pan-wizard-core/templates/retrospective.md +1 -1
- package/pan-wizard-core/templates/roadmap.md +15 -15
- package/pan-wizard-core/templates/summary-complex.md +1 -0
- package/pan-wizard-core/templates/summary-minimal.md +1 -0
- package/pan-wizard-core/templates/summary-standard.md +2 -1
- package/pan-wizard-core/templates/summary.md +18 -2
- package/pan-wizard-core/templates/uat.md +3 -3
- package/pan-wizard-core/templates/user-setup.md +8 -8
- package/pan-wizard-core/templates/verification-report.md +53 -8
- package/pan-wizard-core/workflows/add-phase.md +1 -1
- package/pan-wizard-core/workflows/assumptions.md +1 -1
- package/pan-wizard-core/workflows/discuss-phase.md +9 -8
- package/pan-wizard-core/workflows/exec-phase.md +26 -57
- package/pan-wizard-core/workflows/execute-plan.md +8 -7
- package/pan-wizard-core/workflows/health.md +13 -12
- package/pan-wizard-core/workflows/help.md +8 -9
- package/pan-wizard-core/workflows/learn.md +13 -11
- package/pan-wizard-core/workflows/map-codebase.md +10 -10
- package/pan-wizard-core/workflows/milestone-audit.md +10 -10
- package/pan-wizard-core/workflows/milestone-gaps.md +17 -24
- package/pan-wizard-core/workflows/milestone-new.md +7 -6
- package/pan-wizard-core/workflows/new-project.md +9 -9
- package/pan-wizard-core/workflows/optimize.md +11 -9
- package/pan-wizard-core/workflows/pause.md +6 -1
- package/pan-wizard-core/workflows/phase-tests.md +4 -4
- package/pan-wizard-core/workflows/plan-phase.md +31 -34
- package/pan-wizard-core/workflows/profile.md +2 -2
- package/pan-wizard-core/workflows/progress.md +5 -7
- package/pan-wizard-core/workflows/quick.md +5 -3
- package/pan-wizard-core/workflows/remove-phase.md +1 -1
- package/pan-wizard-core/workflows/research-phase.md +9 -5
- package/pan-wizard-core/workflows/resume-project.md +4 -6
- package/pan-wizard-core/workflows/retro.md +3 -1
- package/pan-wizard-core/workflows/settings.md +16 -24
- package/pan-wizard-core/workflows/todo-check.md +1 -1
- package/pan-wizard-core/workflows/transition.md +12 -12
- package/pan-wizard-core/workflows/update.md +25 -17
- package/pan-wizard-core/workflows/verify-phase.md +17 -15
- package/pan-zcode/KNOWN-BETA-RISKS.md +2 -2
- package/pan-zcode/README.md +7 -7
- package/pan-zcode/bin/install-zcode.js +1 -1
- package/scripts/build-plugin.js +3 -1
- package/scripts/generate-skills-docs.py +13 -4
|
@@ -55,6 +55,12 @@ const TOKENS_FILE = 'tokens.jsonl';
|
|
|
55
55
|
* write at `cache_write` (market-ideas queue M13). OpenAI and Google report no
|
|
56
56
|
* lifetime split, so their rows carry none.
|
|
57
57
|
*/
|
|
58
|
+
// The prompt size above which a long-prompt rate applies (Haiku 5.5's tier). The
|
|
59
|
+
// cost-logger hook and `cost rebuild` record the tokens of every request over it as a
|
|
60
|
+
// row's `long_prompt`, with this figure as its `above`; hooks/pan-cost-logger.js keeps
|
|
61
|
+
// its own copy (hooks require nothing from lib), and a test pins the two equal.
|
|
62
|
+
const LONG_PROMPT_THRESHOLD = 100000;
|
|
63
|
+
|
|
58
64
|
const DEFAULT_RATES = {
|
|
59
65
|
// Anthropic — platform.claude.com/docs/en/about-claude/pricing, every row read
|
|
60
66
|
// 2026-09-23. Opus 4.6+/Opus 5 are $5/$25 (the old $15/$75 Opus pricing ended with
|
|
@@ -91,16 +97,24 @@ const DEFAULT_RATES = {
|
|
|
91
97
|
// 2026-08-31 was made permanent and the scheduled rise to $3/$15 cancelled.
|
|
92
98
|
// Lesson: never write down a pre-announced price — this row carried the future
|
|
93
99
|
// rate for a month and over-billed by half.
|
|
94
|
-
// Sonnet 5.5 (claude-sonnet-5-5):
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
'claude-sonnet-5-5': { input: 2.0, output: 10.0, cache_read: 0.
|
|
100
|
+
// Sonnet 5.5 (claude-sonnet-5-5): $2/$10, 5-minute writes $2.50, 1-hour $4, and
|
|
101
|
+
// hits at 0.05x input, $0.10 (pricing page, read 2026-10-10; Claude Code 2.1.296
|
|
102
|
+
// corrected its own figure from $0.20 the same week). Claude Code 2.1.284 points the
|
|
103
|
+
// `sonnet` alias at it on the Anthropic API (model-config, read 2026-10-03). Its own
|
|
104
|
+
// row is what keeps it off Sonnet 5's $0.20 hits through the family prefix.
|
|
105
|
+
'claude-sonnet-5-5': { input: 2.0, output: 10.0, cache_read: 0.10, cache_write: 2.50, cache_write_1h: 4.0 },
|
|
100
106
|
'claude-sonnet-5': { input: 2.0, output: 10.0, cache_read: 0.20, cache_write: 2.50, cache_write_1h: 4.0 },
|
|
101
107
|
'claude-sonnet-4-6': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75, cache_write_1h: 6.0 },
|
|
102
108
|
// Sonnet 4.5 (dated id claude-sonnet-4-5-20250929): $3/$15/$0.30/$3.75 (R7).
|
|
103
109
|
'claude-sonnet-4-5': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75, cache_write_1h: 6.0 },
|
|
110
|
+
// Haiku 5.5 (claude-haiku-5-5): $0.10/$0.50, 5-minute writes $0.125, 1-hour $0.20,
|
|
111
|
+
// hits $0.01 — and every rate 5x for a request whose prompt is over 100,000 tokens,
|
|
112
|
+
// counting cache reads and writes (pricing page "Long context pricing", read
|
|
113
|
+
// 2026-10-10). Claude Code 2.1.293 makes it the `haiku` alias on the Anthropic API,
|
|
114
|
+
// which is PAN's fast tier there. `long` prices the part of a row that came from
|
|
115
|
+
// such requests (`long_prompt`, see computeCost).
|
|
116
|
+
'claude-haiku-5-5': { input: 0.10, output: 0.50, cache_read: 0.01, cache_write: 0.125, cache_write_1h: 0.20,
|
|
117
|
+
long: { above: LONG_PROMPT_THRESHOLD, input: 0.50, output: 2.50, cache_read: 0.05, cache_write: 0.625, cache_write_1h: 1.0 } },
|
|
104
118
|
// Haiku 4.5: API id claude-haiku-4-5-20251001, alias claude-haiku-4-5.
|
|
105
119
|
'claude-haiku-4-5': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25, cache_write_1h: 2.0 },
|
|
106
120
|
|
|
@@ -242,6 +256,7 @@ const scaleRate = (rate, m) => ({
|
|
|
242
256
|
cache_read: round6(rate.cache_read * m),
|
|
243
257
|
cache_write: round6(rate.cache_write * m),
|
|
244
258
|
...(typeof rate.cache_write_1h === 'number' ? { cache_write_1h: round6(rate.cache_write_1h * m) } : {}),
|
|
259
|
+
...(rate.long && typeof rate.long === 'object' ? { long: { above: rate.long.above, ...scaleRate(rate.long, m) } } : {}),
|
|
245
260
|
});
|
|
246
261
|
|
|
247
262
|
/**
|
|
@@ -388,12 +403,35 @@ function computeCost(rec, configRates) {
|
|
|
388
403
|
// bills at the one-hour rate; the rest — and every write on a row or rate without
|
|
389
404
|
// the split — at `cache_write`.
|
|
390
405
|
const write1h = Math.min(cacheWrite, Math.max(0, Number(rec.cache_write_1h_tokens) || 0));
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
406
|
+
const tokens = { input, output, cacheRead, cacheWrite, write1h };
|
|
407
|
+
// A long-prompt tier (Haiku 5.5): the row's `long_prompt` holds the tokens of the
|
|
408
|
+
// requests whose prompt was over the threshold, and those bill at the long rates.
|
|
409
|
+
// Measured at the same threshold only — a row split at another figure cannot be
|
|
410
|
+
// re-cut here, so it prices at the base rates as before.
|
|
411
|
+
const lp = rec.long_prompt;
|
|
412
|
+
let usd = 0;
|
|
413
|
+
if (rate.long && lp && typeof lp === 'object' && Number(lp.above) === Number(rate.long.above)) {
|
|
414
|
+
const part = {
|
|
415
|
+
input: Math.min(input, Number(lp.input_tokens) || 0),
|
|
416
|
+
output: Math.min(output, Number(lp.output_tokens) || 0),
|
|
417
|
+
cacheRead: Math.min(cacheRead, Number(lp.cache_read_tokens) || 0),
|
|
418
|
+
cacheWrite: Math.min(cacheWrite, Number(lp.cache_write_tokens) || 0),
|
|
419
|
+
};
|
|
420
|
+
part.write1h = Math.min(part.cacheWrite, write1h, Number(lp.cache_write_1h_tokens) || 0);
|
|
421
|
+
usd += priceTokens(part, rate.long);
|
|
422
|
+
for (const k of Object.keys(part)) tokens[k] -= part[k];
|
|
423
|
+
}
|
|
424
|
+
usd += priceTokens(tokens, rate);
|
|
394
425
|
return Math.round(usd * 10000) / 10000;
|
|
395
426
|
}
|
|
396
427
|
|
|
428
|
+
/** USD for disjoint token counts at one rate row (1-hour writes at their own rate). */
|
|
429
|
+
function priceTokens(t, rate) {
|
|
430
|
+
const rate1h = typeof rate.cache_write_1h === 'number' ? rate.cache_write_1h : rate.cache_write;
|
|
431
|
+
return (t.input * rate.input + t.output * rate.output + t.cacheRead * rate.cache_read
|
|
432
|
+
+ (t.cacheWrite - t.write1h) * rate.cache_write + t.write1h * rate1h) / 1_000_000;
|
|
433
|
+
}
|
|
434
|
+
|
|
397
435
|
/**
|
|
398
436
|
* Append a cost record. Non-blocking — errors are swallowed so instrumentation
|
|
399
437
|
* never breaks the caller.
|
|
@@ -532,7 +570,11 @@ function aggregate(cwd, opts) {
|
|
|
532
570
|
const records = readRecords(cwd);
|
|
533
571
|
const malformedSkipped = _lastReadMalformed; // captured before any later read
|
|
534
572
|
const since = opts?.since ? new Date(opts.since).getTime() : null;
|
|
535
|
-
|
|
573
|
+
// A bare date is that day's first instant, so as an upper bound it left out the
|
|
574
|
+
// whole day the docs call inclusive: read it as the day's last millisecond.
|
|
575
|
+
const until = opts?.until
|
|
576
|
+
? new Date(/^\d{4}-\d{2}-\d{2}$/.test(String(opts.until)) ? `${opts.until}T23:59:59.999Z` : opts.until).getTime()
|
|
577
|
+
: null;
|
|
536
578
|
const config = loadConfig(cwd);
|
|
537
579
|
const configRates = effectiveRates(config);
|
|
538
580
|
|
|
@@ -699,10 +741,12 @@ function renderChart(agg) {
|
|
|
699
741
|
function cmdCostReport(cwd, opts, raw) {
|
|
700
742
|
const format = opts?.format || 'json';
|
|
701
743
|
const agg = aggregate(cwd, opts);
|
|
744
|
+
// `--format table|chart` asks for text, so it prints text with or without
|
|
745
|
+
// `--raw`; without `--raw` it used to print the JSON report unchanged.
|
|
702
746
|
if (format === 'table') {
|
|
703
|
-
output(agg,
|
|
747
|
+
output(agg, true, renderTable(agg));
|
|
704
748
|
} else if (format === 'chart') {
|
|
705
|
-
output(agg,
|
|
749
|
+
output(agg, true, renderChart(agg));
|
|
706
750
|
} else {
|
|
707
751
|
output(agg, raw);
|
|
708
752
|
}
|
|
@@ -722,6 +766,86 @@ function cmdCostClear(cwd, raw) {
|
|
|
722
766
|
}
|
|
723
767
|
}
|
|
724
768
|
|
|
769
|
+
// ─── Host usage limits (Claude Code subscriptions) ───────────────────────────
|
|
770
|
+
//
|
|
771
|
+
// Claude Code's status-line payload carries `rate_limits.five_hour` and
|
|
772
|
+
// `rate_limits.seven_day` (`used_percentage`, `resets_at` in epoch seconds;
|
|
773
|
+
// code.claude.com/docs/en/statusline, read 2026-10-10). On a subscription those
|
|
774
|
+
// windows, not dollars, are what a long campaign runs out of. PAN's status line
|
|
775
|
+
// writes them, account-wide, to claude-limits.json in the per-user pan-hooks
|
|
776
|
+
// directory; campaigns read them here. Headless `claude -p` runs no status line, so
|
|
777
|
+
// there the file goes stale and nothing stops on it.
|
|
778
|
+
|
|
779
|
+
const HOST_LIMITS_FILE = 'claude-limits.json';
|
|
780
|
+
const HOST_LIMITS_FRESH_SECONDS = 15 * 60;
|
|
781
|
+
const DEFAULT_WEEKLY_LIMIT_STOP_PCT = 90;
|
|
782
|
+
|
|
783
|
+
/** The per-user pan-hooks directory when it is safe to read (ours, not a symlink, owner-only), else null. */
|
|
784
|
+
function hooksDirIfSafe(tmpDir) {
|
|
785
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : (process.env.USERNAME || 'win');
|
|
786
|
+
const dir = path.join(tmpDir || require('os').tmpdir(), `pan-hooks-${uid}`);
|
|
787
|
+
try {
|
|
788
|
+
const st = fs.lstatSync(dir);
|
|
789
|
+
if (st.isSymbolicLink() || !st.isDirectory()) return null;
|
|
790
|
+
if (typeof process.getuid === 'function' && (st.uid !== process.getuid() || (st.mode & 0o077) !== 0)) return null;
|
|
791
|
+
return dir;
|
|
792
|
+
} catch { return null; }
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* The host's usage-limit windows as the status line last saw them, or null when no
|
|
797
|
+
* status line has written them. A window whose `resets_at` has passed reads as reset
|
|
798
|
+
* (used 0); the whole reading is `stale` past HOST_LIMITS_FRESH_SECONDS.
|
|
799
|
+
* @param {{tmpDir?: string, now?: number}} [opts] - now in epoch seconds
|
|
800
|
+
*/
|
|
801
|
+
function readHostLimits(opts = {}) {
|
|
802
|
+
const dir = hooksDirIfSafe(opts.tmpDir);
|
|
803
|
+
if (!dir) return null;
|
|
804
|
+
let raw;
|
|
805
|
+
try { raw = JSON.parse(fs.readFileSync(path.join(dir, HOST_LIMITS_FILE), 'utf8')); } catch { return null; }
|
|
806
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
807
|
+
const now = Number.isFinite(opts.now) ? opts.now : Math.floor(Date.now() / 1000);
|
|
808
|
+
const win = (w) => {
|
|
809
|
+
if (!w || typeof w !== 'object' || !Number.isFinite(Number(w.used_percentage))) return null;
|
|
810
|
+
const resetsAt = Number.isFinite(Number(w.resets_at)) ? Number(w.resets_at) : null;
|
|
811
|
+
const reset = resetsAt !== null && resetsAt <= now;
|
|
812
|
+
return { used_pct: reset ? 0 : Math.max(0, Math.min(100, Number(w.used_percentage))), resets_at: resetsAt, reset_since_read: reset };
|
|
813
|
+
};
|
|
814
|
+
const age = Number.isFinite(Number(raw.timestamp)) ? now - Number(raw.timestamp) : null;
|
|
815
|
+
return {
|
|
816
|
+
five_hour: win(raw.five_hour),
|
|
817
|
+
seven_day: win(raw.seven_day),
|
|
818
|
+
age_seconds: age,
|
|
819
|
+
stale: age === null || age > HOST_LIMITS_FRESH_SECONDS,
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Whether a campaign should stop on the 7-day window: a fresh reading at or past
|
|
825
|
+
* `cost.weekly_limit_stop_pct` (default 90; 0 or false turns it off).
|
|
826
|
+
*/
|
|
827
|
+
function weeklyLimitStop(cwd, limits = readHostLimits()) {
|
|
828
|
+
let pct = DEFAULT_WEEKLY_LIMIT_STOP_PCT;
|
|
829
|
+
try {
|
|
830
|
+
const v = loadConfig(cwd)?.cost?.weekly_limit_stop_pct;
|
|
831
|
+
if (v === false || v === 0) pct = 0;
|
|
832
|
+
else if (Number.isFinite(Number(v)) && Number(v) > 0 && Number(v) <= 100) pct = Number(v);
|
|
833
|
+
} catch { /* defaults */ }
|
|
834
|
+
const used = limits && !limits.stale && limits.seven_day ? limits.seven_day.used_pct : null;
|
|
835
|
+
return { stop: pct > 0 && used !== null && used >= pct, threshold_pct: pct, seven_day_used_pct: used };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function cmdCostLimits(cwd, raw) {
|
|
839
|
+
const limits = readHostLimits();
|
|
840
|
+
const stop = weeklyLimitStop(cwd, limits);
|
|
841
|
+
output({
|
|
842
|
+
available: !!limits,
|
|
843
|
+
...(limits || { five_hour: null, seven_day: null, age_seconds: null, stale: true }),
|
|
844
|
+
weekly_limit_stop_pct: stop.threshold_pct,
|
|
845
|
+
weekly_limit_reached: stop.stop,
|
|
846
|
+
}, raw, stop.stop ? 'weekly_limit_reached' : (limits ? 'ok' : 'no_reading'));
|
|
847
|
+
}
|
|
848
|
+
|
|
725
849
|
// ─── Rate-table staleness ───────────────────────────────────────────────────
|
|
726
850
|
|
|
727
851
|
// Date DEFAULT_RATES was last verified against published provider pricing — ALL
|
|
@@ -738,7 +862,7 @@ function cmdCostClear(cwd, raw) {
|
|
|
738
862
|
// a default-model change inside the window — that is the job of the
|
|
739
863
|
// documented-default-ids fixture test (tests/fixtures/documented-default-models.json,
|
|
740
864
|
// R31), which fails the suite the day a documented default id has no exact row.
|
|
741
|
-
const RATES_VERIFIED_AT = '2026-
|
|
865
|
+
const RATES_VERIFIED_AT = '2026-10-10';
|
|
742
866
|
const RATES_STALE_AFTER_DAYS = 60;
|
|
743
867
|
const RATE_TIERS = ['reasoning', 'mid', 'fast'];
|
|
744
868
|
|
|
@@ -784,6 +908,11 @@ module.exports = {
|
|
|
784
908
|
renderTable,
|
|
785
909
|
renderChart,
|
|
786
910
|
resolveRate,
|
|
911
|
+
LONG_PROMPT_THRESHOLD,
|
|
912
|
+
readHostLimits,
|
|
913
|
+
weeklyLimitStop,
|
|
914
|
+
cmdCostLimits,
|
|
915
|
+
HOST_LIMITS_FILE,
|
|
787
916
|
ratesFromModelPricing,
|
|
788
917
|
managedSettingsDir,
|
|
789
918
|
loadManagedModelPricing,
|
|
@@ -21,8 +21,10 @@ const SCANNABLE_EXTS = ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py'];
|
|
|
21
21
|
|
|
22
22
|
// ─── Pass 1: Deterministic static analysis ───────────────────────────────────
|
|
23
23
|
|
|
24
|
-
// Calls
|
|
25
|
-
// try/catch wrapping only such a call is
|
|
24
|
+
// Calls the matcher treats as non-throwing: none of them throws on a string or a
|
|
25
|
+
// number, so a try/catch wrapping only such a call is usually phantom. Number, String,
|
|
26
|
+
// parseInt and parseFloat do throw on an object whose toString throws (all but String
|
|
27
|
+
// on a Symbol too), which is why findPhantomTryCatch tiers it review_required.
|
|
26
28
|
// Deliberately EXCLUDES throwing calls — JSON.parse (SyntaxError on bad input),
|
|
27
29
|
// JSON.stringify (TypeError on circular refs / BigInt), new RegExp, fs.*Sync,
|
|
28
30
|
// etc. — because their try/catch is load-bearing and must not be flagged for
|
|
@@ -431,7 +433,7 @@ function writePatternsMemory(cwd, findings, opts) {
|
|
|
431
433
|
const lines = [
|
|
432
434
|
'---',
|
|
433
435
|
'name: distill-patterns',
|
|
434
|
-
'description: Cross-session memory of AI-bloat patterns detected
|
|
436
|
+
'description: Cross-session memory of AI-bloat patterns detected (it records detections, not fixes)',
|
|
435
437
|
'type: project',
|
|
436
438
|
'---',
|
|
437
439
|
'',
|
|
@@ -520,6 +522,7 @@ module.exports = {
|
|
|
520
522
|
findUnreferencedExports,
|
|
521
523
|
SAFETY_TIERS,
|
|
522
524
|
DEFAULT_BLOAT_THRESHOLD,
|
|
525
|
+
PATTERNS_FILE,
|
|
523
526
|
MAX_FUNCTION_LOC,
|
|
524
527
|
MAX_PARAM_COUNT,
|
|
525
528
|
MAX_NESTING_DEPTH,
|
|
@@ -44,11 +44,21 @@ const DEFAULT_SCHEMA_PATH = path.join(
|
|
|
44
44
|
* calls it must pass its verdict as output()'s 4th argument; a bare
|
|
45
45
|
* process.exit() after an output() call is unreachable.
|
|
46
46
|
*/
|
|
47
|
+
/**
|
|
48
|
+
* A missing path or a file where a directory is expected ends in error(), not in the
|
|
49
|
+
* stack trace walkMarkdownFiles' throw used to print (`doc-lint counts README.md`).
|
|
50
|
+
* @param {string} targetDir
|
|
51
|
+
*/
|
|
52
|
+
function requireDirectory(targetDir) {
|
|
53
|
+
let stat = null;
|
|
54
|
+
try { stat = fs.statSync(targetDir); } catch { /* absent */ }
|
|
55
|
+
if (!stat) error(`directory not found: ${targetDir}`);
|
|
56
|
+
if (!stat.isDirectory()) error(`not a directory: ${targetDir} — doc-lint scans a directory of markdown files`);
|
|
57
|
+
}
|
|
58
|
+
|
|
47
59
|
function cmdDocLint(cwd, dir, opts = {}) {
|
|
48
60
|
const targetDir = path.isAbsolute(dir) ? dir : path.join(cwd, dir);
|
|
49
|
-
|
|
50
|
-
error(`directory not found: ${targetDir}`);
|
|
51
|
-
}
|
|
61
|
+
requireDirectory(targetDir);
|
|
52
62
|
|
|
53
63
|
const schemaPath = opts.schema
|
|
54
64
|
? (path.isAbsolute(opts.schema) ? opts.schema : path.join(cwd, opts.schema))
|
|
@@ -209,7 +219,6 @@ function isCountAllowed(relativePath) {
|
|
|
209
219
|
// Things that LOOK like counts but are stable identities (allowed everywhere):
|
|
210
220
|
const STABLE_IDENTITIES = [
|
|
211
221
|
/\b5\s+(target\s+)?runtimes\b/i, // 5 target runtimes
|
|
212
|
-
/\b5\s+hooks\b/i, // 5 hooks (named individually)
|
|
213
222
|
/\bLAYER\s+\d+\b/, // architecture layer labels
|
|
214
223
|
/\b5\s+(parallel\s+)?(researchers?|research\s+)/i, // 5 parallel researchers
|
|
215
224
|
/\b6\s+(parallel\s+)?agents\b/i, // 6 parallel agents (codebase mapper)
|
|
@@ -218,9 +227,17 @@ const STABLE_IDENTITIES = [
|
|
|
218
227
|
/\bthree\s+phases\b|\bfour\s+phases\b/i, // generic phase counts in narrative
|
|
219
228
|
];
|
|
220
229
|
|
|
221
|
-
function isStableIdentity(matchText, surrounding) {
|
|
230
|
+
function isStableIdentity(matchText, surrounding, matchIndex = 0) {
|
|
222
231
|
for (const re of STABLE_IDENTITIES) {
|
|
223
|
-
if (re.test(matchText)
|
|
232
|
+
if (re.test(matchText)) return true;
|
|
233
|
+
// Only an identity that overlaps the count excuses it ("LAYER 6 modules"). One
|
|
234
|
+
// elsewhere on the line excused every count there: "52 commands on all 5 runtimes".
|
|
235
|
+
const all = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
|
|
236
|
+
let s;
|
|
237
|
+
while ((s = all.exec(surrounding)) !== null) {
|
|
238
|
+
if (s.index < matchIndex + matchText.length && matchIndex < s.index + s[0].length) return true;
|
|
239
|
+
if (s[0].length === 0) all.lastIndex++;
|
|
240
|
+
}
|
|
224
241
|
}
|
|
225
242
|
return false;
|
|
226
243
|
}
|
|
@@ -233,9 +250,7 @@ function isStableIdentity(matchText, surrounding) {
|
|
|
233
250
|
*/
|
|
234
251
|
function cmdDocLintCounts(cwd, dir, opts = {}) {
|
|
235
252
|
const targetDir = path.isAbsolute(dir) ? dir : path.join(cwd, dir);
|
|
236
|
-
|
|
237
|
-
error(`directory not found: ${targetDir}`);
|
|
238
|
-
}
|
|
253
|
+
requireDirectory(targetDir);
|
|
239
254
|
|
|
240
255
|
const exclude = opts.exclude || [];
|
|
241
256
|
const files = walkMarkdownFiles(targetDir, { exclude });
|
|
@@ -260,8 +275,8 @@ function cmdDocLintCounts(cwd, dir, opts = {}) {
|
|
|
260
275
|
re.lastIndex = 0; // reset for /g
|
|
261
276
|
let m;
|
|
262
277
|
while ((m = re.exec(line)) !== null) {
|
|
263
|
-
// Skip
|
|
264
|
-
if (isStableIdentity(m[0], line)) continue;
|
|
278
|
+
// Skip a count that is part of a stable identity
|
|
279
|
+
if (isStableIdentity(m[0], line, m.index)) continue;
|
|
265
280
|
violations.push({
|
|
266
281
|
file: file.relativePath,
|
|
267
282
|
line: i + 1,
|
|
@@ -363,6 +378,10 @@ function scanDocFlags(cwd, opts = {}) {
|
|
|
363
378
|
}
|
|
364
379
|
|
|
365
380
|
function cmdDocLintFlags(cwd, opts = {}, raw) {
|
|
381
|
+
for (const d of opts.docDirs || []) {
|
|
382
|
+
const abs = path.isAbsolute(d) ? d : path.join(cwd, d);
|
|
383
|
+
if (fs.existsSync(abs)) requireDirectory(abs);
|
|
384
|
+
}
|
|
366
385
|
const r = scanDocFlags(cwd, opts);
|
|
367
386
|
if (raw) {
|
|
368
387
|
if (r.violation_count === 0) {
|
|
@@ -276,6 +276,13 @@ function disposeFindings(cwd, input) {
|
|
|
276
276
|
return { contract: V.VERDICT_CONTRACT, disposition: as, disposed, skipped };
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Milestone versions compare with or without the leading `v`: the ledger stores
|
|
281
|
+
* `getMilestoneInfo`'s `v1.0`, while milestone-audit names its version `1.0` (its
|
|
282
|
+
* report is `v{version}-milestone-audit.md`), so `--milestone 1.0` found no debt.
|
|
283
|
+
*/
|
|
284
|
+
const sameMilestone = (a, b) => String(a || '').trim().replace(/^v/i, '') === String(b || '').trim().replace(/^v/i, '');
|
|
285
|
+
|
|
279
286
|
/** Findings with their folded status, filtered. */
|
|
280
287
|
function listFindings(cwd, filters = {}) {
|
|
281
288
|
const { rows, malformed } = readLedger(cwd);
|
|
@@ -285,7 +292,7 @@ function listFindings(cwd, filters = {}) {
|
|
|
285
292
|
if (filters.agent) list = list.filter((f) => f.agent === filters.agent);
|
|
286
293
|
if (filters.status) list = list.filter((f) => f.status === filters.status);
|
|
287
294
|
if (filters.class) list = list.filter((f) => f.class === filters.class);
|
|
288
|
-
if (filters.milestone) list = list.filter((f) => f.milestone
|
|
295
|
+
if (filters.milestone) list = list.filter((f) => sameMilestone(f.milestone, filters.milestone));
|
|
289
296
|
const findings = list.map(publicFinding);
|
|
290
297
|
return {
|
|
291
298
|
contract: V.VERDICT_CONTRACT,
|
|
@@ -306,7 +313,7 @@ function findingsDebt(cwd, opts = {}) {
|
|
|
306
313
|
const fold = foldFindings(rows);
|
|
307
314
|
const byPhase = new Map();
|
|
308
315
|
for (const f of fold.findings.values()) {
|
|
309
|
-
if (milestone && f.milestone
|
|
316
|
+
if (milestone && !sameMilestone(f.milestone, milestone)) continue;
|
|
310
317
|
if (f.status !== 'deferred' && f.status !== 'open') continue;
|
|
311
318
|
const key = V.comparablePhase(f.phase);
|
|
312
319
|
if (!byPhase.has(key)) byPhase.set(key, { phase: f.phase, deferred: [], open: [] });
|
|
@@ -22,6 +22,7 @@ const {
|
|
|
22
22
|
const { extractFrontmatter, extractPriorityEffort } = require('./frontmatter.cjs');
|
|
23
23
|
const { enumerateRoadmapPhases } = require('./roadmap.cjs');
|
|
24
24
|
const { readErrorPatterns } = require('./commands.cjs');
|
|
25
|
+
const { weeklyLimitStop } = require('./cost.cjs');
|
|
25
26
|
const { planningPath, listPhaseDirs, classifyPhaseStatus, filterPlanFiles, filterSummaryFiles, planningRel } = require('./utils.cjs');
|
|
26
27
|
|
|
27
28
|
// ─── Scan helpers ───────────────────────────────────────────────────────────
|
|
@@ -302,10 +303,13 @@ function allocateBudget(items, budget, mode) {
|
|
|
302
303
|
} else if (mode === 'features') {
|
|
303
304
|
const { picked: mPicked, used: mUsed } = allocatePass(items, budget, { maxPriority: 0, minPriority: 0, track: 'mandatory' });
|
|
304
305
|
addToBatch(mPicked);
|
|
305
|
-
|
|
306
|
+
// The feature/stability split shares what the P0 pass left: splitting the
|
|
307
|
+
// whole budget again let a batch reach nearly twice the budget.
|
|
308
|
+
const rest = budget - mUsed;
|
|
309
|
+
const featureBudget = Math.floor(rest * FEATURE_RATIO);
|
|
306
310
|
const { picked: fPicked, used: fUsed } = allocatePass(items, featureBudget, { minPriority: 3, maxPriority: 5, track: 'feature', exclude: batchIds });
|
|
307
311
|
addToBatch(fPicked);
|
|
308
|
-
const { picked: sPicked, used: sUsed } = allocatePass(items,
|
|
312
|
+
const { picked: sPicked, used: sUsed } = allocatePass(items, rest - featureBudget, { minPriority: 1, maxPriority: 2, track: 'stability', exclude: batchIds });
|
|
309
313
|
addToBatch(sPicked);
|
|
310
314
|
allocated = mUsed + fUsed + sUsed;
|
|
311
315
|
} else {
|
|
@@ -920,9 +924,14 @@ function focusAutoUpdate(cwd, raw, getVal) {
|
|
|
920
924
|
run.totals.tests_current = cycle.tests_after;
|
|
921
925
|
run.status = AUTORUN_STATUSES.IN_PROGRESS;
|
|
922
926
|
|
|
923
|
-
|
|
927
|
+
// The host's 7-day usage window (Claude Code subscriptions, read from what PAN's
|
|
928
|
+
// status line last saw): past cost.weekly_limit_stop_pct the run stops resumable,
|
|
929
|
+
// so the rest of the window stays the user's. No fresh reading never stops it.
|
|
930
|
+
const weekly = weeklyLimitStop(cwd);
|
|
931
|
+
cycle.weekly_limit_used_pct = weekly.seven_day_used_pct;
|
|
932
|
+
const stopReason = determineStopReason(cycle, run) || (weekly.stop ? 'weekly_limit' : null);
|
|
924
933
|
if (stopReason) {
|
|
925
|
-
run.status = stopReason === 'regression' ? AUTORUN_STATUSES.STOPPED : AUTORUN_STATUSES.COMPLETED;
|
|
934
|
+
run.status = stopReason === 'regression' || stopReason === 'weekly_limit' ? AUTORUN_STATUSES.STOPPED : AUTORUN_STATUSES.COMPLETED;
|
|
926
935
|
run.stop_reason = stopReason;
|
|
927
936
|
}
|
|
928
937
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
-
const { safeReadFile, output, error, escapeRegex } = require('./core.cjs');
|
|
7
|
+
const { safeReadFile, output, error, escapeRegex, toLf, dominantEol, withEol } = require('./core.cjs');
|
|
8
8
|
const { FIELD_VALUE_RE, PRIORITY_LEVELS, EFFORT_SIZES } = require('./constants.cjs');
|
|
9
9
|
|
|
10
10
|
// --- Inline array rendering thresholds -------------------------------------------
|
|
@@ -169,7 +169,21 @@ function renderYamlEntry(lines, key, value, depth) {
|
|
|
169
169
|
lines.push(`${indent}${key}:`);
|
|
170
170
|
const itemIndent = ' '.repeat(depth + 1);
|
|
171
171
|
for (const item of value) {
|
|
172
|
-
|
|
172
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
173
|
+
// A list of maps (a plan's must_haves artifacts and key_links): the first
|
|
174
|
+
// key on the dash line, the rest under it. Written as a scalar, each item
|
|
175
|
+
// came out "[object Object]".
|
|
176
|
+
const entries = Object.entries(item).filter(([, v]) => v !== null && v !== undefined);
|
|
177
|
+
entries.forEach(([k, v], idx) => {
|
|
178
|
+
// An array value goes out as a flow sequence of JSON strings, which the
|
|
179
|
+
// parser reads back as an array (`exports: ["GET", "POST"]`).
|
|
180
|
+
const text = Array.isArray(v) ? `[${v.map((x) => JSON.stringify(String(x))).join(', ')}]`
|
|
181
|
+
: quoteIfNeeded(typeof v === 'object' ? JSON.stringify(v) : v);
|
|
182
|
+
lines.push(`${itemIndent}${idx === 0 ? '- ' : ' '}${k}: ${text}`);
|
|
183
|
+
});
|
|
184
|
+
} else {
|
|
185
|
+
lines.push(`${itemIndent}- ${quoteIfNeeded(item)}`);
|
|
186
|
+
}
|
|
173
187
|
}
|
|
174
188
|
}
|
|
175
189
|
} else if (typeof value === 'object') {
|
|
@@ -201,34 +215,76 @@ function quoteIfNeeded(value) {
|
|
|
201
215
|
*/
|
|
202
216
|
function spliceFrontmatter(content, newObj) {
|
|
203
217
|
const yamlStr = reconstructFrontmatter(newObj);
|
|
204
|
-
|
|
218
|
+
// Match on LF and give the file back in its own ending. The LF-only match
|
|
219
|
+
// missed the block in a CRLF file, so `frontmatter set` and `merge` stacked a
|
|
220
|
+
// second frontmatter on top of the first instead of replacing it.
|
|
221
|
+
const eol = dominantEol(content);
|
|
222
|
+
const text = toLf(content);
|
|
223
|
+
const match = text.match(/^---\n[\s\S]+?\n---/);
|
|
205
224
|
if (match) {
|
|
206
|
-
return `---\n${yamlStr}\n---` +
|
|
225
|
+
return withEol(`---\n${yamlStr}\n---` + text.slice(match[0].length), eol);
|
|
226
|
+
}
|
|
227
|
+
return withEol(`---\n${yamlStr}\n---\n\n` + text, eol);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A YAML scalar as written in a must_haves value: a double-quoted one unescapes `\\`
|
|
232
|
+
* and `\"`, a single-quoted one `''`, a plain one is taken as written. The raw `\\.`
|
|
233
|
+
* of the shipped key-link example reached `new RegExp` in `verify key-links`, so
|
|
234
|
+
* `pattern: "prisma\\.message\\.(find|create)"` never matched.
|
|
235
|
+
* @param {string} raw - The value text after `key:` (or after `- `)
|
|
236
|
+
* @returns {string}
|
|
237
|
+
*/
|
|
238
|
+
function yamlScalar(raw) {
|
|
239
|
+
const s = String(raw).trim();
|
|
240
|
+
// A flow sequence (`exports: ["GET", "POST"]`, as the plan template writes it) is an
|
|
241
|
+
// array of scalars: taken whole it was one string, and `verify artifacts` then
|
|
242
|
+
// reported every export missing.
|
|
243
|
+
if (s.length >= 2 && s.startsWith('[') && s.endsWith(']')) {
|
|
244
|
+
const items = [];
|
|
245
|
+
let cur = '';
|
|
246
|
+
let quote = null;
|
|
247
|
+
const inner = s.slice(1, -1);
|
|
248
|
+
for (let i = 0; i < inner.length; i++) {
|
|
249
|
+
const c = inner[i];
|
|
250
|
+
if (quote) {
|
|
251
|
+
cur += c;
|
|
252
|
+
if (c === '\\' && quote === '"' && i + 1 < inner.length) cur += inner[++i];
|
|
253
|
+
else if (c === quote) quote = null;
|
|
254
|
+
} else if (c === '"' || c === "'") { quote = c; cur += c; }
|
|
255
|
+
else if (c === ',') { items.push(cur); cur = ''; }
|
|
256
|
+
else cur += c;
|
|
257
|
+
}
|
|
258
|
+
items.push(cur);
|
|
259
|
+
return items.map((x) => yamlScalar(x)).filter((x) => x !== '');
|
|
207
260
|
}
|
|
208
|
-
return
|
|
261
|
+
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1).replace(/\\(["\\])/g, '$1');
|
|
262
|
+
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1).replace(/''/g, "'");
|
|
263
|
+
return s;
|
|
209
264
|
}
|
|
210
265
|
|
|
211
266
|
/**
|
|
212
267
|
* Parse a specific block (artifacts, key_links, truths) from must_haves in raw YAML frontmatter.
|
|
213
268
|
*
|
|
214
|
-
* Block structure being parsed:
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* provides: bar (level 3+ -- 8-space indent, continuation k-v pairs)
|
|
269
|
+
* Block structure being parsed (indents are relative, never fixed columns):
|
|
270
|
+
* must_haves:
|
|
271
|
+
* artifacts: (the blockName, deeper than must_haves)
|
|
272
|
+
* - path: foo (list items)
|
|
273
|
+
* provides: bar (continuation k-v pairs, deeper than the item's dash)
|
|
220
274
|
*
|
|
221
|
-
* This function finds the named block
|
|
222
|
-
*
|
|
223
|
-
*
|
|
275
|
+
* This function finds the named block under must_haves, then parses its list
|
|
276
|
+
* items and their nested key-value pairs into an array of objects or strings.
|
|
277
|
+
* Values go through yamlScalar.
|
|
224
278
|
*
|
|
225
279
|
* @param {string} content - Full markdown content with frontmatter
|
|
226
280
|
* @param {string} blockName - Block name to extract (e.g., "artifacts", "key_links")
|
|
227
281
|
* @returns {Array} Parsed array of block items (objects or strings)
|
|
228
282
|
*/
|
|
229
283
|
function parseMustHavesBlock(content, blockName) {
|
|
230
|
-
// Extract raw YAML between --- delimiters
|
|
231
|
-
|
|
284
|
+
// Extract raw YAML between --- delimiters. On LF: a CRLF plan (every Windows
|
|
285
|
+
// field project) matched nothing here, so its must_haves read as empty and the
|
|
286
|
+
// reconcile gate below trusted the verdict again, as it did before the indent fix.
|
|
287
|
+
const fmMatch = toLf(content).match(/^---\n([\s\S]+?)\n---/);
|
|
232
288
|
if (!fmMatch) return [];
|
|
233
289
|
|
|
234
290
|
const yaml = fmMatch[1];
|
|
@@ -290,18 +346,18 @@ function parseMustHavesBlock(content, blockName) {
|
|
|
290
346
|
|
|
291
347
|
const body = dashMatch[2];
|
|
292
348
|
// A plain string item — no `key: value` shape.
|
|
293
|
-
const kvMatch = body.match(/^(\w+):\s*
|
|
349
|
+
const kvMatch = body.match(/^(\w+):\s*(.*?)\s*$/);
|
|
294
350
|
if (kvMatch) {
|
|
295
351
|
currentItem = {};
|
|
296
|
-
currentItem[kvMatch[1]] = kvMatch[2];
|
|
352
|
+
currentItem[kvMatch[1]] = yamlScalar(kvMatch[2]);
|
|
297
353
|
} else {
|
|
298
|
-
currentItem = body
|
|
354
|
+
currentItem = yamlScalar(body);
|
|
299
355
|
}
|
|
300
356
|
} else if (currentItem && typeof currentItem === 'object') {
|
|
301
357
|
// Continuation key-value: deeper than the item's dash (properties of it)
|
|
302
|
-
const kvMatch = line.match(/^\s+(\w+):\s*
|
|
358
|
+
const kvMatch = line.match(/^\s+(\w+):\s*(.*?)\s*$/);
|
|
303
359
|
if (kvMatch) {
|
|
304
|
-
const val = kvMatch[2];
|
|
360
|
+
const val = yamlScalar(kvMatch[2]);
|
|
305
361
|
// Coerce pure-integer strings to numbers for convenience
|
|
306
362
|
currentItem[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val;
|
|
307
363
|
}
|
|
@@ -309,7 +365,7 @@ function parseMustHavesBlock(content, blockName) {
|
|
|
309
365
|
// already deeper than the list-item dash (a shallower one would have been
|
|
310
366
|
// treated as a new item above), so relative depth alone identifies it —
|
|
311
367
|
// no fixed column.
|
|
312
|
-
const arrMatch = line.match(/^\s+-\s+
|
|
368
|
+
const arrMatch = line.match(/^\s+-\s+(.+?)\s*$/);
|
|
313
369
|
if (arrMatch) {
|
|
314
370
|
// Convert the most recently added key's scalar value into an array,
|
|
315
371
|
// then append this item to that array
|
|
@@ -318,7 +374,7 @@ function parseMustHavesBlock(content, blockName) {
|
|
|
318
374
|
if (lastKey && !Array.isArray(currentItem[lastKey])) {
|
|
319
375
|
currentItem[lastKey] = currentItem[lastKey] ? [currentItem[lastKey]] : [];
|
|
320
376
|
}
|
|
321
|
-
if (lastKey) currentItem[lastKey].push(arrMatch[1]);
|
|
377
|
+
if (lastKey) currentItem[lastKey].push(yamlScalar(arrMatch[1]));
|
|
322
378
|
}
|
|
323
379
|
}
|
|
324
380
|
}
|
|
@@ -354,6 +410,27 @@ const FRONTMATTER_SCHEMAS = {
|
|
|
354
410
|
verification: { required: ['phase', 'verified', 'status', 'score'] },
|
|
355
411
|
};
|
|
356
412
|
|
|
413
|
+
/**
|
|
414
|
+
* extractFrontmatter cannot read a list of maps: each must_haves artifact and key
|
|
415
|
+
* link came back as the string of its first line (`path: "src/x.ts`), so the plan
|
|
416
|
+
* checker and verify-phase, which read them through `frontmatter get`, got no
|
|
417
|
+
* objects. parseMustHavesBlock reads both blocks by relative indent.
|
|
418
|
+
* @param {object} fm - extractFrontmatter output
|
|
419
|
+
* @param {string} content - the file it came from
|
|
420
|
+
* @returns {object} fm, with must_haves.artifacts and .key_links as objects
|
|
421
|
+
*/
|
|
422
|
+
function withParsedMustHaves(fm, content) {
|
|
423
|
+
const mh = fm && fm.must_haves;
|
|
424
|
+
if (!mh || typeof mh !== 'object' || Array.isArray(mh)) return fm;
|
|
425
|
+
const fixed = { ...mh };
|
|
426
|
+
for (const block of ['artifacts', 'key_links']) {
|
|
427
|
+
if (!(block in fixed)) continue;
|
|
428
|
+
const parsed = parseMustHavesBlock(content, block);
|
|
429
|
+
if (parsed.length) fixed[block] = parsed;
|
|
430
|
+
}
|
|
431
|
+
return { ...fm, must_haves: fixed };
|
|
432
|
+
}
|
|
433
|
+
|
|
357
434
|
/**
|
|
358
435
|
* Get frontmatter from a file, optionally filtered to a single field.
|
|
359
436
|
* @param {string} cwd - Working directory path
|
|
@@ -367,7 +444,7 @@ function cmdFrontmatterGet(cwd, filePath, field, raw) {
|
|
|
367
444
|
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
|
368
445
|
const content = safeReadFile(fullPath);
|
|
369
446
|
if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
|
|
370
|
-
const fm = extractFrontmatter(content);
|
|
447
|
+
const fm = withParsedMustHaves(extractFrontmatter(content), content);
|
|
371
448
|
if (field) {
|
|
372
449
|
const value = fm[field];
|
|
373
450
|
if (value === undefined) { output({ error: 'Field not found', field }, raw); return; }
|
|
@@ -395,7 +472,9 @@ function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
|
|
|
395
472
|
} catch {
|
|
396
473
|
output({ error: 'File not found', path: filePath }, raw); return;
|
|
397
474
|
}
|
|
398
|
-
|
|
475
|
+
// The block is rewritten whole, so a plan's must_haves lists of maps must be read
|
|
476
|
+
// as maps: extractFrontmatter alone flattened each item to its first line.
|
|
477
|
+
const fm = withParsedMustHaves(extractFrontmatter(content), content);
|
|
399
478
|
let parsedValue;
|
|
400
479
|
// Attempt JSON parse so callers can pass structured values (arrays, objects);
|
|
401
480
|
// fall back to raw string if the value is not valid JSON
|
|
@@ -427,7 +506,7 @@ function cmdFrontmatterMerge(cwd, filePath, data, raw) {
|
|
|
427
506
|
} catch {
|
|
428
507
|
output({ error: 'File not found', path: filePath }, raw); return;
|
|
429
508
|
}
|
|
430
|
-
const fm = extractFrontmatter(content);
|
|
509
|
+
const fm = withParsedMustHaves(extractFrontmatter(content), content);
|
|
431
510
|
let mergeData;
|
|
432
511
|
// Parse the JSON data string; abort with error if the caller passed invalid JSON
|
|
433
512
|
try { mergeData = JSON.parse(data); } catch { error('Invalid JSON for --data'); return; }
|