pan-wizard 3.33.0 → 3.35.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 +29 -26
- package/agents/pan-conductor.md +18 -17
- package/agents/pan-counterfactual.md +2 -2
- package/agents/pan-debugger.md +30 -9
- package/agents/pan-design-checker.md +2 -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 +30 -9
- package/agents/pan-experiment-runner.md +9 -8
- package/agents/pan-hardener.md +1 -1
- package/agents/pan-integration-checker.md +2 -2
- package/agents/pan-knowledge.md +1 -1
- package/agents/pan-meta-reviewer.md +4 -4
- package/agents/pan-optimizer.md +39 -43
- package/agents/pan-phase-researcher.md +10 -10
- package/agents/pan-plan-checker.md +37 -34
- package/agents/pan-planner.md +57 -44
- package/agents/pan-previewer.md +1 -1
- package/agents/pan-project-researcher.md +12 -12
- package/agents/pan-release.md +1 -1
- package/agents/pan-research-synthesizer.md +3 -3
- package/agents/pan-reviewer.md +26 -3
- package/agents/pan-roadmapper.md +15 -14
- package/agents/pan-verifier.md +33 -25
- package/bin/install-lib.cjs +176 -38
- package/bin/install.js +81 -63
- 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 -8
- package/commands/pan/dashboard.md +0 -3
- package/commands/pan/debug.md +10 -7
- package/commands/pan/design-phase.md +5 -5
- 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 +4 -7
- package/commands/pan/hygiene.md +14 -7
- package/commands/pan/knowledge.md +9 -10
- 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 +19 -21
- 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 +18 -14
- package/commands/pan/patches.md +2 -6
- package/commands/pan/pause.md +1 -1
- package/commands/pan/phase-tests.md +3 -2
- package/commands/pan/plan-phase.md +9 -19
- package/commands/pan/preview.md +2 -3
- 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 +3 -7
- package/commands/pan/research-phase.md +16 -15
- package/commands/pan/resume.md +9 -11
- package/commands/pan/retro.md +5 -3
- package/commands/pan/review-deep.md +7 -9
- package/commands/pan/settings.md +1 -1
- package/commands/pan/verify-phase.md +0 -11
- package/commands/pan/what-if.md +3 -4
- package/hooks/dist/pan-context-monitor.js +352 -44
- package/hooks/dist/pan-cost-logger.js +30 -0
- package/hooks/dist/pan-guard.js +244 -0
- package/hooks/dist/pan-statusline.js +73 -24
- 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 +57 -6
- package/pan-wizard-core/bin/lib/config.cjs +7 -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 +135 -21
- 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 +184 -17
- package/pan-wizard-core/bin/lib/init.cjs +13 -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 +97 -17
- package/pan-wizard-core/bin/lib/phase-gate.cjs +174 -0
- 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 +39 -13
- package/pan-wizard-core/bin/lib/preview.cjs +34 -31
- 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/verdict.cjs +140 -10
- package/pan-wizard-core/bin/lib/verify-integrity.cjs +181 -0
- package/pan-wizard-core/bin/lib/verify-plan-set.cjs +230 -0
- 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 +11 -7
- package/pan-wizard-core/bin/lib/verify.cjs +30 -28
- package/pan-wizard-core/bin/lib/whatif.cjs +53 -9
- package/pan-wizard-core/bin/pan-tools.cjs +114 -19
- package/pan-wizard-core/learnings/index.json +5 -5
- package/pan-wizard-core/mcp/merge-gate.cjs +14 -3
- package/pan-wizard-core/mcp/native-tools.cjs +3 -3
- package/pan-wizard-core/opencode/pan-wizard.js +139 -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 +15 -10
- package/pan-wizard-core/references/phase-argument-parsing.md +7 -7
- package/pan-wizard-core/references/planning-config.md +7 -7
- package/pan-wizard-core/references/tdd.md +10 -1
- package/pan-wizard-core/references/verdict-contract.md +6 -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/config.json +2 -6
- package/pan-wizard-core/templates/continue-here.md +1 -1
- package/pan-wizard-core/templates/debug-subagent-prompt.md +5 -3
- 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 +31 -17
- package/pan-wizard-core/templates/planner-subagent-prompt.md +5 -5
- package/pan-wizard-core/templates/preview-report.md +1 -1
- 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/state.md +4 -4
- 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/diagnose-issues.md +4 -4
- package/pan-wizard-core/workflows/discuss-phase.md +10 -8
- package/pan-wizard-core/workflows/exec-phase.md +75 -95
- 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 +18 -25
- package/pan-wizard-core/workflows/milestone-new.md +7 -6
- package/pan-wizard-core/workflows/new-project.md +17 -25
- 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 +53 -42
- package/pan-wizard-core/workflows/profile.md +2 -2
- package/pan-wizard-core/workflows/progress.md +6 -8
- package/pan-wizard-core/workflows/quick.md +7 -5
- 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 +4 -3
- package/pan-wizard-core/workflows/transition.md +16 -12
- package/pan-wizard-core/workflows/update.md +25 -17
- package/pan-wizard-core/workflows/verify-phase.md +22 -20
- 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-hooks.js +1 -0
- package/scripts/build-plugin.js +3 -1
- package/scripts/generate-skills-docs.py +15 -6
- package/scripts/prompt-lint.cjs +167 -0
- package/scripts/test-surface.cjs +1 -0
|
@@ -655,6 +655,7 @@ function buildCostRecord(data, cwd) {
|
|
|
655
655
|
let cacheWrite = 0;
|
|
656
656
|
// { h1, m5 } when the source carried the cache-write lifetime split (M13).
|
|
657
657
|
let ttlSplit = null;
|
|
658
|
+
let longPrompt = null;
|
|
658
659
|
let durationMs = null;
|
|
659
660
|
// token_source records WHICH path produced the counts: `agent-transcript` (the
|
|
660
661
|
// subagent's own conversation file — see resolveAgentTranscript), `transcript`
|
|
@@ -717,6 +718,9 @@ function buildCostRecord(data, cwd) {
|
|
|
717
718
|
if (typeof fromTranscript.cache_write_1h_tokens === 'number' && rawCw <= SLICE_MAX.cache_write) {
|
|
718
719
|
ttlSplit = { h1: fromTranscript.cache_write_1h_tokens, m5: fromTranscript.cache_write_5m_tokens || 0 };
|
|
719
720
|
}
|
|
721
|
+
// The long-prompt part of the slice, kept only when nothing was clamped (a clamped
|
|
722
|
+
// total no longer contains it).
|
|
723
|
+
if (fromTranscript.long_prompt && !clamped) longPrompt = fromTranscript.long_prompt;
|
|
720
724
|
durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts); // as measured — see SLICE_MAX
|
|
721
725
|
if (!model) model = fromTranscript.model;
|
|
722
726
|
if (fromTranscript.lineCount > since) {
|
|
@@ -809,6 +813,8 @@ function buildCostRecord(data, cwd) {
|
|
|
809
813
|
cache_write_tokens: cacheWrite,
|
|
810
814
|
// The lifetime split of cache_write_tokens, present only when measured (M13).
|
|
811
815
|
...(ttlSplit ? { cache_write_1h_tokens: ttlSplit.h1, cache_write_5m_tokens: ttlSplit.m5 } : {}),
|
|
816
|
+
// Tokens of the requests whose prompt was over LONG_PROMPT_THRESHOLD, when any were.
|
|
817
|
+
...(longPrompt ? { long_prompt: longPrompt } : {}),
|
|
812
818
|
cost_usd: null,
|
|
813
819
|
duration_ms: durationMs,
|
|
814
820
|
phase,
|
|
@@ -938,6 +944,7 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
|
|
|
938
944
|
totals.cache_read_input_tokens += extractNumber(usage, 'cache_read_input_tokens');
|
|
939
945
|
totals.cache_creation_input_tokens += extractNumber(usage, 'cache_creation_input_tokens');
|
|
940
946
|
addCacheTtlSplit(totals, usage);
|
|
947
|
+
addLongPrompt(totals, usage);
|
|
941
948
|
}
|
|
942
949
|
totals.lineCount = seen;
|
|
943
950
|
return totals;
|
|
@@ -958,6 +965,29 @@ function addCacheTtlSplit(totals, usage) {
|
|
|
958
965
|
totals.cache_write_5m_tokens = (totals.cache_write_5m_tokens || 0) + extractNumber(cc, 'ephemeral_5m_input_tokens');
|
|
959
966
|
}
|
|
960
967
|
|
|
968
|
+
// Haiku 5.5 bills a request whose prompt (input plus cache reads and writes) is over
|
|
969
|
+
// this many tokens at higher rates, request by request. A row sums many requests, so
|
|
970
|
+
// the tokens of those over it are kept apart as `long_prompt`, and the read side
|
|
971
|
+
// (lib/cost.cjs computeCost) prices them at the model's long rate. A copy of
|
|
972
|
+
// LONG_PROMPT_THRESHOLD in lib/cost.cjs: hooks require nothing from lib, and
|
|
973
|
+
// tests/cost-long-prompt.test.cjs pins the two equal.
|
|
974
|
+
const LONG_PROMPT_THRESHOLD = 100000;
|
|
975
|
+
|
|
976
|
+
/** Add one request's usage to `totals.long_prompt` when its prompt is over the threshold. */
|
|
977
|
+
function addLongPrompt(totals, usage) {
|
|
978
|
+
const inp = extractNumber(usage, 'input_tokens');
|
|
979
|
+
const cr = extractNumber(usage, 'cache_read_input_tokens');
|
|
980
|
+
const cw = extractNumber(usage, 'cache_creation_input_tokens');
|
|
981
|
+
if (inp + cr + cw <= LONG_PROMPT_THRESHOLD) return;
|
|
982
|
+
const lp = totals.long_prompt || (totals.long_prompt = { above: LONG_PROMPT_THRESHOLD, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, cache_write_1h_tokens: 0 });
|
|
983
|
+
lp.input_tokens += inp;
|
|
984
|
+
lp.output_tokens += extractNumber(usage, 'output_tokens');
|
|
985
|
+
lp.cache_read_tokens += cr;
|
|
986
|
+
lp.cache_write_tokens += cw;
|
|
987
|
+
const cc = usage && usage.cache_creation;
|
|
988
|
+
if (cc && typeof cc === 'object') lp.cache_write_1h_tokens += extractNumber(cc, 'ephemeral_1h_input_tokens');
|
|
989
|
+
}
|
|
990
|
+
|
|
961
991
|
/**
|
|
962
992
|
* Append record to .planning/metrics/tokens.jsonl. Silently succeeds
|
|
963
993
|
* even if the file or directory can't be written — hook must not block.
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// PAN guard — a pre-tool hook (program A7).
|
|
4
|
+
//
|
|
5
|
+
// Registered on the hosts with a pre-tool event that can deny a call: Claude Code
|
|
6
|
+
// and Codex (`PreToolUse`), Gemini CLI (`BeforeTool`) and Copilot CLI (`preToolUse`).
|
|
7
|
+
// OpenCode registers no hooks. The command carries `--host <runtime>`, because the
|
|
8
|
+
// payload and the way to deny differ per host.
|
|
9
|
+
//
|
|
10
|
+
// Rules, over the files a tool call is about to write:
|
|
11
|
+
// secret_write the written text holds a credential (known token shapes,
|
|
12
|
+
// or a long key=value secret)
|
|
13
|
+
// state_write a direct write to PAN's own state: state.md, findings.jsonl,
|
|
14
|
+
// the token ledger (pan-tools owns those)
|
|
15
|
+
// undeclared_test_edit a test file the phase in flight does not declare in any
|
|
16
|
+
// unfinished plan's files_modified
|
|
17
|
+
//
|
|
18
|
+
// Mode, from the planning tree's config.json `hooks.guard`:
|
|
19
|
+
// off nothing
|
|
20
|
+
// report (default) every match is appended to <planning>/guard.jsonl, the call proceeds
|
|
21
|
+
// enforce as report, and a secret_write is denied; the other rules still only report
|
|
22
|
+
// until the writers of PAN's state go through pan-tools verbs (A7b)
|
|
23
|
+
//
|
|
24
|
+
// Inert outside a PAN project. Fail-open on anything it cannot parse; never fail-open
|
|
25
|
+
// on a matched deny. Never writes outside the planning tree.
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
const TOKEN_RE = /\b(?:sk-ant-[A-Za-z0-9_-]{10,}|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,}|-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----)/;
|
|
31
|
+
const SECRET_KV_RE = /\b(api[_-]?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|client[_-]?secret|secret[_-]?key|password|passwd)\b["']?\s*[:=]\s*["']([^"'\s]{12,})["']/i;
|
|
32
|
+
const TEST_PATH_RE = /(^|\/)(tests?|__tests__|specs?)\//i;
|
|
33
|
+
const TEST_FILE_RE = /(\.(test|spec)\.[a-z0-9]+|(^|\/)test_[^/]+\.py|_test\.(py|go)|Tests?\.(java|kt|cs))$/i;
|
|
34
|
+
const MODES = ['off', 'report', 'enforce'];
|
|
35
|
+
|
|
36
|
+
// ─── R39: one run per hook when Claude and Copilot share a project ───────────
|
|
37
|
+
// Copilot CLI also runs the hooks in a repository's .claude/settings.json and
|
|
38
|
+
// .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
|
|
39
|
+
// hooks loaded): in a project with both the Claude and the Copilot install, every
|
|
40
|
+
// PAN hook ran twice under Copilot — once from .github/hooks/pan.json, once from the
|
|
41
|
+
// Claude settings. The Copilot project copy (this file under .github/hooks) steps
|
|
42
|
+
// aside whenever the project's Claude settings register the same script, so the hook
|
|
43
|
+
// runs once, and runs again from here the moment the Claude registration is gone.
|
|
44
|
+
// Both files are repository hooks to Copilot and load under the same trust rule, so
|
|
45
|
+
// deferring never leaves zero. Only this copy defers: Claude Code never reads
|
|
46
|
+
// .github/hooks, Gemini and Codex never read .claude/settings.json, and a global
|
|
47
|
+
// Copilot copy loads where repository hooks may not. Identical in every hook
|
|
48
|
+
// Copilot registers — tests/copilot-hook-dedupe.test.cjs pins the copies.
|
|
49
|
+
function deferToClaudeRegistration(projectDir, hookFile = __filename) {
|
|
50
|
+
// Assembled, not written as a literal: the installer rewrites every quoted .claude
|
|
51
|
+
// literal in a hook copy to that runtime's own directory, and this one must stay Claude's.
|
|
52
|
+
const claudeDir = ['.', 'claude'].join('');
|
|
53
|
+
try {
|
|
54
|
+
const hooksDir = path.dirname(hookFile);
|
|
55
|
+
if (path.basename(hooksDir) !== 'hooks' || path.basename(path.dirname(hooksDir)) !== '.github') return false;
|
|
56
|
+
if (typeof projectDir !== 'string' || !projectDir) return false;
|
|
57
|
+
const script = path.basename(hookFile);
|
|
58
|
+
for (const name of ['settings.json', 'settings.local.json']) {
|
|
59
|
+
let settings;
|
|
60
|
+
try { settings = JSON.parse(fs.readFileSync(path.join(projectDir, claudeDir, name), 'utf8')); } catch { continue; }
|
|
61
|
+
const events = settings && typeof settings.hooks === 'object' ? settings.hooks : null;
|
|
62
|
+
if (!events) continue;
|
|
63
|
+
for (const groups of Object.values(events)) {
|
|
64
|
+
if (!Array.isArray(groups)) continue;
|
|
65
|
+
for (const group of groups) {
|
|
66
|
+
const handlers = group && Array.isArray(group.hooks) ? group.hooks : [];
|
|
67
|
+
if (handlers.some((h) => h && typeof h.command === 'string' && h.command.includes(script))) return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch { /* fail open: run this copy */ }
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
function planningDirName() {
|
|
77
|
+
const raw = process.env.PAN_PLANNING_DIR || '';
|
|
78
|
+
if (raw.trim()) {
|
|
79
|
+
const rel = raw.trim().replace(/\\/g, '/');
|
|
80
|
+
const bad = rel.startsWith('/') || /^[A-Za-z]:/.test(rel) || rel.split('/').includes('..');
|
|
81
|
+
if (!bad) return rel;
|
|
82
|
+
}
|
|
83
|
+
const track = (process.env.PAN_TRACK || '').trim();
|
|
84
|
+
if (track && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(track)) return `.planning/tracks/${track}`;
|
|
85
|
+
return '.planning';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readJson(p) {
|
|
89
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A value that is a placeholder, not a credential. */
|
|
93
|
+
function isPlaceholder(v) {
|
|
94
|
+
return /^(?:\$\{|\$[A-Z_]|process\.env|os\.environ|<|xxx|your[_-]|changeme|example|dummy|placeholder|redacted|\*+$)/i.test(v);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hasSecret(text) {
|
|
98
|
+
if (typeof text !== 'string' || !text) return false;
|
|
99
|
+
if (TOKEN_RE.test(text)) return true;
|
|
100
|
+
const m = text.match(SECRET_KV_RE);
|
|
101
|
+
return !!(m && !isPlaceholder(m[2]) && /\d/.test(m[2]) && /[A-Za-z]/.test(m[2]));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The tool call as `{tool, writes: [{path, text}], command}`, whatever the host.
|
|
106
|
+
* Claude/Codex/Gemini send `tool_name` + `tool_input`; Copilot sends `toolName` +
|
|
107
|
+
* `toolArgs` (a JSON string).
|
|
108
|
+
*/
|
|
109
|
+
function normalise(payload) {
|
|
110
|
+
const p = payload && typeof payload === 'object' ? payload : {};
|
|
111
|
+
const tool = String(p.tool_name || p.toolName || p.tool || '');
|
|
112
|
+
let input = p.tool_input || p.toolArgs || p.input || {};
|
|
113
|
+
if (typeof input === 'string') { try { input = JSON.parse(input); } catch { input = { command: input }; } }
|
|
114
|
+
if (!input || typeof input !== 'object') input = {};
|
|
115
|
+
const writes = [];
|
|
116
|
+
const file = input.file_path || input.filePath || input.path || input.notebook_path || input.absolute_path;
|
|
117
|
+
const texts = [input.content, input.new_string, input.newString, input.new_source, input.file_text, input.text]
|
|
118
|
+
.concat(Array.isArray(input.edits) ? input.edits.map((e) => e && (e.new_string || e.newString)) : [])
|
|
119
|
+
.filter((t) => typeof t === 'string');
|
|
120
|
+
if (typeof file === 'string' && file && (texts.length || /write|edit|create|replace|notebook/i.test(tool))) {
|
|
121
|
+
writes.push({ path: file, text: texts.join('\n') });
|
|
122
|
+
}
|
|
123
|
+
const command = typeof input.command === 'string' ? input.command : (Array.isArray(input.command) ? input.command.join(' ') : '');
|
|
124
|
+
// Codex edits files through apply_patch: the patch names its files and carries the text.
|
|
125
|
+
if (/\*\*\* (?:Add|Update) File: /.test(command)) {
|
|
126
|
+
let cur = null;
|
|
127
|
+
for (const line of command.split(/\r?\n/)) {
|
|
128
|
+
const m = line.match(/^\*\*\* (?:Add|Update) File: (.+)$/);
|
|
129
|
+
if (m) { cur = { path: m[1].trim(), text: '' }; writes.push(cur); continue; }
|
|
130
|
+
if (cur && line.startsWith('+')) cur.text += line.slice(1) + '\n';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { tool, writes, command };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Project-relative POSIX path, or null when the path is outside the project. */
|
|
137
|
+
function rel(projectDir, p) {
|
|
138
|
+
const abs = path.resolve(projectDir, p);
|
|
139
|
+
const r = path.relative(projectDir, abs).replace(/\\/g, '/');
|
|
140
|
+
return r.startsWith('..') || path.isAbsolute(r) ? null : r;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isTestFile(r) {
|
|
144
|
+
return TEST_PATH_RE.test(r) || TEST_FILE_RE.test(r);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** files_modified of the unfinished plans of the phase state.md names, or null when none is in flight. */
|
|
148
|
+
function declaredFiles(projectDir, planningRel) {
|
|
149
|
+
let state;
|
|
150
|
+
try { state = fs.readFileSync(path.join(projectDir, planningRel, 'state.md'), 'utf8'); } catch { return null; }
|
|
151
|
+
const m = state.match(/\*\*Current Phase:\*\*[ \t]*([0-9]+(?:\.[0-9]+)?)/i) || state.match(/^Phase:[ \t]*([0-9]+(?:\.[0-9]+)?)/m);
|
|
152
|
+
if (!m) return null;
|
|
153
|
+
const want = String(parseFloat(m[1]));
|
|
154
|
+
const phasesDir = path.join(projectDir, planningRel, 'phases');
|
|
155
|
+
let dir;
|
|
156
|
+
try { dir = fs.readdirSync(phasesDir).find((d) => { const n = d.match(/^(\d+(?:\.\d+)?)-/); return n && String(parseFloat(n[1])) === want; }); } catch { return null; }
|
|
157
|
+
if (!dir) return null;
|
|
158
|
+
const full = path.join(phasesDir, dir);
|
|
159
|
+
const names = fs.readdirSync(full);
|
|
160
|
+
const done = new Set(names.filter((n) => n.endsWith('-summary.md')).map((n) => n.replace(/-summary\.md$/, '')));
|
|
161
|
+
const out = new Set();
|
|
162
|
+
let plans = 0;
|
|
163
|
+
for (const n of names.filter((x) => x.endsWith('-plan.md'))) {
|
|
164
|
+
if (done.has(n.replace(/-plan\.md$/, ''))) continue;
|
|
165
|
+
plans++;
|
|
166
|
+
const text = fs.readFileSync(path.join(full, n), 'utf8').replace(/\r\n/g, '\n');
|
|
167
|
+
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
168
|
+
if (!fm) continue;
|
|
169
|
+
const inline = fm[1].match(/^files_modified:[ \t]*\[([^\]]*)\]/m);
|
|
170
|
+
if (inline) inline[1].split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean).forEach((f) => out.add(f.replace(/^\.\//, '')));
|
|
171
|
+
const block = fm[1].match(/^files_modified:[ \t]*\n((?:[ \t]+-.*\n?)+)/m);
|
|
172
|
+
if (block) block[1].split('\n').map((l) => l.replace(/^[ \t]+-[ \t]*/, '').trim().replace(/^["']|["']$/g, '')).filter(Boolean).forEach((f) => out.add(f.replace(/^\.\//, '')));
|
|
173
|
+
}
|
|
174
|
+
return plans ? out : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The rule matches for one normalised call. */
|
|
178
|
+
function evaluate(call, projectDir, planningRel) {
|
|
179
|
+
const hits = [];
|
|
180
|
+
const declared = call.writes.length ? declaredFiles(projectDir, planningRel) : null;
|
|
181
|
+
const ownState = new Set([`${planningRel}/state.md`, `${planningRel}/findings.jsonl`, `${planningRel}/metrics/tokens.jsonl`]);
|
|
182
|
+
for (const w of call.writes) {
|
|
183
|
+
const r = rel(projectDir, w.path);
|
|
184
|
+
if (hasSecret(w.text)) hits.push({ rule: 'secret_write', path: r || w.path });
|
|
185
|
+
if (!r) continue;
|
|
186
|
+
if (ownState.has(r)) hits.push({ rule: 'state_write', path: r });
|
|
187
|
+
if (declared && isTestFile(r) && !declared.has(r)) hits.push({ rule: 'undeclared_test_edit', path: r });
|
|
188
|
+
}
|
|
189
|
+
return hits;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function decide(hits, mode) {
|
|
193
|
+
if (mode !== 'enforce') return null;
|
|
194
|
+
return hits.find((h) => h.rule === 'secret_write') || null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The host whose deny format this call needs: Copilot by payload shape, else the --host flag. */
|
|
198
|
+
function hostOf(payload, flagHost) {
|
|
199
|
+
const p = payload && typeof payload === 'object' ? payload : {};
|
|
200
|
+
if (typeof p.toolName === 'string' && p.tool_name === undefined) return 'copilot';
|
|
201
|
+
return flagHost;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function main() {
|
|
205
|
+
const flagHost = (process.argv[process.argv.indexOf('--host') + 1] || 'claude').toLowerCase();
|
|
206
|
+
let raw = '';
|
|
207
|
+
try { raw = fs.readFileSync(0, 'utf8'); } catch { return; }
|
|
208
|
+
let payload;
|
|
209
|
+
try { payload = JSON.parse(raw); } catch { return; } // fail open
|
|
210
|
+
const projectDir = (payload && (payload.cwd || payload.workspace || payload.project_dir)) || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
211
|
+
if (deferToClaudeRegistration(projectDir)) return; // the Claude registration covers this call under Copilot
|
|
212
|
+
// The payload names the host better than the registration does: Copilot sends its own
|
|
213
|
+
// camelCase payload (toolName, toolArgs) even to a hook registered in .claude/settings.json,
|
|
214
|
+
// and denies only on the JSON decision, so a Copilot payload gets Copilot's deny whatever
|
|
215
|
+
// --host says. The flag decides for the snake_case hosts (Claude Code, Codex, Gemini).
|
|
216
|
+
const host = hostOf(payload, flagHost);
|
|
217
|
+
const planningRel = planningDirName();
|
|
218
|
+
const planning = path.join(projectDir, planningRel);
|
|
219
|
+
if (!fs.existsSync(planning)) return; // not a PAN project
|
|
220
|
+
const cfg = readJson(path.join(planning, 'config.json')) || {};
|
|
221
|
+
const mode = MODES.includes(cfg.hooks && cfg.hooks.guard) ? cfg.hooks.guard : 'report';
|
|
222
|
+
if (mode === 'off') return;
|
|
223
|
+
let hits = [];
|
|
224
|
+
try { hits = evaluate(normalise(payload), projectDir, planningRel); } catch { return; } // fail open
|
|
225
|
+
if (!hits.length) return;
|
|
226
|
+
const deny = decide(hits, mode);
|
|
227
|
+
try {
|
|
228
|
+
const ts = new Date().toISOString();
|
|
229
|
+
const lines = hits.map((h) => JSON.stringify({ ts, host, mode, rule: h.rule, path: h.path, tool: normalise(payload).tool, action: deny && h === deny ? 'denied' : 'reported' }));
|
|
230
|
+
fs.appendFileSync(path.join(planning, 'guard.jsonl'), lines.join('\n') + '\n');
|
|
231
|
+
} catch { /* the log is best effort; a deny below still stands */ }
|
|
232
|
+
if (!deny) return;
|
|
233
|
+
const reason = `PAN guard: this write puts a credential into ${deny.path}. Keep secrets out of the repository (an environment variable or a local, ignored file), then retry. (hooks.guard: enforce)`;
|
|
234
|
+
if (host === 'copilot') {
|
|
235
|
+
process.stdout.write(JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: reason }) + '\n');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
process.stderr.write(reason + '\n');
|
|
239
|
+
process.exitCode = 2;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (require.main === module) main();
|
|
243
|
+
|
|
244
|
+
module.exports = { normalise, evaluate, decide, hasSecret, declaredFiles, isTestFile, hostOf, deferToClaudeRegistration };
|
|
@@ -13,7 +13,7 @@ const os = require('os');
|
|
|
13
13
|
*
|
|
14
14
|
* @param {Object} data - Parsed stdin JSON from Claude Code.
|
|
15
15
|
* @param {Object} [deps] - Optional dep injection for testing.
|
|
16
|
-
* {fs, path, homeDir, tmpDir} — defaults to real modules + OS paths.
|
|
16
|
+
* {fs, path, homeDir, tmpDir, configDir} — defaults to real modules + OS paths (configDir: CLAUDE_CONFIG_DIR, ignored when homeDir is given).
|
|
17
17
|
* @returns {string} The statusline content.
|
|
18
18
|
*/
|
|
19
19
|
function buildStatuslineOutput(data, deps) {
|
|
@@ -30,6 +30,54 @@ function buildStatuslineOutput(data, deps) {
|
|
|
30
30
|
const session = data.session_id || '';
|
|
31
31
|
const remaining = data.context_window?.remaining_percentage;
|
|
32
32
|
|
|
33
|
+
// The per-user 0700 bridge directory, or null when it is not safe to write into.
|
|
34
|
+
// Mirrors bridgeDir() in pan-context-monitor.js (the reader).
|
|
35
|
+
let bridgeSubdirMemo;
|
|
36
|
+
const secureBridgeDir = () => {
|
|
37
|
+
if (bridgeSubdirMemo !== undefined) return bridgeSubdirMemo;
|
|
38
|
+
bridgeSubdirMemo = null;
|
|
39
|
+
const uid = (typeof process.getuid === 'function' ? process.getuid() : process.env.USERNAME || 'win');
|
|
40
|
+
const sub = pathMod.join(tmpDir, `pan-hooks-${uid}`);
|
|
41
|
+
// Fail CLOSED if the per-user dir is pre-planted/symlinked/owned by someone
|
|
42
|
+
// else: mkdirSync{recursive} silently no-ops on an existing dir, so verify
|
|
43
|
+
// ownership+mode before writing (M60).
|
|
44
|
+
try {
|
|
45
|
+
fsMod.mkdirSync(sub, { recursive: true, mode: 0o700 });
|
|
46
|
+
const st = fsMod.lstatSync(sub);
|
|
47
|
+
// Symlink check is cross-platform; POSIX ownership/mode checks apply only
|
|
48
|
+
// where getuid exists (Windows fakes mode bits; gating on them there
|
|
49
|
+
// disabled the bridge entirely, N15).
|
|
50
|
+
let secure = !st.isSymbolicLink();
|
|
51
|
+
if (secure && typeof process.getuid === 'function') {
|
|
52
|
+
secure = st.uid === process.getuid() && (st.mode & 0o077) === 0;
|
|
53
|
+
}
|
|
54
|
+
if (secure) bridgeSubdirMemo = sub;
|
|
55
|
+
} catch { /* not safe */ }
|
|
56
|
+
return bridgeSubdirMemo;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Usage limits (Claude Code subscriptions: `rate_limits.five_hour` / `seven_day`).
|
|
60
|
+
// Account-wide, so one file for every session; `pan-tools cost limits` and the
|
|
61
|
+
// focus-auto weekly stop read it. Written only when the host sent the block.
|
|
62
|
+
const limits = data.rate_limits && typeof data.rate_limits === 'object' ? data.rate_limits : null;
|
|
63
|
+
const limitWin = (w) => (w && Number.isFinite(Number(w.used_percentage))
|
|
64
|
+
? { used_percentage: Number(w.used_percentage), ...(Number.isFinite(Number(w.resets_at)) ? { resets_at: Number(w.resets_at) } : {}) }
|
|
65
|
+
: null);
|
|
66
|
+
const fiveHour = limits ? limitWin(limits.five_hour) : null;
|
|
67
|
+
const sevenDay = limits ? limitWin(limits.seven_day) : null;
|
|
68
|
+
if ((fiveHour || sevenDay) && d.skipBridge !== true) {
|
|
69
|
+
try {
|
|
70
|
+
const sub = secureBridgeDir();
|
|
71
|
+
if (sub) {
|
|
72
|
+
fsMod.writeFileSync(pathMod.join(sub, 'claude-limits.json'), JSON.stringify({
|
|
73
|
+
...(fiveHour ? { five_hour: fiveHour } : {}),
|
|
74
|
+
...(sevenDay ? { seven_day: sevenDay } : {}),
|
|
75
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
76
|
+
}), { mode: 0o600 });
|
|
77
|
+
}
|
|
78
|
+
} catch { /* best-effort */ }
|
|
79
|
+
}
|
|
80
|
+
|
|
33
81
|
// Context window bar — shows USED percentage scaled so 80% real = 100% shown.
|
|
34
82
|
let ctx = '';
|
|
35
83
|
if (remaining != null) {
|
|
@@ -39,34 +87,23 @@ function buildStatuslineOutput(data, deps) {
|
|
|
39
87
|
|
|
40
88
|
if (session && d.skipBridge !== true) {
|
|
41
89
|
try {
|
|
42
|
-
//
|
|
90
|
+
// The session bridge goes into the per-user 0700 subdir so another user on
|
|
43
91
|
// a shared host can't symlink-attack the predictable session path.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const bridgeSubdir = pathMod.join(tmpDir, `pan-hooks-${uid}`);
|
|
47
|
-
// Fail CLOSED if the per-user dir is pre-planted/symlinked/owned by
|
|
48
|
-
// someone else — mkdirSync{recursive} silently no-ops on an existing dir,
|
|
49
|
-
// so verify ownership+mode before writing the session bridge (M60).
|
|
50
|
-
let secure = false;
|
|
51
|
-
try {
|
|
52
|
-
fsMod.mkdirSync(bridgeSubdir, { recursive: true, mode: 0o700 });
|
|
53
|
-
const st = fsMod.lstatSync(bridgeSubdir);
|
|
54
|
-
// Symlink check is cross-platform; POSIX ownership/mode checks apply
|
|
55
|
-
// only where getuid exists (Windows fakes mode bits — gating on them
|
|
56
|
-
// there disabled the bridge entirely, N15).
|
|
57
|
-
secure = !st.isSymbolicLink();
|
|
58
|
-
if (secure && typeof process.getuid === 'function') {
|
|
59
|
-
secure = st.uid === process.getuid() && (st.mode & 0o077) === 0;
|
|
60
|
-
}
|
|
61
|
-
} catch { secure = false; }
|
|
62
|
-
if (secure) {
|
|
92
|
+
const bridgeSubdir = secureBridgeDir();
|
|
93
|
+
if (bridgeSubdir) {
|
|
63
94
|
const bridgePath = pathMod.join(bridgeSubdir, `claude-ctx-${session}.json`);
|
|
95
|
+
// Token counts and the model id let the context monitor measure against
|
|
96
|
+
// the window the host compacts at, not the model window (O7).
|
|
97
|
+
const cw = data.context_window || {};
|
|
64
98
|
fsMod.writeFileSync(bridgePath, JSON.stringify({
|
|
65
99
|
session_id: session,
|
|
66
100
|
remaining_percentage: remaining,
|
|
67
101
|
used_pct: used,
|
|
102
|
+
...(Number.isFinite(cw.total_input_tokens) ? { total_input_tokens: cw.total_input_tokens } : {}),
|
|
103
|
+
...(Number.isFinite(cw.context_window_size) ? { context_window_size: cw.context_window_size } : {}),
|
|
104
|
+
...(data.model && data.model.id ? { model_id: data.model.id } : {}),
|
|
68
105
|
timestamp: Math.floor(Date.now() / 1000),
|
|
69
|
-
}));
|
|
106
|
+
}), { mode: 0o600 });
|
|
70
107
|
}
|
|
71
108
|
} catch { /* bridge is best-effort */ }
|
|
72
109
|
}
|
|
@@ -118,7 +155,10 @@ function buildStatuslineOutput(data, deps) {
|
|
|
118
155
|
|
|
119
156
|
// Current task from todos
|
|
120
157
|
let task = '';
|
|
121
|
-
|
|
158
|
+
// Claude Code keeps its todos in CLAUDE_CONFIG_DIR when that is set (a given
|
|
159
|
+
// homeDir is a test seam and ignores it).
|
|
160
|
+
const configDir = d.configDir !== undefined ? d.configDir : (d.homeDir ? '' : (process.env.CLAUDE_CONFIG_DIR || ''));
|
|
161
|
+
const todosDir = configDir ? pathMod.join(configDir, 'todos') : pathMod.join(homeDir, '.claude', 'todos');
|
|
122
162
|
let todosExists = false;
|
|
123
163
|
try { todosExists = fsMod.statSync(todosDir).isDirectory(); } catch { /* missing */ }
|
|
124
164
|
if (session && todosExists) {
|
|
@@ -154,7 +194,16 @@ function buildStatuslineOutput(data, deps) {
|
|
|
154
194
|
const head = `${panUpdate}\x1b[2m${model}\x1b[0m`;
|
|
155
195
|
const taskSegment = task ? ` │ \x1b[1m${task}\x1b[0m` : '';
|
|
156
196
|
const dirSegment = ` │ \x1b[2m${dirname}\x1b[0m`;
|
|
157
|
-
|
|
197
|
+
// Usage-limit badge: the window closer to its cap, green below 70%, yellow below
|
|
198
|
+
// 90%, red from 90% (focus-auto's default weekly stop).
|
|
199
|
+
let limitBadge = '';
|
|
200
|
+
const shown = [fiveHour && ['5h', fiveHour.used_percentage], sevenDay && ['7d', sevenDay.used_percentage]].filter(Boolean);
|
|
201
|
+
if (shown.length) {
|
|
202
|
+
const worst = Math.max(...shown.map(([, v]) => v));
|
|
203
|
+
const color = worst >= 90 ? '[31m' : worst >= 70 ? '[33m' : '[32m';
|
|
204
|
+
limitBadge = ` ${color}${shown.map(([k, v]) => `${k} ${Math.round(v)}%`).join(' · ')}[0m`;
|
|
205
|
+
}
|
|
206
|
+
return `${head}${taskSegment}${dirSegment}${ctx}${cacheBadge}${thinkingBadge}${limitBadge}`;
|
|
158
207
|
}
|
|
159
208
|
|
|
160
209
|
// ─── Stdin driver ───────────────────────────────────────────────────────────
|
|
@@ -73,6 +73,10 @@ const fs = require('fs');
|
|
|
73
73
|
const os = require('os');
|
|
74
74
|
const path = require('path');
|
|
75
75
|
|
|
76
|
+
// The CLI the block reason tells the agent to run. Installs and the plugin put the
|
|
77
|
+
// core beside hooks/; no `pan-tools` bin is on PATH.
|
|
78
|
+
const PAN_TOOLS = `node "${path.join(__dirname, '..', 'pan-wizard-core', 'bin', 'pan-tools.cjs').split(path.sep).join('/')}"`;
|
|
79
|
+
|
|
76
80
|
// ─── R39: one run per hook when Claude and Copilot share a project ───────────
|
|
77
81
|
// Copilot CLI also runs the hooks in a repository's .claude/settings.json and
|
|
78
82
|
// .claude/settings.local.json. Measured 2026-09-26 (Copilot CLI 1.0.88, repository
|
|
@@ -205,8 +209,9 @@ function buildStopDecision({ stopHookActive, config, stateContent, roadmapConten
|
|
|
205
209
|
`Continue the chain now: if the current phase is finished, follow transition.md's ` +
|
|
206
210
|
`offer_next_phase Route A and spawn Phase ${nextPhase} as a Task subagent ` +
|
|
207
211
|
`(plan-phase orchestrator, ARGUMENTS='${nextPhase} --auto'); if the current phase is ` +
|
|
208
|
-
`mid-flight, resume it instead. If the user explicitly asked to stop, first run
|
|
209
|
-
|
|
212
|
+
`mid-flight, resume it instead. If the user explicitly asked to stop, first run ` +
|
|
213
|
+
`\`${PAN_TOOLS} config-set workflow.auto_advance false\` (under mode: yolo, which arms this ` +
|
|
214
|
+
`guard by itself, set \`workflow.stop_guard false\` the same way instead) — then stop. ` +
|
|
210
215
|
`(Disable this guard permanently with workflow.stop_guard: false.)`
|
|
211
216
|
};
|
|
212
217
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pan-wizard",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.35.0",
|
|
4
4
|
"description": "Command a bot army for your codebase: a reasoning-tier Mission Control delegates whole-project goals to specialist squads and ships behind a human merge gate. Five AI CLIs, zero context rot.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pan-wizard": "bin/install.js"
|
|
@@ -82,19 +82,56 @@ function removeAgentsMdSection(existing) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
*
|
|
86
|
-
* (
|
|
87
|
-
*
|
|
85
|
+
* PAN's block in CLAUDE.md: the @AGENTS.md import, and Claude Code's "Compact
|
|
86
|
+
* instructions" section (memory optimisation O8). Claude Code reads a section of
|
|
87
|
+
* that name in the project-root CLAUDE.md when it summarises a conversation, and
|
|
88
|
+
* re-reads CLAUDE.md from disk after compaction. The section names what the summary
|
|
89
|
+
* must keep for PAN work to resume where it stopped.
|
|
90
|
+
*
|
|
91
|
+
* Codex is deliberately left out: its `compact_prompt` replaces the built-in
|
|
92
|
+
* compaction prompt rather than adding to it, and the default OpenAI provider
|
|
93
|
+
* ignores it (read 2026-10-04), so PAN would be swapping a tuned prompt for one
|
|
94
|
+
* that may never run. PAN's state re-injection after compaction covers Codex.
|
|
95
|
+
*/
|
|
96
|
+
function buildClaudeMdBridge() {
|
|
97
|
+
return [
|
|
98
|
+
PAN_AGENTS_BEGIN,
|
|
99
|
+
'@AGENTS.md',
|
|
100
|
+
'',
|
|
101
|
+
'# Compact instructions',
|
|
102
|
+
'',
|
|
103
|
+
'When this conversation is compacted, keep what PAN work needs to resume where it stopped:',
|
|
104
|
+
'- the current phase and plan, and the task in progress with its stopping point (`.planning/state.md` records them; keep that path);',
|
|
105
|
+
'- decisions made in this session that `.planning/state.md` does not yet hold;',
|
|
106
|
+
'- the files changed since the last commit.',
|
|
107
|
+
'Leave out file contents and command output: they can be read again.',
|
|
108
|
+
PAN_AGENTS_END,
|
|
109
|
+
].join('\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Ensure CLAUDE.md carries PAN's block: the marker-fenced @AGENTS.md import
|
|
114
|
+
* (Claude Code's documented pattern for adopting the universal rules file) and
|
|
115
|
+
* the compact instructions. Idempotent; preserves all user content. An older PAN
|
|
116
|
+
* bridge (the import alone) is replaced in place, so a reinstall or `memory
|
|
117
|
+
* rebuild` brings existing projects up to date.
|
|
88
118
|
* @param {string|null} existing - Current CLAUDE.md content, or null if absent
|
|
89
119
|
* @returns {string} New file content
|
|
90
120
|
*/
|
|
91
121
|
function ensureClaudeMdImport(existing) {
|
|
92
|
-
const block =
|
|
122
|
+
const block = buildClaudeMdBridge();
|
|
93
123
|
if (!existing || !existing.trim()) {
|
|
94
124
|
return block + '\n';
|
|
95
125
|
}
|
|
96
|
-
|
|
97
|
-
|
|
126
|
+
const beginIdx = existing.indexOf(PAN_AGENTS_BEGIN);
|
|
127
|
+
const endIdx = existing.indexOf(PAN_AGENTS_END);
|
|
128
|
+
if (beginIdx !== -1) {
|
|
129
|
+
// PAN's own bridge: bring it up to date. Any other PAN block is left alone.
|
|
130
|
+
if (endIdx > beginIdx && /^@AGENTS\.md\s*$/m.test(existing.slice(beginIdx, endIdx))) {
|
|
131
|
+
const eol = existing.includes('\r\n') ? '\r\n' : '\n';
|
|
132
|
+
return existing.slice(0, beginIdx) + block.replace(/\n/g, eol) + existing.slice(endIdx + PAN_AGENTS_END.length);
|
|
133
|
+
}
|
|
134
|
+
return existing;
|
|
98
135
|
}
|
|
99
136
|
if (/^@AGENTS\.md\s*$/m.test(existing)) {
|
|
100
137
|
return existing; // user already imports AGENTS.md themselves
|
|
@@ -118,6 +155,7 @@ module.exports = {
|
|
|
118
155
|
buildAgentsMdSection,
|
|
119
156
|
upsertAgentsMdSection,
|
|
120
157
|
removeAgentsMdSection,
|
|
158
|
+
buildClaudeMdBridge,
|
|
121
159
|
ensureClaudeMdImport,
|
|
122
160
|
removeClaudeMdImport,
|
|
123
161
|
};
|
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
* ]
|
|
24
24
|
* }
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* Nothing populates this file on its own: no hook or host writes it.
|
|
27
|
+
* `bridge cache --servers <json>` (or an external script) seeds it; this module
|
|
28
|
+
* reads the cache and reasons over it.
|
|
28
29
|
*/
|
|
29
30
|
|
|
30
31
|
const fs = require('fs');
|
|
@@ -184,7 +185,7 @@ function recommendForPhase(cwd, phaseNum, opts) {
|
|
|
184
185
|
phase: String(phaseNum),
|
|
185
186
|
runtime: cache.runtime,
|
|
186
187
|
recommendations: [],
|
|
187
|
-
reason: 'no MCP tools cached —
|
|
188
|
+
reason: 'no MCP tools cached — seed .planning/bridge/available-tools.json with `pan-tools bridge cache --servers <json>`; no hook or host writes it',
|
|
188
189
|
};
|
|
189
190
|
}
|
|
190
191
|
|
|
@@ -240,8 +241,8 @@ function cmdBridgeRecommend(cwd, phaseNum, opts, raw) {
|
|
|
240
241
|
}
|
|
241
242
|
|
|
242
243
|
function cmdBridgeCache(cwd, serversJson, runtime, raw) {
|
|
243
|
-
//
|
|
244
|
-
//
|
|
244
|
+
// The cache's only writer: no hook or host runtime populates the file, so this
|
|
245
|
+
// CLI path (or an external script) is how users seed it.
|
|
245
246
|
if (!serversJson) {
|
|
246
247
|
// No payload — just echo the current cache path/state.
|
|
247
248
|
output(listTools(cwd), raw);
|
|
@@ -66,12 +66,14 @@ function writeScheduleFile(cwd, schedule) {
|
|
|
66
66
|
* @returns {object|{error}} the written descriptor
|
|
67
67
|
*/
|
|
68
68
|
function writeSchedule(cwd, opts, now) {
|
|
69
|
-
|
|
69
|
+
// An update keeps the stored cadence like every other field: `--pause` on an
|
|
70
|
+
// hourly campaign used to set it back to daily.
|
|
71
|
+
const existing = readSchedule(cwd) || {};
|
|
72
|
+
const cadence = opts.cadence || existing.cadence || 'daily';
|
|
70
73
|
if (parseCadence(cadence) === null) {
|
|
71
74
|
return { error: `Invalid cadence "${cadence}". Use hourly | daily | weekly | Nh | Nd.` };
|
|
72
75
|
}
|
|
73
76
|
const at = now || new Date();
|
|
74
|
-
const existing = readSchedule(cwd) || {};
|
|
75
77
|
const schedule = {
|
|
76
78
|
goal: opts.goal ?? existing.goal ?? null,
|
|
77
79
|
source: opts.source ?? existing.source ?? 'backlog',
|
|
@@ -299,6 +299,23 @@ function formatLearningEntry(learning) {
|
|
|
299
299
|
return lines.join('\n');
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
/**
|
|
303
|
+
* A summary's `key-files` as one list. The summary templates write `{created, modified}`;
|
|
304
|
+
* older summaries carry a flat list. Only the flat list was read, so summaries PAN writes
|
|
305
|
+
* gave no co-change learnings, and one beside a flat-list summary crashed on `.includes`.
|
|
306
|
+
* @param {Object} fm - Summary frontmatter
|
|
307
|
+
* @returns {string[]}
|
|
308
|
+
*/
|
|
309
|
+
function keyFileList(fm) {
|
|
310
|
+
const kf = fm['key-files'] || fm.key_files;
|
|
311
|
+
if (Array.isArray(kf)) return kf.map(String);
|
|
312
|
+
if (kf && typeof kf === 'object') {
|
|
313
|
+
const list = (v) => (Array.isArray(v) ? v : (v ? [v] : []));
|
|
314
|
+
return [...new Set([...list(kf.created), ...list(kf.modified)].map(String))];
|
|
315
|
+
}
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
|
|
302
319
|
/**
|
|
303
320
|
* Extract learnings from session summaries and error patterns.
|
|
304
321
|
* Reads session history + error patterns, extracts file co-change patterns
|
|
@@ -347,7 +364,7 @@ function cmdLearningsExtract(cwd, raw) {
|
|
|
347
364
|
const fileCoChanges = new Map(); // file -> Set of co-changed files
|
|
348
365
|
|
|
349
366
|
for (const { frontmatter } of summaries) {
|
|
350
|
-
const keyFiles =
|
|
367
|
+
const keyFiles = keyFileList(frontmatter);
|
|
351
368
|
if (keyFiles.length < 2) continue;
|
|
352
369
|
for (const file of keyFiles) {
|
|
353
370
|
if (!fileCoChanges.has(file)) fileCoChanges.set(file, new Set());
|
|
@@ -368,7 +385,7 @@ function cmdLearningsExtract(cwd, raw) {
|
|
|
368
385
|
// Count co-occurrences
|
|
369
386
|
let count = 0;
|
|
370
387
|
for (const { frontmatter } of summaries) {
|
|
371
|
-
const kf = frontmatter
|
|
388
|
+
const kf = keyFileList(frontmatter);
|
|
372
389
|
if (kf.includes(file) && kf.includes(coFile)) count++;
|
|
373
390
|
}
|
|
374
391
|
if (count < 2) continue;
|