pan-wizard 3.7.10 → 3.8.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 CHANGED
@@ -451,6 +451,24 @@ The orchestrator never does heavy lifting. It spawns agents, waits, integrates r
451
451
 
452
452
  **The result:** You can run an entire phase — deep research, multiple plans created and verified, thousands of lines of code written across parallel executors, automated verification against goals — and your main context window stays at 30-40%. The work happens in fresh subagent contexts. Your session stays fast and responsive.
453
453
 
454
+ ### Reasoning-Trace Handoff
455
+
456
+ When agents hand work off via files, only OUTPUTS get passed by default — not the reasoning that produced them. Per Cognition's "Don't build multi-agents" research (June 2025), silent decisions force downstream agents to reconcile contradictions blindly. PAN passes the reasoning explicitly:
457
+
458
+ - Plans carry a `## Plan Decisions` section (Locked / Open / Considered+rejected buckets) — the executor reads it before coding so it doesn't re-argue settled choices.
459
+ - Summaries carry an `## Implementation Decisions` section — the verifier reads it to understand WHY the executor deviated from the plan, not just THAT it did.
460
+
461
+ The plan-checker enforces this with two dedicated dimensions (Spec Sufficiency for Handoff, Decision Trace Completeness). Schema lives in `pan-wizard-core/references/handoff-decisions.md`.
462
+
463
+ ### Self-Improving Learnings
464
+
465
+ PAN runs autonomous experiments in isolated folders, harvests the resulting telemetry, and promotes generalizable findings into a shipped patterns store at `pan-wizard-core/learnings/`:
466
+
467
+ - `learnings/universal/<topic>.md` — patterns that ship to every install (atomic-state, concurrency, idempotency, secret-handling, test-patterns, …). Loaded by planner / executor / verifier agents during their work.
468
+ - `learnings/internal/<topic>.md` — PAN-development patterns; source-only (stripped at install).
469
+ - `learnings/index.json` — topic→agent-relevance map. Workflows call `pan-tools learn topics-for --agent <role> --token-budget N` to load only relevant patterns instead of skim-everything (avoids the distractor-density anti-pattern).
470
+ - `pan-tools learn lint` — integrity check (duplicate IDs, dangling refs, scope leaks). Wired into `/check`.
471
+
454
472
  ### Atomic Git Commits
455
473
 
456
474
  Each task gets its own commit immediately after completion:
@@ -553,7 +571,8 @@ PAN is not a replacement for your IDE or AI agent — it's the orchestration lay
553
571
  | `/pan:todo-check` | List pending todos |
554
572
  | `/pan:debug [desc]` | Systematic debugging with persistent state |
555
573
  | `/pan:quick [--full]` | Execute ad-hoc task with PAN guarantees (`--full` adds plan-checking and verification) |
556
- | `/pan:health [--repair] [--standards]` | Validate `.planning/` directory integrity, auto-repair with `--repair`, standards compliance with `--standards` |
574
+ | `/pan:health [--repair] [--standards] [--full] [--drift] [--links]` | Validate `.planning/` directory integrity. `--repair` auto-fixes; `--standards` checks compliance; `--full` runs tests + build; `--drift` runs convention drift; `--links` attaches doc-code link-graph summary |
575
+ | `/pan:links [--strict]` | Validate the doc-code link graph: inline `[[<id>]]` refs, `// @pan:` source anchors, `require-code-mention` contracts (ADR-0027, v3.8.0+) |
557
576
  | `/pan:phase-tests [N]` | Generate tests for a completed phase based on UAT criteria |
558
577
  | `/pan:milestone-cleanup` | Archive accumulated phase directories from completed milestones |
559
578
  | `/pan:retro` | Milestone retrospective — estimation accuracy, verification patterns, gap analysis |
