pi-crew 0.9.48 → 0.9.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/AGENTS.md +18 -0
  2. package/CHANGELOG.md +314 -0
  3. package/dist/build-meta.json +70 -42
  4. package/dist/index.mjs +505 -430
  5. package/dist/index.mjs.map +4 -4
  6. package/docs/decisions/2026-07-24-oidc-trusted-publishing.md +112 -0
  7. package/package.json +2 -3
  8. package/skills/.gitkeep +0 -0
  9. package/skills/distill-persona/BUILD-NOTES.md +55 -0
  10. package/skills/distill-persona/SKILL.md +550 -0
  11. package/skills/distill-persona/UPGRADE-LOG-RESEARCH-SKILLS.md +100 -0
  12. package/skills/distill-persona/references/coverage-manifest.md +65 -0
  13. package/skills/distill-persona/references/cross-skill-differentiation.md +12 -0
  14. package/skills/distill-persona/references/description-discipline.md +6 -0
  15. package/skills/distill-persona/references/diagnostic-path.md +25 -0
  16. package/skills/distill-persona/references/distillation-field-synthesis-pass2.md +59 -0
  17. package/skills/distill-persona/references/distillation-field-synthesis.md +108 -0
  18. package/skills/distill-persona/references/fidelity-rubric.md +19 -0
  19. package/skills/distill-persona/references/field-models.md +20 -0
  20. package/skills/distill-persona/references/handoff.md +42 -0
  21. package/skills/distill-persona/references/optional-body-sections.md +9 -0
  22. package/skills/distill-persona/references/registry-routing.md +11 -0
  23. package/skills/distill-persona/references/research/lesson-memory-shortcut.md +33 -0
  24. package/skills/distill-persona/references/research/r1-a-examples.md +23 -0
  25. package/skills/distill-persona/references/research/r1-b-scripts.md +26 -0
  26. package/skills/distill-persona/references/research/r1-c-human-readme.md +31 -0
  27. package/skills/distill-persona/references/research/r1-d-tests.md +28 -0
  28. package/skills/distill-persona/references/research/r1-verification.md +36 -0
  29. package/skills/distill-persona/references/research/r2-low-yield.md +26 -0
  30. package/skills/distill-persona/references/self-upgrade-directive.md +20 -0
  31. package/skills/distill-persona/references/taste-principles.md +8 -0
  32. package/skills/distill-persona/references/topic-variant.md +13 -0
  33. package/skills/distill-persona/references/update-mode.md +7 -0
  34. package/skills/distill-persona/scripts/fidelity_eval.py +244 -0
  35. package/skills/distill-persona/scripts/validate-run.mjs +297 -0
  36. package/skills/distill-persona/scripts/validate-skill-structure.mjs +177 -0
  37. package/skills/distill-software/BUILD-NOTES.md +56 -0
  38. package/skills/distill-software/SKILL.md +363 -0
  39. package/skills/distill-software/references/handoff.md +47 -0
  40. package/skills/distill-software/scripts/code_dna.py +290 -0
  41. package/skills/research/DISTILLATION-PROCESS-CHECKLIST.md +120 -0
  42. package/skills/research/EXCAVATION-CHECKLIST.md +142 -0
  43. package/skills/research/FIDELITY.md +180 -0
  44. package/skills/research/SKILL.md +432 -0
  45. package/skills/research/references/anti-patterns.md +184 -0
  46. package/skills/research/references/fidelity.md +241 -0
  47. package/skills/research/references/handoff.md +48 -0
  48. package/skills/research/references/research-protocol.md +162 -0
  49. package/skills/research/references/source-inventory.md +135 -0
  50. package/skills/research/references/verified-models.md +163 -0
  51. package/skills/research/scripts/__pycache__/safe_io.cpython-312.pyc +0 -0
  52. package/skills/research/scripts/code_dna.py +233 -0
  53. package/skills/research/scripts/emit_run_summary.py +142 -0
  54. package/skills/research/scripts/safe_io.py +314 -0
  55. package/skills/research/scripts/source_evaluator.py +234 -0
  56. package/skills/research/scripts/validate-skill-structure.mjs +177 -0
  57. package/skills/research/scripts/verify_citations.py +225 -0
  58. package/skills/security-priority.json +28 -0
  59. package/src/config/config.ts +1 -0
  60. package/src/config/role-tools.ts +6 -3
  61. package/src/config/types.ts +8 -0
  62. package/src/extension/crew-cleanup.ts +18 -1
  63. package/src/extension/crew-vibes/index.ts +11 -2
  64. package/src/extension/register.ts +1 -1
  65. package/src/extension/registration/command-registration.ts +1 -0
  66. package/src/extension/registration/commands.ts +7 -3
  67. package/src/extension/registration/lifecycle-handlers.ts +1 -3
  68. package/src/extension/registration/ui.ts +4 -0
  69. package/src/extension/registration/viewers.ts +3 -0
  70. package/src/extension/team-tool/run.ts +7 -6
  71. package/src/runtime/background-runner.ts +11 -16
  72. package/src/runtime/chain-runner.ts +3 -2
  73. package/src/runtime/heartbeat-watcher.ts +28 -1
  74. package/src/runtime/pipeline-runner.ts +8 -7
  75. package/src/runtime/task-runner.ts +165 -119
  76. package/src/schema/config-schema.ts +1 -0
  77. package/src/ui/live-run-sidebar.ts +2 -0
  78. package/src/ui/mascot.ts +11 -9
  79. package/src/ui/render-coalescer.ts +9 -0
  80. package/src/ui/run-snapshot-cache.ts +10 -11
  81. package/src/ui/terminal-status.ts +5 -0
  82. package/src/ui/widget/index.ts +3 -5
  83. package/src/ui/widget/widget-types.ts +0 -1
  84. package/src/utils/gh-protocol.ts +9 -8
  85. package/workflows/distill.workflow.md +198 -0
  86. package/assets/runner-spritesheet.png +0 -0
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+ // validate-skill-structure.mjs — structural invariant checker for generated distill skills.
3
+ // Implements F10 (awesome-persona-distill-skills finding): hard-fail if any structural assertion fails.
4
+ // Run AFTER Phase 3 build, BEFORE Phase 4 behavioral fidelity. Self-contained (stdlib only).
5
+ //
6
+ // Usage:
7
+ // node validate-skill-structure.mjs <path-to-SKILL.md>
8
+ // node validate-skill-structure.mjs <skill-dir>
9
+ //
10
+ // Exit codes: 0 = all assertions pass (all-green → may ship); 1 = one or more failed (iterate).
11
+
12
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
13
+ import { join, dirname, basename } from 'node:path';
14
+
15
+ const target = process.argv.find((a) => !a.startsWith('-') && a !== process.argv[0] && a !== process.argv[1]);
16
+ if (!target) {
17
+ console.error('Usage: validate-skill-structure.mjs <path-to-SKILL.md | skill-dir> [--engine]');
18
+ process.exit(2);
19
+ }
20
+
21
+ // Resolve to a SKILL.md path
22
+ let skillPath = target;
23
+ if (statSync(target).isDirectory()) {
24
+ skillPath = join(target, 'SKILL.md');
25
+ }
26
+ if (!existsSync(skillPath)) {
27
+ console.error(`✗ Not found: ${skillPath}`);
28
+ process.exit(2);
29
+ }
30
+
31
+ const src = readFileSync(skillPath, 'utf8');
32
+ const dir = dirname(skillPath);
33
+ const name = basename(dir);
34
+ const isEngine = process.argv.includes('--engine');
35
+ const PLACEHOLDERS = [/<person>/i, /<target>/i, /<topic>/i, /<field>/i, /TODO/i, /TBD/i, /XXX/i, /YYYY-MM-DD/i, /\.\.\.\s*<\/?/i];
36
+
37
+ const failures = [];
38
+ const passes = [];
39
+ const check = (label, ok, detail = '') => {
40
+ (ok ? passes : failures).push(ok ? ` ✓ ${label}` : ` ✗ ${label}${detail ? ' — ' + detail : ''}`);
41
+ };
42
+
43
+ // --- Frontmatter ---
44
+ const fm = src.match(/^---\n([\s\S]*?)\n---/);
45
+ check('frontmatter block present', !!fm, 'no --- block at top');
46
+ const fmText = fm ? fm[1] : '';
47
+ const fmField = (key) => {
48
+ const m = fmText.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
49
+ return m ? m[1].trim() : null;
50
+ };
51
+ const hasFm = (key) => new RegExp(`^${key}:`, 'm').test(fmText);
52
+
53
+ check('frontmatter: name', hasFm('name'));
54
+ check('frontmatter: description', hasFm('description'));
55
+ const desc = fmField('description');
56
+ check('frontmatter: description ≤1 sentence (one terminal punct or one clause)',
57
+ desc ? desc.split(/[.。!!??]/).filter(Boolean).length <= 2 : false,
58
+ desc ? `got: "${desc.slice(0, 60)}…"` : 'missing');
59
+ check('frontmatter: triggers', hasFm('triggers') || hasFm('trigger'), 'no triggers field');
60
+ if (!isEngine) check('frontmatter: distilled (staleness date)', hasFm('distilled') || hasFm('调研时间'), 'no distilled/调研时间 staleness anchor');
61
+ const distilled = fmField('distilled') || fmField('调研时间');
62
+ check('frontmatter: distilled is valid date (YYYY-MM-DD)',
63
+ !distilled || /^\d{4}-\d{2}-\d{2}/.test(distilled),
64
+ distilled ? `got "${distilled}"` : '');
65
+ if (!isEngine) check('frontmatter: target (person|topic|software)', hasFm('target'), 'no target field');
66
+
67
+ // --- Body sections ---
68
+ check('Agentic Protocol section present', /回答工作流|Agentic Protocol/i.test(src));
69
+ check('Agentic Protocol Step 1', /Step 1|第一步|步骤 1|### 1\b/i.test(src));
70
+ check('Agentic Protocol Step 2', /Step 2|第二步|步骤 2|### 2\b/i.test(src));
71
+ check('Agentic Protocol Step 3', /Step 3|第三步|步骤 3|### 3\b/i.test(src));
72
+
73
+ // honest boundaries: count items (numbered list or bullets) under an honest-boundaries heading
74
+ function countListItems(haystack, headingRe, windowChars = 2000) {
75
+ const m = haystack.match(headingRe);
76
+ if (!m) return { found: false, count: 0, block: '' };
77
+ const after = haystack.slice(m.index);
78
+ const section = after.match(/([\s\S]*?)\n##(?=[^#])/);
79
+ const block = section ? section[1] : after.slice(0, windowChars);
80
+ // count BOTH list items AND table data rows (rows with | content |, excluding separator rows)
81
+ const listItems = (block.match(/^\s*(?:\d+[.)]|[-*])\s+\S/gm) || []).length;
82
+ const tableRows = (block.match(/^\s*\|(?![\s:|-]+\|?\s*$).+\|/gm) || []).length;
83
+ return { found: true, count: listItems + tableRows, block };
84
+ }
85
+ if (!isEngine) {
86
+ const boundary = countListItems(src, /诚实边界|honest boundar(?:y|ies)/i);
87
+ check('honest boundaries (M11) ≥3', boundary.count >= 3, `found ${boundary.count} items`);
88
+ }
89
+
90
+ // --- Anti-drift tables (M9a 内在张力, M9b 反例黑名单, M12 fallback tree) — Darwin gap #2: validator previously skipped these
91
+ if (!isEngine) {
92
+ const tension = countListItems(src, /M9a|内在张力|inner tension|internal tension/i);
93
+ check('内在张力 (M9a) ≥3 tension pairs', tension.count >= 3, tension.found ? `found ${tension.count} items` : 'section missing');
94
+
95
+ const blacklist = countListItems(src, /M9b|反例黑名单|anti.?pattern blacklist/i);
96
+ check('反例黑名单 (M9b) ≥7 rows', blacklist.count >= 7, blacklist.found ? `found ${blacklist.count} rows` : 'section missing');
97
+
98
+ const fallback = countListItems(src, /M12|失败模式.*[Ff]allback|[Ff]allback\s*树/i);
99
+ check('失败模式Fallback树 (M12) ≥8 rows', fallback.count >= 8, fallback.found ? `found ${fallback.count} rows` : 'section missing');
100
+ }
101
+
102
+ // --- FIDELITY.md companion artifact (Darwin gap #3: ship-gate requires it but nothing checked)
103
+ const fidelityPath = join(dir, 'FIDELITY.md');
104
+ if (existsSync(fidelityPath)) {
105
+ const fm2 = readFileSync(fidelityPath, 'utf8');
106
+ const totalMatch = fm2.match(/(?:总分|total)[::\s*]*\*{0,2}([0-9]+)\s*\*?\s*(?:\/|/|\sout\sof\s)\s*\*?\s*100/i);
107
+ check('FIDELITY.md total score present', !!totalMatch, totalMatch ? `=${totalMatch[1]}/100` : 'no /100 score found');
108
+ const qCount = (fm2.match(/(?:Q[1-5]|问题[1-5]|question\s*[1-5])/gi) || []).length;
109
+ check('FIDELITY.md ≥5 test questions', qCount >= 5, `found ${qCount} question refs`);
110
+ check('FIDELITY.md flags single-agent/self-score caveat', /单\s*agent|single.?agent|self.?score|upper.?bound|independent/i.test(fm2), 'add single-agent upper-bound caveat');
111
+ } else if (!isEngine) {
112
+ check('FIDELITY.md companion present', false, 'no FIDELITY.md in skill dir');
113
+ }
114
+
115
+ // --- EXCAVATION-CHECKLIST.md (Phase 1 protocol: track + verify each part was really read)
116
+ const checklistPath = join(dir, 'EXCAVATION-CHECKLIST.md');
117
+ if (existsSync(checklistPath)) {
118
+ const cl = readFileSync(checklistPath, 'utf8');
119
+ // dangling rows: status ⬜ not-started or ⏳ reading left at ship time = silently skipped/forgotten
120
+ const dangling = (cl.match(/\|\s*[⬜⏳][^|]*\|/gu) || []).length;
121
+ check('checklist: no dangling ⬜/⏳ rows (every part resolved)', dangling === 0, dangling ? `${dangling} row(s) not-started/reading — resolve to ✅📄/⏭/🧠` : '');
122
+ // memory ratio: parse "memory-ratio: NN%" if declared
123
+ const ratioMatch = cl.match(/memory-ratio[:\s]*([0-9]+)\s*%/i);
124
+ if (ratioMatch) {
125
+ const ratio = parseInt(ratioMatch[1], 10);
126
+ check('checklist: 🧠 memory-ratio ≤30%', ratio <= 30, `declared ${ratio}% (>30% = recap, not distillation)`);
127
+ } else {
128
+ check('checklist: declares memory-ratio', false, 'add "🧠 memory-ratio: NN% (X/Y findings)" header line');
129
+ }
130
+ // proof-of-read present: at least one verbatim-quote-with-location cell (heuristic — a ✅ row should carry a quote)
131
+ const proofCells = (cl.match(/"[^"]{8,}"[^|]*/g) || []).length;
132
+ check('checklist: ≥1 proof-of-read (verbatim quote in a ✅ row)', proofCells >= 1, `${proofCells} quote cell(s) found`);
133
+ } else if (!isEngine) {
134
+ check('EXCAVATION-CHECKLIST.md present', false, 'no excavation checklist — Phase 1 protocol requires one');
135
+ }
136
+
137
+ // --- DISTILLATION-PROCESS-CHECKLIST.md (whole-pipeline tracking + the 3-empty-rounds deep-dive gate)
138
+ const procPath = join(dir, 'DISTILLATION-PROCESS-CHECKLIST.md');
139
+ if (existsSync(procPath)) {
140
+ const pc = readFileSync(procPath, 'utf8');
141
+ // dangling phases: ⬜/⏳ left in the phase-progress table at ship = a phase skipped
142
+ const danglingPhases = (pc.match(/\|\s*[⬜⏳][^|]*\|/gu) || []).length;
143
+ check('process: no dangling ⬜/⏳ phases (every phase completed)', danglingPhases === 0, danglingPhases ? `${danglingPhases} phase(s) not done` : '');
144
+ // deep-dive round log present
145
+ check('process: deep-dive round log present', /round log|\| *Round.*New findings/i.test(pc), 'add the round-log table');
146
+ // 3-empty-rounds gate recorded (≥3 consecutive zero-new rounds before leaving a phase)
147
+ const gateFired = /gate fires|3 consecutive (empty|zero)|GATE FIRES/i.test(pc);
148
+ check('process: 3-empty-rounds gate recorded (≥3 zero-new rounds)', gateFired, 'record a round-log row marked gate-fired before declaring any research phase done');
149
+ } else if (!isEngine) {
150
+ check('DISTILLATION-PROCESS-CHECKLIST.md present', false, 'no process checklist — every phase + the 3-round deep-dive gate must be tracked');
151
+ }
152
+
153
+ // --- Placeholders ---
154
+ const foundPlaceholders = PLACEHOLDERS.filter((re) => re.test(src));
155
+ if (!isEngine) check('no unresolved placeholder text', foundPlaceholders.length === 0,
156
+ foundPlaceholders.map((re) => src.match(re)?.[0]).filter(Boolean).join(', '));
157
+
158
+ // --- Self-containment (F9): references/sources should not dangle outside the dir ---
159
+ const refsDir = join(dir, 'references');
160
+ if (!isEngine) check('self-contained: no external repo paths in body (e.g. 07-调研/)',
161
+ !/[\w/-]*调研与分析?\/|src\/|source\//i.test(src.replace(/```[\s\S]*?```/g, '')),
162
+ 'body references paths outside skill dir');
163
+
164
+ // --- Software-specific assertions (if target: software) ---
165
+ if (/target:\s*software/i.test(fmText) || /code-dna|代码表达DNA|code expression/i.test(src)) {
166
+ check('[software] Code Expression-DNA section present', /代码表达DNA|Code Expression-DNA|code.?dna/i.test(src));
167
+ check('[software] toolchain matrix present', /toolchain|eslint|oxlint|biome|tsconfig/i.test(src));
168
+ check('[software] distilled_against commit anchor', hasFm('distilled_against') || /distilled_against/i.test(src));
169
+ }
170
+
171
+ // --- Report ---
172
+ console.log(`\nvalidate-skill-structure: ${name}`);
173
+ console.log(`path: ${skillPath}\n`);
174
+ passes.forEach((l) => console.log(l));
175
+ failures.forEach((l) => console.log(l));
176
+ console.log(`\n${passes.length} pass, ${failures.length} fail — ${failures.length === 0 ? '✅ ALL-GREEN (may ship)' : '🔴 NOT READY (iterate Phase 2→3)'}${isEngine ? ' [engine mode — generated-skill checks skipped]' : ''}\n`);
177
+ process.exit(failures.length === 0 ? 0 : 1);
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ verify_citations.py — borrowed from Geek-skills-deep-research, ported for the
4
+ research skill (F13: wired INTO the Agentic Protocol Step 2, never orphaned).
5
+
6
+ Structural citation-integrity checker: resolves every citation marker `[n]` in
7
+ the report against the LOCAL source pool (sources.json); flags unresolved
8
+ citations, dangling references, non-sequential numbering, and source
9
+ concentration (> 25% from any single source).
10
+
11
+ ⚠️ This is a STRUCTURAL check only — it does NOT make network requests.
12
+ It does NOT verify HTTP 404s, redirect chains, or content-drift. A URL that
13
+ has gone dead or been hijacked will still pass if it matches the local source
14
+ pool. For live-URL verification, use a separate network checker (e.g.
15
+ WebFetch HEAD) before relying on citation-liveness.
16
+
17
+ Stdlib only. Python 3.9+.
18
+
19
+ Usage:
20
+ python3 verify_citations.py <report.md> <sources.json> [--output results.json]
21
+ python3 verify_citations.py --self-test # run a quick self-test
22
+
23
+ Exit codes:
24
+ 0 no unresolved / no dangling / no concentration issues
25
+ 1 one or more issues found (see --output report)
26
+ 2 usage error
27
+ """
28
+ import json
29
+ import re
30
+ import sys
31
+ from pathlib import Path
32
+ from urllib.parse import urlparse
33
+
34
+
35
+ SOURCE_CONCENTRATION_LIMIT = 0.25 # No single source > 25% of citations
36
+
37
+
38
+ def normalize_url(url: str) -> str:
39
+ """Lowercase + strip trailing slash + strip common tracking params."""
40
+ url = url.strip().rstrip("/")
41
+ parsed = urlparse(url.lower())
42
+ drop_params = {"utm_source", "utm_medium", "utm_campaign", "ref", "fbclid", "gclid"}
43
+ # Best-effort: keep query but strip params in drop_params
44
+ if parsed.query:
45
+ parts = [p for p in parsed.query.split("&") if p.split("=")[0] not in drop_params]
46
+ query = "&".join(parts)
47
+ else:
48
+ query = ""
49
+ return f"{parsed.scheme}://{parsed.netloc}{parsed.path}{('?' + query) if query else ''}".rstrip("/")
50
+
51
+
52
+ def url_signature(url: str):
53
+ """Robust (domain, path_segments[:3]) for matching."""
54
+ parsed = urlparse(url.lower())
55
+ host = parsed.hostname or ""
56
+ if host.startswith("www."):
57
+ host = host[4:]
58
+ segments = [s for s in parsed.path.split("/") if s][:3]
59
+ return (host, tuple(segments))
60
+
61
+
62
+ def extract_citations(text: str):
63
+ """Find [n] markers in body. Returns list of int indices."""
64
+ return [int(m) for m in re.findall(r"\[(\d+)\]", text)]
65
+
66
+
67
+ def extract_references(text: str):
68
+ """Find reference list. Returns dict {int_idx: (label, url)}."""
69
+ refs = {}
70
+ # Match common patterns: [1] Label — URL or [1] Label - http...
71
+ for m in re.finditer(r"^\s*\[(\d+)\]\s+([^—\-]+?)\s*[—\-]\s*(\S+)", text, re.MULTILINE):
72
+ refs[int(m.group(1))] = (m.group(2).strip(), m.group(3).strip())
73
+ return refs
74
+
75
+
76
+ def main(argv):
77
+ if not argv or argv[0] in ("-h", "--help"):
78
+ print(__doc__)
79
+ return 0
80
+ if argv[0] == "--self-test":
81
+ return self_test()
82
+ if len(argv) < 2:
83
+ print("Usage: verify_citations.py <report.md> <sources.json> [--output results.json]", file=sys.stderr)
84
+ return 2
85
+
86
+ report_path = Path(argv[0])
87
+ sources_path = Path(argv[1])
88
+ output_path = None
89
+ if len(argv) >= 4 and argv[2] == "--output":
90
+ output_path = Path(argv[3])
91
+
92
+ report = report_path.read_text(encoding="utf-8")
93
+ sources_data = json.loads(sources_path.read_text(encoding="utf-8"))
94
+
95
+ sources = sources_data.get("sources", [])
96
+ sources_normalized = {normalize_url(s["url"]): s for s in sources}
97
+
98
+ citations = extract_citations(report)
99
+ refs = extract_references(report)
100
+
101
+ issues = []
102
+ stats = {
103
+ "report_citations": len(citations),
104
+ "unique_citations": len(set(citations)),
105
+ "references_declared": len(refs),
106
+ "sources_in_pool": len(sources),
107
+ "concentration": {},
108
+ }
109
+
110
+ # 1. Every [n] has a matching reference
111
+ for c in set(citations):
112
+ if c not in refs:
113
+ issues.append({
114
+ "severity": "fatal",
115
+ "type": "unresolved_citation",
116
+ "detail": f"Citation [{c}] has no matching reference entry",
117
+ })
118
+
119
+ # 2. Every reference URL exists in source pool
120
+ for idx, (label, url) in refs.items():
121
+ if normalize_url(url) not in sources_normalized:
122
+ # Try signature match
123
+ sig = url_signature(url)
124
+ match = None
125
+ for s_url, s in sources_normalized.items():
126
+ if url_signature(s_url) == sig:
127
+ match = s
128
+ break
129
+ if not match:
130
+ issues.append({
131
+ "severity": "fatal",
132
+ "type": "url_not_in_source_pool",
133
+ "detail": f"Reference [{idx}] URL '{url}' not found in source pool",
134
+ })
135
+
136
+ # 3. Dangling references (in list but never cited)
137
+ for idx in refs:
138
+ if idx not in set(citations):
139
+ issues.append({
140
+ "severity": "warn",
141
+ "type": "dangling_reference",
142
+ "detail": f"Reference [{idx}] declared but never cited in body",
143
+ })
144
+
145
+ # 4. Sequential numbering, no gaps
146
+ if refs:
147
+ max_idx = max(refs.keys())
148
+ missing = [i for i in range(1, max_idx + 1) if i not in refs]
149
+ if missing:
150
+ issues.append({
151
+ "severity": "warn",
152
+ "type": "non_sequential_numbering",
153
+ "detail": f"Reference numbering gaps: {missing}",
154
+ })
155
+
156
+ # 5. Source concentration
157
+ cite_counts = {}
158
+ for c in citations:
159
+ if c in refs:
160
+ label = refs[c][0]
161
+ cite_counts[label] = cite_counts.get(label, 0) + 1
162
+ total = max(len(citations), 1)
163
+ for label, count in cite_counts.items():
164
+ ratio = count / total
165
+ stats["concentration"][label] = round(ratio, 3)
166
+ if ratio > SOURCE_CONCENTRATION_LIMIT:
167
+ issues.append({
168
+ "severity": "fatal",
169
+ "type": "source_concentration",
170
+ "detail": f"Source '{label}' has {count}/{total} citations ({ratio*100:.1f}%) > {SOURCE_CONCENTRATION_LIMIT*100:.0f}% limit",
171
+ })
172
+
173
+ result = {
174
+ "ok": all(i["severity"] != "fatal" for i in issues),
175
+ "stats": stats,
176
+ "issues": issues,
177
+ }
178
+
179
+ if output_path:
180
+ output_path.write_text(json.dumps(result, indent=2), encoding="utf-8")
181
+ else:
182
+ print(json.dumps(result, indent=2))
183
+
184
+ return 0 if result["ok"] else 1
185
+
186
+
187
+ def self_test():
188
+ """Run a quick self-test on a fabricated report."""
189
+ report = """# Test
190
+
191
+ Claim one [1]. Claim two [2]. Claim three [3]. Claim four [4]. Claim five [5].
192
+
193
+ ## References
194
+
195
+ [1] Author A — https://example.com/a
196
+ [2] Author B — https://example.org/b
197
+ [3] Author C — https://example.net/c
198
+ [4] Author D — https://example.io/d
199
+ [5] Author E — https://example.dev/e
200
+ """
201
+ sources = {
202
+ "sources": [
203
+ {"url": "https://example.com/a", "title": "Author A's paper"},
204
+ {"url": "https://example.org/b", "title": "Author B's notes"},
205
+ {"url": "https://example.net/c", "title": "Author C's x"},
206
+ {"url": "https://example.io/d", "title": "Author D's y"},
207
+ {"url": "https://example.dev/e", "title": "Author E's z"},
208
+ ]
209
+ }
210
+ import tempfile
211
+ with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as f:
212
+ f.write(report)
213
+ rp = f.name
214
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
215
+ json.dump(sources, f)
216
+ sp = f.name
217
+ code = main([rp, sp])
218
+ Path(rp).unlink()
219
+ Path(sp).unlink()
220
+ print(f"\nself-test: exit={code} (expected 0 — 1 citation per source, no concentration)")
221
+ return code
222
+
223
+
224
+ if __name__ == "__main__":
225
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,28 @@
1
+ {
2
+ "version": "1.0.0",
3
+ "generated": "2026-05-28T06:10:00Z",
4
+ "source": "source/Anthropic-Cybersecurity-Skills/",
5
+ "description": "Prioritized security skills for pi-crew security-reviewer role",
6
+ "priority_skills": [
7
+ { "id": "detecting-ai-model-prompt-injection-attacks", "priority": "critical", "atlas": ["AML.T0051"], "category": "agent-security" },
8
+ { "id": "detecting-supply-chain-attacks-in-ci-cd", "priority": "critical", "atlas": ["AML.T0010", "AML.T0104"], "category": "supply-chain" },
9
+ { "id": "detecting-anomalous-authentication-patterns", "priority": "high", "atlas": ["AML.T0043", "AML.T0018"], "category": "auth" },
10
+ { "id": "detecting-typosquatting-packages-in-npm-pypi", "priority": "high", "atlas": [], "category": "supply-chain" },
11
+ { "id": "detecting-path-traversal", "priority": "high", "atlas": [], "category": "code-security" },
12
+ { "id": "detecting-command-injection", "priority": "high", "atlas": [], "category": "code-security" },
13
+ { "id": "detecting-sensitive-data-exposure", "priority": "high", "atlas": ["AML.T0067"], "category": "secrets" },
14
+ { "id": "detecting-context-poisoning-in-agent-loops", "priority": "high", "atlas": ["AML.T0051"], "category": "agent-security" },
15
+ { "id": "detecting-tool-invocation-abuse", "priority": "medium", "atlas": ["AML.T0051", "AML.T0054"], "category": "agent-security" },
16
+ { "id": "detecting-malicious-skill-loading", "priority": "medium", "atlas": ["AML.T0062"], "category": "agent-security" },
17
+ { "id": "detecting-credential-leakage-in-logs", "priority": "medium", "atlas": [], "category": "secrets" },
18
+ { "id": "detecting-session-fixation", "priority": "medium", "atlas": ["AML.T0018"], "category": "auth" },
19
+ { "id": "detecting-data-exfiltration-indicators", "priority": "medium", "atlas": ["AML.T0067"], "category": "data-security" },
20
+ { "id": "detecting-serverless-function-injection", "priority": "medium", "atlas": [], "category": "code-security" },
21
+ { "id": "detecting-race-condition-vulnerabilities", "priority": "medium", "atlas": ["AML.T0054"], "category": "code-security" },
22
+ { "id": "detecting-agent-privilege-escalation", "priority": "medium", "atlas": ["AML.T0054"], "category": "agent-security" },
23
+ { "id": "detecting-malicious-npm-packages", "priority": "low", "atlas": [], "category": "supply-chain" },
24
+ { "id": "detecting-dependency-confusion-attacks", "priority": "low", "atlas": [], "category": "supply-chain" },
25
+ { "id": "detecting-token-hijacking", "priority": "low", "atlas": ["AML.T0018"], "category": "auth" },
26
+ { "id": "detecting-race-condition-in-file-operations", "priority": "low", "atlas": ["AML.T0054"], "category": "code-security" }
27
+ ]
28
+ }
@@ -707,6 +707,7 @@ function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
707
707
  allowChildProcessFallback: parseWithSchema(Type.Boolean(), obj.allowChildProcessFallback),
708
708
  maxTurns: parsePositiveInteger(obj.maxTurns, LIMIT_CEILINGS.runtimeMaxTurns),
709
709
  graceTurns: parsePositiveInteger(obj.graceTurns, LIMIT_CEILINGS.runtimeGraceTurns),
710
+ taskTimeoutMs: parsePositiveInteger(obj.taskTimeoutMs, LIMIT_CEILINGS.runtimeMaxTurns),
710
711
  inheritContext: parseWithSchema(Type.Boolean(), obj.inheritContext) ?? true,
711
712
  promptMode: parseWithSchema(Type.Union([Type.Literal("replace"), Type.Literal("append")]), obj.promptMode),
712
713
  groupJoin: parseWithSchema(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")]), obj.groupJoin),
@@ -11,10 +11,13 @@ export interface RoleToolConfig {
11
11
  }
12
12
 
13
13
  export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
14
- // Explorer - Read-only, no write or execute
14
+ // Explorer - Read-only exploration; bash is included for git log/show
15
+ // (decisions stream needs commit-history mining) but edit/write stay
16
+ // excluded. State-mutation safety is enforced separately by
17
+ // READ_ONLY_ROLES in role-permission.ts.
15
18
  explorer: {
16
- tools: ["read", "grep", "find", "ls", "glob"],
17
- excludeTools: ["edit", "write", "bash", "web"],
19
+ tools: ["read", "grep", "find", "ls", "glob", "bash"],
20
+ excludeTools: ["edit", "write", "web"],
18
21
  },
19
22
 
20
23
  // Analyst - Read and analyze, limited execution
@@ -44,6 +44,14 @@ export interface CrewRuntimeConfig {
44
44
  allowChildProcessFallback?: boolean;
45
45
  maxTurns?: number;
46
46
  graceTurns?: number;
47
+ /**
48
+ * W2 fix — wall-clock timeout per task in milliseconds. When the task
49
+ * exceeds this limit, input.signal is aborted which triggers the existing
50
+ * SIGTERM → SIGKILL escalation in child-pi.ts. Default 0 (no timeout).
51
+ * Prevents runaway agent loops (e.g. 11_build in oh-my-pi distill run that
52
+ * re-verified completed files 14+ times).
53
+ */
54
+ taskTimeoutMs?: number;
47
55
  inheritContext?: boolean;
48
56
  promptMode?: "replace" | "append";
49
57
  groupJoin?: "off" | "group" | "smart";
@@ -51,7 +51,17 @@ class ChildProcessRegistry {
51
51
 
52
52
  export const childProcessRegistry = new ChildProcessRegistry();
53
53
 
54
- export function registerCleanupHandler(pi: ExtensionAPI): void {
54
+ /**
55
+ * Optional UI timer disposal hook. Set by register.ts so the SIGTERM/SIGHUP
56
+ * signal handler can dispose terminal-status timers (idle re-assert loop +
57
+ * flash clear) that would otherwise keep the event loop alive.
58
+ */
59
+ let terminalStatusDispose: (() => void) | undefined;
60
+
61
+ export function registerCleanupHandler(pi: ExtensionAPI, opts?: { disposeTerminalStatus?: () => void }): void {
62
+ // Store the latest dispose fn at module level so the signal handler
63
+ // (registered once) always uses the current one even across re-registrations.
64
+ terminalStatusDispose = opts?.disposeTerminalStatus;
55
65
  // Handle session_shutdown event
56
66
  pi.on("session_shutdown", async () => {
57
67
  console.log("[pi-crew] Session shutdown - cleaning up resources");
@@ -76,6 +86,13 @@ export function registerCleanupHandler(pi: ExtensionAPI): void {
76
86
  signalHandlersRegistered = true;
77
87
  const handleSignal = async (signal: string): Promise<void> => {
78
88
  console.log(`[pi-crew] Received ${signal} - starting cleanup`);
89
+ // Dispose UI timers so the idle re-assert loop / flash clear don't
90
+ // keep the event loop alive after child processes are killed.
91
+ try {
92
+ terminalStatusDispose?.();
93
+ } catch {
94
+ // Best-effort — must not block child cleanup on signal.
95
+ }
79
96
  await cleanupChildProcesses();
80
97
  };
81
98
  process.on("SIGTERM", () => {
@@ -100,9 +100,18 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
100
100
  });
101
101
  }
102
102
 
103
- /** Trigger a footer repaint; the footer recomputes capacity/quota on render. */
103
+ /** Trigger a footer repaint; the footer recomputes capacity/quota on render.
104
+ *
105
+ * Wrapped wholly in safeUiCall because fetchProviderAndRefresh is async: after
106
+ * its `await` the session may have shut down (session_shutdown clears the
107
+ * timers, but an in-flight fetchProviderAndRefresh still resumes), making ctx
108
+ * stale. Accessing the `hasUI` getter on a stale ctx throws — catch it so
109
+ * crew-vibes never crashes pi. Matches the file's core philosophy: "must
110
+ * NEVER break the user's session". */
104
111
  function refreshFooter(ctx: ExtensionContext): void {
105
- if (ctx?.hasUI) safeUiCall("refresh-footer", () => requestRender(ctx));
112
+ safeUiCall("refresh-footer", () => {
113
+ if (ctx?.hasUI) requestRender(ctx);
114
+ });
106
115
  }
107
116
 
108
117
  function publishSpeedFooter(ctx: ExtensionContext, speed = footerAnimator.value()): void {
@@ -79,7 +79,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
79
79
  // subagents or when the flag is off, it returns a no-op controller.
80
80
  ctx.brokerController = installCrewBrokerLifecycleController(pi, ctx);
81
81
 
82
- registerCleanupHandler(pi);
82
+ registerCleanupHandler(pi, { disposeTerminalStatus: () => ctx.terminalStatus?.dispose?.() });
83
83
  registerCompactionGuard(pi, {
84
84
  foregroundControllers: ctx.foregroundControllers,
85
85
  foregroundTeamRunControllers: ctx.foregroundTeamRunControllers,
@@ -31,6 +31,7 @@ export function registerPiCommands(pi: ExtensionAPI, ctx: RegistrationContext):
31
31
  getManifestCache: ctx.getManifestCache,
32
32
  getRunSnapshotCache: ctx.getRunSnapshotCache,
33
33
  getMetricRegistry: () => ctx.observabilityState.metricRegistry,
34
+ uiState: ctx.uiState,
34
35
  dismissNotifications: () => {
35
36
  ctx.widgetState.notificationCount = 0;
36
37
  if (ctx.currentCtx) {
@@ -62,6 +62,7 @@ import { piTeamsHelp } from "../help.ts";
62
62
  import { handleTeamManagerCommand } from "../team-manager-command.ts";
63
63
  import { withSessionId } from "../team-tool/context.ts";
64
64
  import { commandText, notifyCommandResult, parseRunArgs, parseScalar, pushUnset, setNestedConfig } from "./command-utils.ts";
65
+ import type { UiState } from "./ui.ts";
65
66
  import { openLiveConversation, openTranscriptViewer, selectAgentTask } from "./viewers.ts";
66
67
 
67
68
  export interface RegisterTeamCommandsDeps {
@@ -73,6 +74,7 @@ export interface RegisterTeamCommandsDeps {
73
74
  };
74
75
  getRunSnapshotCache?: (cwd: string) => ReturnType<typeof createRunSnapshotCache>;
75
76
  getMetricRegistry?: () => MetricRegistry | undefined;
77
+ uiState?: UiState;
76
78
  dismissNotifications?: () => void;
77
79
  }
78
80
 
@@ -391,6 +393,7 @@ export async function openTeamDashboard(ctx: ExtensionContext): Promise<void> {
391
393
  if (!ctx.hasUI) return;
392
394
  const deps = depsRef;
393
395
  if (!deps) return;
396
+ if (deps.uiState) deps.uiState.dashboardOpen = true;
394
397
  const cmdCtx = ctx as ExtensionCommandContext;
395
398
  for (;;) {
396
399
  // Extract sessionId for workspace-scoped filtering
@@ -428,7 +431,7 @@ export async function openTeamDashboard(ctx: ExtensionContext): Promise<void> {
428
431
  : { width, maxHeight: "90%", anchor: "center", margin: 2 },
429
432
  },
430
433
  );
431
- if (!selection) return;
434
+ if (!selection) break;
432
435
  if (selection.action === "reload") continue;
433
436
  if (selection.action === "notifications-dismiss") {
434
437
  deps.dismissNotifications?.();
@@ -537,8 +540,9 @@ export async function openTeamDashboard(ctx: ExtensionContext): Promise<void> {
537
540
  teamCommandContext(cmdCtx),
538
541
  );
539
542
  await notifyCommandResult(cmdCtx, commandText(result));
540
- return;
543
+ break;
541
544
  }
545
+ if (deps.uiState) deps.uiState.dashboardOpen = false;
542
546
  }
543
547
 
544
548
  export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommandsDeps): void {
@@ -963,7 +967,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
963
967
  await ctx.ui.custom<undefined>(
964
968
  (tui, theme, _keybindings, done) =>
965
969
  new AnimatedMascot(theme, () => done(undefined), {
966
- frameIntervalMs: style === "armin" ? 33 : 180,
970
+ frameIntervalMs: style === "armin" ? 100 : 180,
967
971
  autoCloseMs: 7000,
968
972
  requestRender: () => requestRenderTarget(tui),
969
973
  style,
@@ -168,8 +168,6 @@ function installSessionStartHandler(pi: ExtensionAPI, ctx: RegistrationContext):
168
168
  ctx.crewAutocompleteRegistered = true;
169
169
  registerCrewAutocomplete(extensionCtx);
170
170
  }
171
- if (ctx.widgetState.interval) clearInterval(ctx.widgetState.interval);
172
- ctx.widgetState.interval = undefined;
173
171
  notifyActiveRuns(extensionCtx);
174
172
 
175
173
  const currentSessionId = extractBrokerSessionId(extensionCtx);
@@ -586,7 +584,7 @@ function setupRenderLoop(
586
584
  const snapshotCache = lastFrameSnapshotCache ?? ctx.getRunSnapshotCache(ctx.currentCtx.cwd);
587
585
  const manifests = lastPreloadedManifests;
588
586
  if (!lastPreloadedConfig) backgroundPreload();
589
- if (ctx.uiState.liveSidebarRunId) {
587
+ if (ctx.uiState.liveSidebarRunId || ctx.uiState.dashboardOpen) {
590
588
  const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
591
589
  if (ctx.widgetState.lastVisibility !== "hidden" || ctx.widgetState.lastPlacement !== placement) {
592
590
  setExtensionWidget(ctx.currentCtx, "pi-crew", undefined, { placement });
@@ -42,6 +42,10 @@ async function importLiveRunSidebar(): Promise<typeof LiveRunSidebarType> {
42
42
  export interface UiState {
43
43
  liveSidebarRunId: string | undefined;
44
44
  dashboardOpened: boolean;
45
+ /** True while the full RunDashboard overlay (/team-dashboard) is open.
46
+ * Checked by the global renderTick to skip crew-widget updates while the
47
+ * dashboard obscures the widget (mirrors the liveSidebarRunId gate). */
48
+ dashboardOpen?: boolean;
45
49
  }
46
50
 
47
51
  /** Dependencies passed in by register.ts. */
@@ -103,6 +103,9 @@ export async function openLiveConversation(
103
103
  invalidate() {
104
104
  /* overlay polls */
105
105
  },
106
+ dispose() {
107
+ overlay.dispose();
108
+ },
106
109
  };
107
110
  },
108
111
  {