@@ -0,0 +1,102 @@
1
+ ---
2
+ name: pan:links
3
+ group: Validation
4
+ description: Validate the doc-code link graph — inline wiki-style refs, source-comment anchors, and require-code-mention contracts (ADR-0027, v3.8.0+)
5
+ allowed-tools:
6
+ - Bash
7
+ - Read
8
+ - Grep
9
+ ---
10
+
11
+ # /pan:links
12
+
13
+ Validate the doc-code link graph. Walks `docs/`, `pan-wizard-core/`, `commands/`, and `agents/` for inline `[[<id>]]` references and `// @pan: <id>` source-comment anchors. Reports broken refs, stale anchors, and uncovered backlink contracts.
14
+
15
+ **Usage:**
16
+ ```
17
+ /pan:links
18
+ /pan:links --strict
19
+ /pan:links --doc-root <path> [--doc-root <path>...]
20
+ /pan:links --source-root <path> [--source-root <path>...]
21
+ ```
22
+
23
+ **Flags:**
24
+ - `--strict` — fail (exit 1) on warnings, not only errors. Default is advisory: warnings do not flip status.
25
+ - `--doc-root <path>` — override default doc roots. Repeatable.
26
+ - `--source-root <path>` — override default source roots. Repeatable.
27
+ - `--raw` — human-readable output instead of JSON.
28
+
29
+ **What it does:**
30
+
31
+ Three sequential passes share one walk pair:
32
+
33
+ 1. **Forward links** — every `[[<id>]]` in body text and every `must_haves.key_links` entry must resolve. Section anchors (`[[ADR-0021#Decision]]`) check that the named heading exists.
34
+ 2. **Backlink contract** — docs with `require-code-mention: true` in frontmatter must have at least one `@pan:` source anchor that resolves to them.
35
+ 3. **Anchor-target existence** — every `// @pan: <id>` comment must point to a real doc.
36
+
37
+ **Doc-id forms accepted:**
38
+
39
+ - `ADR-NNNN` — resolves via glob to `docs/decisions/ADR-NNNN-*.md`
40
+ - `<path>.md` — exact path relative to repo root
41
+ - `<path>` (no extension) — tries `<path>.md` then `<path>/README.md`
42
+ - Any of the above with `#section` — verifies a heading whose slug matches
43
+
44
+ **Source-anchor grammar:**
45
+
46
+ ```
47
+ // @pan: ADR-0027 (JS / TS / CJS)
48
+ # @pan: ADR-0027 (Python / shell)
49
+ <!-- @pan: ADR-0027 --> (Markdown / HTML)
50
+ ```
51
+
52
+ Anchors cluster at the top of a file under a single banner; comment leader must be the line's first non-whitespace token.
53
+
54
+ **Exit codes:**
55
+
56
+ - `0` — pass
57
+ - `1` — fail (errors present, or warnings present under `--strict`)
58
+
59
+ **Output (JSON):**
60
+
61
+ ```json
62
+ {
63
+ "ok": true,
64
+ "summary": {
65
+ "total_findings": 0,
66
+ "errors": 0,
67
+ "warnings": 0,
68
+ "status": "pass",
69
+ "doc_files_scanned": 280,
70
+ "source_files_scanned": 170,
71
+ "anchors_found": 4,
72
+ "forward_links_found": 12,
73
+ "backlink_contracts_checked": 3
74
+ },
75
+ "findings": []
76
+ }
77
+ ```
78
+
79
+ **Finding codes:**
80
+
81
+ | Code | Severity | Meaning |
82
+ |---|---|---|
83
+ | F-001 | error | Inline `[[<id>]]` does not resolve |
84
+ | F-002 | error | `[[<doc>#<section>]]` resolves the file but the section is missing |
85
+ | F-003 | warning | `must_haves.key_links` entry's `from` or `to` does not exist |
86
+ | F-004 | warning | `must_haves.key_links` regex pattern is invalid |
87
+ | B-001 | error | Doc has `require-code-mention: true` but no `@pan:` anchors resolve to it |
88
+ | B-002 | warning | Doc is anchored by exactly one source file (single-source informational) |
89
+ | A-001 | error | `@pan:` anchor target does not resolve |
90
+ | A-002 | warning | `@pan:` anchor section is missing in the resolved file |
91
+ | A-004 | warning | `@pan:` anchor has empty id |
92
+
93
+ **Composing with `validate health`:**
94
+
95
+ `validate health --links` includes the link-graph summary as a `link_graph` field in the health report. Used as a pre-flight check before release. Errors degrade the health report to a warning-level issue (`LINKS_ERR`); non-blocking unless `--strict` is added to a separate `links validate` invocation.
96
+
97
+ **See also:**
98
+
99
+ - ADR-0027 — Doc–Code Link Graph
100
+ - `docs/specs/doc_code_link_graph_featureai.md` — wire-level spec
101
+ - `pan-tools doc-lint` — frontmatter schema validator (orthogonal concern)
102
+ - `pan-tools verify-key-links` — legacy frontmatter-only link verifier (subsumed; both still ship)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-wizard",
3
- "version": "3.7.10",
3
+ "version": "3.8.0",
4
4
  "description": "A lightweight workflow automation and context engineering system for Claude Code, OpenCode, Gemini CLI, Codex, and Copilot CLI.",
5
5
  "bin": {
6
6
  "pan-wizard": "bin/install.js"
@@ -51,7 +51,7 @@
51
51
  "devDependencies": {
52
52
  "@playwright/test": "^1.58.2",
53
53
  "@vscode/test-electron": "^2.5.2",
54
- "esbuild": "^0.24.0"
54
+ "esbuild": "^0.28.0"
55
55
  },
56
56
  "scripts": {
57
57
  "build:hooks": "node scripts/build-hooks.js",
@@ -5,6 +5,8 @@
5
5
  * Supports JS/TS (v0), with extensible language registry for Python/Go/Rust/Java/C# (v1).
6
6
  */
7
7
 
8
+ // @pan: ADR-0021
9
+
8
10
  const fs = require('fs');
9
11
  const path = require('path');
10
12
  const { CODEBASE_DIR, DRIFT_MAX_FILE_SIZE } = require('./constants.cjs');
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ // @pan: ADR-0026
2
3
  /**
3
4
  * experiment.cjs — Self-improvement loop W1: experiment scaffolding.
4
5
  *
@@ -0,0 +1,549 @@
1
+ 'use strict';
2
+ // @pan: ADR-0027
3
+ /**
4
+ * Links — Doc–Code link graph scanner and lint.
5
+ *
6
+ * Implements ADR-0027 (Doc–Code Link Graph).
7
+ * Spec: docs/specs/doc_code_link_graph_featureai.md
8
+ *
9
+ * Three lint passes share one walk pair:
10
+ * - Forward links: inline [[<id>]] in body + must_haves.key_links in frontmatter.
11
+ * - Backlink contract: docs with `require-code-mention: true` must have at
12
+ * least one resolving @pan: anchor.
13
+ * - Anchor-target existence: every @pan: anchor must resolve to a real doc.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { safeReadFile, toPosix, output } = require('./core.cjs');
19
+ const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
20
+ const { walkMarkdownFiles } = require('./doc-lint/walk.js');
21
+
22
+ const DEFAULT_DOC_ROOTS = [
23
+ 'docs',
24
+ 'pan-wizard-core/workflows',
25
+ 'pan-wizard-core/templates',
26
+ 'pan-wizard-core/references',
27
+ 'pan-wizard-core/learnings',
28
+ 'commands',
29
+ 'agents',
30
+ ];
31
+
32
+ const DEFAULT_SOURCE_ROOTS = [
33
+ 'pan-wizard-core',
34
+ 'bin',
35
+ 'hooks',
36
+ 'scripts',
37
+ ];
38
+
39
+ const SOURCE_EXT_TO_LEADER = {
40
+ '.cjs': '//',
41
+ '.js': '//',
42
+ '.mjs': '//',
43
+ '.ts': '//',
44
+ '.sh': '#',
45
+ '.py': '#',
46
+ '.ps1': '#',
47
+ '.md': '<!--',
48
+ '.html': '<!--',
49
+ };
50
+
51
+ const ANCHOR_RES = {
52
+ '//': /^\s*\/\/\s*@pan:\s*([^\s].*?)\s*$/,
53
+ '#': /^\s*#\s*@pan:\s*([^\s].*?)\s*$/,
54
+ '<!--': /^\s*<!--\s*@pan:\s*([^\s].*?)\s*(?:-->)?\s*$/,
55
+ };
56
+
57
+ const INLINE_LINK_RE = /\[\[([^\[\]\s|][^\[\]]*?)\]\]/g;
58
+ const ADR_SHORT_RE = /^ADR-(\d{4})$/i;
59
+
60
+ const SKIP_DIR_NAMES = new Set(['node_modules', '.git', 'dist', '.cache', 'coverage']);
61
+
62
+ // ─── Doc-id resolver ─────────────────────────────────────────────────────────
63
+
64
+ function slugify(s) {
65
+ return String(s).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
66
+ }
67
+
68
+ function fileHasSection(filePath, section) {
69
+ const content = safeReadFile(filePath);
70
+ if (!content) return false;
71
+ const target = slugify(section);
72
+ const lines = content.split('\n');
73
+ for (const line of lines) {
74
+ const m = line.match(/^#{1,6}\s+(.+?)\s*$/);
75
+ if (m && slugify(m[1]) === target) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ function resolveDocId(rawId, cwd) {
81
+ if (!rawId || !rawId.trim()) return { resolved: false, reason: 'empty id' };
82
+ let id = rawId.trim();
83
+ let section = null;
84
+ const hashIdx = id.indexOf('#');
85
+ if (hashIdx !== -1) {
86
+ section = id.slice(hashIdx + 1).trim();
87
+ id = id.slice(0, hashIdx).trim();
88
+ }
89
+
90
+ // ADR-NNNN shortcut → glob docs/decisions/ADR-NNNN-*.md
91
+ const adrMatch = id.match(ADR_SHORT_RE);
92
+ if (adrMatch) {
93
+ const num = adrMatch[1];
94
+ const decisionsDir = path.join(cwd, 'docs', 'decisions');
95
+ let entries = [];
96
+ try {
97
+ entries = fs.readdirSync(decisionsDir);
98
+ } catch {
99
+ return { resolved: false, reason: 'docs/decisions/ not found' };
100
+ }
101
+ const candidates = entries.filter(f =>
102
+ f.toLowerCase().startsWith(`adr-${num}-`) && f.endsWith('.md')
103
+ );
104
+ if (candidates.length === 0) {
105
+ return { resolved: false, reason: `no ADR-${num}-*.md found` };
106
+ }
107
+ if (candidates.length > 1) {
108
+ return { resolved: false, reason: `ambiguous ADR-${num}: ${candidates.join(', ')}` };
109
+ }
110
+ const relPath = toPosix(path.join('docs', 'decisions', candidates[0]));
111
+ if (section) {
112
+ const fullPath = path.join(cwd, 'docs', 'decisions', candidates[0]);
113
+ if (fileHasSection(fullPath, section)) {
114
+ return { resolved: true, path: relPath, section };
115
+ }
116
+ return { resolved: true, path: relPath, section, sectionMissing: true };
117
+ }
118
+ return { resolved: true, path: relPath };
119
+ }
120
+
121
+ // Direct .md path
122
+ if (id.endsWith('.md')) {
123
+ const fullPath = path.join(cwd, id);
124
+ try { fs.accessSync(fullPath); }
125
+ catch { return { resolved: false, reason: `${id} not found` }; }
126
+ if (section) {
127
+ if (fileHasSection(fullPath, section)) {
128
+ return { resolved: true, path: toPosix(id), section };
129
+ }
130
+ return { resolved: true, path: toPosix(id), section, sectionMissing: true };
131
+ }
132
+ return { resolved: true, path: toPosix(id) };
133
+ }
134
+
135
+ // Try <id>.md, then <id>/README.md
136
+ const candidates = [`${id}.md`, path.join(id, 'README.md')];
137
+ for (const cand of candidates) {
138
+ const fullPath = path.join(cwd, cand);
139
+ try {
140
+ fs.accessSync(fullPath);
141
+ const relCand = toPosix(cand);
142
+ if (section) {
143
+ if (fileHasSection(fullPath, section)) {
144
+ return { resolved: true, path: relCand, section };
145
+ }
146
+ return { resolved: true, path: relCand, section, sectionMissing: true };
147
+ }
148
+ return { resolved: true, path: relCand };
149
+ } catch { /* try next */ }
150
+ }
151
+ return { resolved: false, reason: `${id} (tried ${id}.md and ${id}/README.md)` };
152
+ }
153
+
154
+ // ─── Forward-link scanner ────────────────────────────────────────────────────
155
+
156
+ function stripInlineCodeSpans(line) {
157
+ // Replace `...` spans (and ``...`` etc.) with placeholders so [[...]] inside
158
+ // backticks is not picked up as a real link.
159
+ return line.replace(/(`+)([^`]|(?!\1)`)*?\1/g, m => ' '.repeat(m.length));
160
+ }
161
+
162
+ function parseInlineLinks(text) {
163
+ const out = [];
164
+ const lines = text.split('\n');
165
+ let inFence = false;
166
+ let fenceMarker = '';
167
+ let inFrontmatter = false;
168
+ let frontmatterDone = false;
169
+ for (let i = 0; i < lines.length; i++) {
170
+ const line = lines[i];
171
+ // Skip leading YAML frontmatter (--- on line 1, then content, then closing ---).
172
+ // Only the leading block; subsequent --- in body is unaffected.
173
+ if (i === 0 && line.trim() === '---') { inFrontmatter = true; continue; }
174
+ if (inFrontmatter && !frontmatterDone) {
175
+ if (line.trim() === '---') { inFrontmatter = false; frontmatterDone = true; }
176
+ continue;
177
+ }
178
+ // Toggle fenced-code-block state on lines opening/closing ``` or ~~~
179
+ const fenceMatch = line.match(/^(\s{0,3})(```+|~~~+)(.*)$/);
180
+ if (fenceMatch) {
181
+ const marker = fenceMatch[2];
182
+ if (!inFence) { inFence = true; fenceMarker = marker[0]; continue; }
183
+ if (marker[0] === fenceMarker) { inFence = false; fenceMarker = ''; continue; }
184
+ }
185
+ if (inFence) continue;
186
+ const stripped = stripInlineCodeSpans(line);
187
+ INLINE_LINK_RE.lastIndex = 0;
188
+ let m;
189
+ while ((m = INLINE_LINK_RE.exec(stripped)) !== null) {
190
+ out.push({ rawId: m[1].trim(), line: i + 1 });
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
196
+ function safeWalkDocs(rootAbs) {
197
+ try {
198
+ return walkMarkdownFiles(rootAbs, { exclude: ['**/node_modules/**'] });
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ function scanForwardLinks(docRoots, cwd) {
205
+ const out = [];
206
+ for (const root of docRoots) {
207
+ const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
208
+ const files = safeWalkDocs(fullDir);
209
+ if (!files) continue;
210
+ for (const file of files) {
211
+ if (file.readError) continue;
212
+ const relPath = toPosix(path.relative(cwd, file.path));
213
+ for (const link of parseInlineLinks(file.content)) {
214
+ out.push({
215
+ source: relPath,
216
+ sourceLine: link.line,
217
+ rawId: link.rawId,
218
+ via: 'inline',
219
+ });
220
+ }
221
+ try {
222
+ const keyLinks = parseMustHavesBlock(file.content, 'key_links');
223
+ for (const link of keyLinks) {
224
+ if (typeof link === 'string') continue;
225
+ out.push({
226
+ source: relPath,
227
+ sourceLine: 0,
228
+ rawId: link.to || '',
229
+ via: 'key_links',
230
+ from: link.from || '',
231
+ pattern: link.pattern || '',
232
+ });
233
+ }
234
+ } catch { /* malformed frontmatter — skip */ }
235
+ }
236
+ }
237
+ return out;
238
+ }
239
+
240
+ // ─── Source-anchor scanner ───────────────────────────────────────────────────
241
+
242
+ function leaderForFile(filePath) {
243
+ const ext = path.extname(filePath).toLowerCase();
244
+ return SOURCE_EXT_TO_LEADER[ext] || null;
245
+ }
246
+
247
+ function parseAnchorLine(line, leader) {
248
+ const re = ANCHOR_RES[leader];
249
+ if (!re) return null;
250
+ const m = line.match(re);
251
+ return m ? m[1].trim() : null;
252
+ }
253
+
254
+ function walkSourceFiles(rootDir, out) {
255
+ let entries;
256
+ try {
257
+ entries = fs.readdirSync(rootDir, { withFileTypes: true });
258
+ } catch {
259
+ return;
260
+ }
261
+ for (const entry of entries) {
262
+ if (entry.isDirectory()) {
263
+ if (SKIP_DIR_NAMES.has(entry.name)) continue;
264
+ walkSourceFiles(path.join(rootDir, entry.name), out);
265
+ continue;
266
+ }
267
+ if (!entry.isFile()) continue;
268
+ const leader = leaderForFile(entry.name);
269
+ if (!leader) continue;
270
+ out.push({ path: path.join(rootDir, entry.name), leader });
271
+ }
272
+ }
273
+
274
+ function scanAnchors(sourceRoots, cwd) {
275
+ const out = [];
276
+ const files = [];
277
+ for (const root of sourceRoots) {
278
+ const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
279
+ walkSourceFiles(fullDir, files);
280
+ }
281
+ for (const { path: fp, leader } of files) {
282
+ const content = safeReadFile(fp);
283
+ if (!content) continue;
284
+ const lines = content.split('\n');
285
+ const relPath = toPosix(path.relative(cwd, fp));
286
+ for (let i = 0; i < lines.length; i++) {
287
+ const id = parseAnchorLine(lines[i], leader);
288
+ if (id !== null) {
289
+ out.push({ source: relPath, sourceLine: i + 1, rawId: id, leader });
290
+ }
291
+ }
292
+ }
293
+ return out;
294
+ }
295
+
296
+ // ─── Lint passes ─────────────────────────────────────────────────────────────
297
+
298
+ function runForwardPass(forwardLinks, cwd) {
299
+ const findings = [];
300
+ for (const link of forwardLinks) {
301
+ if (link.via === 'inline') {
302
+ const r = resolveDocId(link.rawId, cwd);
303
+ if (!r.resolved) {
304
+ findings.push({
305
+ code: 'F-001',
306
+ severity: 'error',
307
+ source: link.source,
308
+ source_line: link.sourceLine,
309
+ target: link.rawId,
310
+ detail: r.reason || 'unresolved',
311
+ });
312
+ } else if (r.sectionMissing) {
313
+ findings.push({
314
+ code: 'F-002',
315
+ severity: 'error',
316
+ source: link.source,
317
+ source_line: link.sourceLine,
318
+ target: link.rawId,
319
+ detail: `Section "#${r.section}" not found in ${r.path}`,
320
+ });
321
+ }
322
+ continue;
323
+ }
324
+ if (link.via === 'key_links') {
325
+ if (link.from) {
326
+ try { fs.accessSync(path.join(cwd, link.from)); }
327
+ catch {
328
+ findings.push({
329
+ code: 'F-003', severity: 'warning',
330
+ source: link.source, source_line: 0, target: link.from,
331
+ detail: `key_links.from path does not exist: ${link.from}`,
332
+ });
333
+ }
334
+ }
335
+ if (link.rawId) {
336
+ try { fs.accessSync(path.join(cwd, link.rawId)); }
337
+ catch {
338
+ findings.push({
339
+ code: 'F-003', severity: 'warning',
340
+ source: link.source, source_line: 0, target: link.rawId,
341
+ detail: `key_links.to path does not exist: ${link.rawId}`,
342
+ });
343
+ }
344
+ }
345
+ if (link.pattern) {
346
+ try { new RegExp(link.pattern); }
347
+ catch (e) {
348
+ findings.push({
349
+ code: 'F-004', severity: 'warning',
350
+ source: link.source, source_line: 0, target: link.rawId,
351
+ detail: `Invalid regex in key_links.pattern: ${e.message}`,
352
+ });
353
+ }
354
+ }
355
+ }
356
+ }
357
+ return findings;
358
+ }
359
+
360
+ function runBacklinkPass(docRoots, anchors, cwd) {
361
+ const findings = [];
362
+
363
+ // Index: resolved doc path → array of anchor source files
364
+ const anchorIdx = new Map();
365
+ for (const a of anchors) {
366
+ const r = resolveDocId(a.rawId, cwd);
367
+ if (!r.resolved) continue;
368
+ if (!anchorIdx.has(r.path)) anchorIdx.set(r.path, []);
369
+ anchorIdx.get(r.path).push(a.source);
370
+ }
371
+
372
+ for (const root of docRoots) {
373
+ const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
374
+ const files = safeWalkDocs(fullDir);
375
+ if (!files) continue;
376
+ for (const file of files) {
377
+ if (file.readError) continue;
378
+ const fm = extractFrontmatter(file.content);
379
+ const requireMention = fm['require-code-mention'];
380
+ if (requireMention !== true && requireMention !== 'true') continue;
381
+ const relPath = toPosix(path.relative(cwd, file.path));
382
+ const sources = anchorIdx.get(relPath) || [];
383
+ if (sources.length === 0) {
384
+ findings.push({
385
+ code: 'B-001', severity: 'error',
386
+ source: relPath, source_line: 0, target: null,
387
+ detail: 'require-code-mention is true but no @pan: anchors resolve to this doc',
388
+ });
389
+ continue;
390
+ }
391
+ const unique = new Set(sources);
392
+ if (unique.size === 1) {
393
+ findings.push({
394
+ code: 'B-002', severity: 'warning',
395
+ source: relPath, source_line: 0, target: [...unique][0],
396
+ detail: `Only one source file anchors this doc (${[...unique][0]})`,
397
+ });
398
+ }
399
+ }
400
+ }
401
+ return findings;
402
+ }
403
+
404
+ function runAnchorTargetPass(anchors, cwd) {
405
+ const findings = [];
406
+ for (const a of anchors) {
407
+ if (!a.rawId) {
408
+ findings.push({
409
+ code: 'A-004', severity: 'warning',
410
+ source: a.source, source_line: a.sourceLine,
411
+ target: null, detail: '@pan: anchor has empty id',
412
+ });
413
+ continue;
414
+ }
415
+ const r = resolveDocId(a.rawId, cwd);
416
+ if (!r.resolved) {
417
+ findings.push({
418
+ code: 'A-001', severity: 'error',
419
+ source: a.source, source_line: a.sourceLine,
420
+ target: a.rawId, detail: r.reason || 'unresolved',
421
+ });
422
+ } else if (r.sectionMissing) {
423
+ findings.push({
424
+ code: 'A-002', severity: 'warning',
425
+ source: a.source, source_line: a.sourceLine,
426
+ target: a.rawId,
427
+ detail: `Section "#${r.section}" not found in ${r.path}`,
428
+ });
429
+ }
430
+ }
431
+ return findings;
432
+ }
433
+
434
+ function countBacklinkContracts(docRoots, cwd) {
435
+ let n = 0;
436
+ for (const root of docRoots) {
437
+ const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
438
+ const files = safeWalkDocs(fullDir);
439
+ if (!files) continue;
440
+ for (const file of files) {
441
+ if (file.readError) continue;
442
+ const fm = extractFrontmatter(file.content);
443
+ const v = fm['require-code-mention'];
444
+ if (v === true || v === 'true') n++;
445
+ }
446
+ }
447
+ return n;
448
+ }
449
+
450
+ function countDocFiles(docRoots, cwd) {
451
+ let n = 0;
452
+ for (const root of docRoots) {
453
+ const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
454
+ const files = safeWalkDocs(fullDir);
455
+ if (files) n += files.length;
456
+ }
457
+ return n;
458
+ }
459
+
460
+ function countSourceFiles(sourceRoots, cwd) {
461
+ const acc = [];
462
+ for (const root of sourceRoots) {
463
+ const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
464
+ walkSourceFiles(fullDir, acc);
465
+ }
466
+ return acc.length;
467
+ }
468
+
469
+ // ─── Top-level validateAll ───────────────────────────────────────────────────
470
+
471
+ function validateAll(cwd, opts = {}) {
472
+ const docRoots = opts.docRoots || DEFAULT_DOC_ROOTS;
473
+ const sourceRoots = opts.sourceRoots || DEFAULT_SOURCE_ROOTS;
474
+ const strict = !!opts.strict;
475
+
476
+ const forwardLinks = scanForwardLinks(docRoots, cwd);
477
+ const anchors = scanAnchors(sourceRoots, cwd);
478
+
479
+ const findings = [];
480
+ findings.push(...runForwardPass(forwardLinks, cwd));
481
+ findings.push(...runBacklinkPass(docRoots, anchors, cwd));
482
+ findings.push(...runAnchorTargetPass(anchors, cwd));
483
+
484
+ const errors = findings.filter(f => f.severity === 'error').length;
485
+ const warnings = findings.filter(f => f.severity === 'warning').length;
486
+ // Per spec §5.2: B-002 is informational and does not flip status under --strict.
487
+ const strictWarnings = findings.filter(f => f.severity === 'warning' && f.code !== 'B-002').length;
488
+ let status;
489
+ if (errors > 0) status = 'fail';
490
+ else if (strict && strictWarnings > 0) status = 'fail';
491
+ else status = 'pass';
492
+
493
+ return {
494
+ ok: status === 'pass',
495
+ summary: {
496
+ total_findings: findings.length,
497
+ errors,
498
+ warnings,
499
+ status,
500
+ doc_files_scanned: countDocFiles(docRoots, cwd),
501
+ source_files_scanned: countSourceFiles(sourceRoots, cwd),
502
+ anchors_found: anchors.length,
503
+ forward_links_found: forwardLinks.length,
504
+ backlink_contracts_checked: countBacklinkContracts(docRoots, cwd),
505
+ },
506
+ findings,
507
+ };
508
+ }
509
+
510
+ function cmdLinksValidate(cwd, opts = {}) {
511
+ const result = validateAll(cwd, opts);
512
+ // Bypass core.output() because it unconditionally exits 0; we need exit 1
513
+ // when status is "fail" so CI / hooks can detect violations.
514
+ if (opts.raw) {
515
+ const lines = [
516
+ `Links: ${result.summary.status.toUpperCase()}`,
517
+ ``,
518
+ `Doc files scanned: ${result.summary.doc_files_scanned}`,
519
+ `Source files scanned: ${result.summary.source_files_scanned}`,
520
+ `Forward links: ${result.summary.forward_links_found}`,
521
+ `Anchors: ${result.summary.anchors_found}`,
522
+ `Backlink contracts: ${result.summary.backlink_contracts_checked}`,
523
+ ``,
524
+ `Errors: ${result.summary.errors}`,
525
+ `Warnings: ${result.summary.warnings}`,
526
+ ``,
527
+ ];
528
+ for (const f of result.findings) {
529
+ const where = f.source_line ? `${f.source}:${f.source_line}` : f.source;
530
+ lines.push(`[${f.severity.toUpperCase()}] ${f.code} ${where}: ${f.detail}`);
531
+ }
532
+ process.stdout.write(lines.join('\n'));
533
+ } else {
534
+ process.stdout.write(JSON.stringify(result, null, 2));
535
+ }
536
+ process.exit(result.summary.status === 'fail' ? 1 : 0);
537
+ }
538
+
539
+ module.exports = {
540
+ validateAll,
541
+ cmdLinksValidate,
542
+ scanForwardLinks,
543
+ scanAnchors,
544
+ resolveDocId,
545
+ parseAnchorLine,
546
+ parseInlineLinks,
547
+ DEFAULT_DOC_ROOTS,
548
+ DEFAULT_SOURCE_ROOTS,
549
+ };
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ // @pan: ADR-0026
2
3
  /**
3
4
  * runner.cjs — Self-improvement loop W2: external agent runner.
4
5
  *
@@ -1215,6 +1215,26 @@ function cmdValidateHealth(cwd, options, raw) {
1215
1215
  }
1216
1216
  }
1217
1217
 
1218
+ // Check 12 (optional): doc-code link graph (ADR-0027)
1219
+ let linkGraphResult;
1220
+ if (options.links) {
1221
+ const links = require('./links.cjs');
1222
+ const r = links.validateAll(cwd);
1223
+ linkGraphResult = {
1224
+ status: r.summary.status,
1225
+ errors: r.summary.errors,
1226
+ warnings: r.summary.warnings,
1227
+ doc_files_scanned: r.summary.doc_files_scanned,
1228
+ source_files_scanned: r.summary.source_files_scanned,
1229
+ anchors_found: r.summary.anchors_found,
1230
+ forward_links_found: r.summary.forward_links_found,
1231
+ backlink_contracts_checked: r.summary.backlink_contracts_checked,
1232
+ };
1233
+ if (r.summary.errors > 0) {
1234
+ addIssue('warning', 'LINKS_ERR', `Link graph has ${r.summary.errors} errors (broken refs or uncovered backlink contracts)`, 'Run pan-tools links validate for details');
1235
+ }
1236
+ }
1237
+
1218
1238
  const result = {
1219
1239
  status,
1220
1240
  errors,
@@ -1230,6 +1250,9 @@ function cmdValidateHealth(cwd, options, raw) {
1230
1250
  if (options.drift) {
1231
1251
  result.drift_status = driftResult;
1232
1252
  }
1253
+ if (options.links) {
1254
+ result.link_graph = linkGraphResult;
1255
+ }
1233
1256
 
1234
1257
  output(result, raw);
1235
1258
  }
@@ -214,6 +214,7 @@ const runner = require('./lib/runner.cjs');
214
214
  const docLint = require('./lib/doc-lint.cjs');
215
215
  const learnLint = require('./lib/learn-lint.cjs');
216
216
  const learnIndex = require('./lib/learn-index.cjs');
217
+ const links = require('./lib/links.cjs');
217
218
 
218
219
  /**
219
220
  * Get the value following a flag in the args array.
@@ -663,7 +664,8 @@ async function main() {
663
664
  const standardsFlag = args.includes('--standards');
664
665
  const fullFlag = args.includes('--full');
665
666
  const driftFlag = args.includes('--drift');
666
- verify.cmdValidateHealth(cwd, { repair: repairFlag, standards: standardsFlag, full: fullFlag, drift: driftFlag }, raw);
667
+ const linksFlag = args.includes('--links');
668
+ verify.cmdValidateHealth(cwd, { repair: repairFlag, standards: standardsFlag, full: fullFlag, drift: driftFlag, links: linksFlag }, raw);
667
669
  } else if (subcommand === 'deployment') {
668
670
  verify.cmdValidateDeployment(cwd, raw);
669
671
  } else {
@@ -1307,6 +1309,28 @@ async function main() {
1307
1309
  break;
1308
1310
  }
1309
1311
 
1312
+ case 'links': {
1313
+ const subcommand = args[1];
1314
+ if (subcommand === 'validate' || !subcommand) {
1315
+ const collectMulti = (flag) => {
1316
+ const vals = [];
1317
+ for (let i = 0; i < args.length; i++) {
1318
+ if (args[i] === flag && i + 1 < args.length) vals.push(args[i + 1]);
1319
+ }
1320
+ return vals.length ? vals : null;
1321
+ };
1322
+ const opts = {
1323
+ docRoots: collectMulti('--doc-root'),
1324
+ sourceRoots: collectMulti('--source-root'),
1325
+ strict: args.includes('--strict'),
1326
+ raw,
1327
+ };
1328
+ links.cmdLinksValidate(cwd, opts);
1329
+ break;
1330
+ }
1331
+ error(`Unknown links subcommand: ${subcommand}. Available: validate`);
1332
+ }
1333
+
1310
1334
  default:
1311
1335
  error(`Unknown command: ${command}. Run pan-tools without arguments to see available commands.`);
1312
1336
  }
@@ -0,0 +1,40 @@
1
+ #!/bin/sh
2
+ # PAN Wizard pre-commit hook.
3
+ #
4
+ # Runs `gitleaks protect` against staged changes. Blocks the commit if a
5
+ # secret is detected. The PAN allowlists in .gitleaks.toml are honoured.
6
+ #
7
+ # Install once per clone:
8
+ # cp scripts/git-hooks/pre-commit .git/hooks/pre-commit
9
+ # chmod +x .git/hooks/pre-commit
10
+ # Or with a symlink (Unix / Git Bash):
11
+ # ln -sf ../../scripts/git-hooks/pre-commit .git/hooks/pre-commit
12
+ #
13
+ # Bypass for an emergency (creates a paper trail in the commit log):
14
+ # SKIP_GITLEAKS=1 git commit -m "..."
15
+ #
16
+ # Exit codes:
17
+ # 0 ok — no secrets detected (or gitleaks not installed; we don't block).
18
+ # 1 gitleaks found something — fix the staged change before committing.
19
+
20
+ set -e
21
+
22
+ if [ "${SKIP_GITLEAKS:-}" = "1" ]; then
23
+ echo "[pre-commit] SKIP_GITLEAKS=1 — gitleaks bypassed."
24
+ exit 0
25
+ fi
26
+
27
+ if ! command -v gitleaks >/dev/null 2>&1; then
28
+ echo "[pre-commit] gitleaks not installed — skipping secret scan." >&2
29
+ echo "[pre-commit] Install with: winget install gitleaks.gitleaks" >&2
30
+ exit 0
31
+ fi
32
+
33
+ REPO_ROOT="$(git rev-parse --show-toplevel)"
34
+ CONFIG="$REPO_ROOT/.gitleaks.toml"
35
+
36
+ if [ -f "$CONFIG" ]; then
37
+ exec gitleaks protect --staged --no-banner --redact --config "$CONFIG"
38
+ else
39
+ exec gitleaks protect --staged --no-banner --redact
40
+ fi