@vigolium/piolium 0.0.1
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/LICENSE +21 -0
- package/README.md +117 -0
- package/agents/access-auditor.md +300 -0
- package/agents/assumption-breaker.md +154 -0
- package/agents/attack-designer.md +116 -0
- package/agents/code-scanner.md +139 -0
- package/agents/concurrency-auditor.md +238 -0
- package/agents/confirm-writer.md +257 -0
- package/agents/context-reviewer.md +274 -0
- package/agents/cross-verifier.md +165 -0
- package/agents/cve-scout.md +381 -0
- package/agents/env-builder.md +282 -0
- package/agents/env-profiler.md +205 -0
- package/agents/evidence-collector.md +140 -0
- package/agents/finding-grader.md +142 -0
- package/agents/finding-writer.md +148 -0
- package/agents/flow-tracer.md +106 -0
- package/agents/goal-backtracer.md +146 -0
- package/agents/history-miner.md +467 -0
- package/agents/independent-verifier.md +118 -0
- package/agents/intent-mapper.md +183 -0
- package/agents/longshot-collector.md +128 -0
- package/agents/longshot-prober.md +126 -0
- package/agents/patch-auditor.md +73 -0
- package/agents/poc-author.md +124 -0
- package/agents/poc-runner.md +194 -0
- package/agents/probe-lead.md +269 -0
- package/agents/red-challenger.md +101 -0
- package/agents/report-composer.md +208 -0
- package/agents/review-adjudicator.md +216 -0
- package/agents/spec-auditor.md +155 -0
- package/agents/taint-tracer.md +265 -0
- package/agents/test-locator.md +209 -0
- package/agents/threat-modeler.md +132 -0
- package/agents/variant-scanner.md +108 -0
- package/agents/variant-spotter.md +110 -0
- package/bin/piolium.mjs +376 -0
- package/extensions/piolium/_vendor/yaml.bundle.d.mts +6 -0
- package/extensions/piolium/_vendor/yaml.bundle.mjs +139 -0
- package/extensions/piolium/agent-runner.ts +322 -0
- package/extensions/piolium/agents.ts +266 -0
- package/extensions/piolium/audit-state.ts +522 -0
- package/extensions/piolium/bundled-resources.ts +97 -0
- package/extensions/piolium/candidate-scan.ts +966 -0
- package/extensions/piolium/command-target.ts +177 -0
- package/extensions/piolium/console-stream.ts +57 -0
- package/extensions/piolium/export-results.ts +380 -0
- package/extensions/piolium/findings.ts +448 -0
- package/extensions/piolium/heartbeat.ts +182 -0
- package/extensions/piolium/help.ts +234 -0
- package/extensions/piolium/index.ts +1865 -0
- package/extensions/piolium/longshot.ts +530 -0
- package/extensions/piolium/matcher-suggestions.ts +196 -0
- package/extensions/piolium/matcher-utils.ts +83 -0
- package/extensions/piolium/modes/balanced.ts +750 -0
- package/extensions/piolium/modes/confirm-bootstrap.ts +186 -0
- package/extensions/piolium/modes/confirm.ts +697 -0
- package/extensions/piolium/modes/deep.ts +917 -0
- package/extensions/piolium/modes/diff.ts +177 -0
- package/extensions/piolium/modes/lite.ts +540 -0
- package/extensions/piolium/modes/longshot.ts +595 -0
- package/extensions/piolium/modes/merge.ts +204 -0
- package/extensions/piolium/modes/phase-runner.ts +267 -0
- package/extensions/piolium/modes/reinvest.ts +546 -0
- package/extensions/piolium/modes/revisit.ts +279 -0
- package/extensions/piolium/modes.ts +48 -0
- package/extensions/piolium/phase-labels.ts +123 -0
- package/extensions/piolium/phase-status-strip.ts +92 -0
- package/extensions/piolium/prompt-prefix-editor.ts +39 -0
- package/extensions/piolium/providers/anthropic-vertex.ts +836 -0
- package/extensions/piolium/recon.ts +409 -0
- package/extensions/piolium/result-stats.ts +105 -0
- package/extensions/piolium/retry.ts +120 -0
- package/extensions/piolium/scheduler.ts +212 -0
- package/extensions/piolium/secrets.ts +368 -0
- package/extensions/piolium/tools/web-tools.ts +148 -0
- package/package.json +77 -0
- package/skills/agentic-actions-auditor/SKILL.md +327 -0
- package/skills/agentic-actions-auditor/references/action-profiles.md +186 -0
- package/skills/agentic-actions-auditor/references/cross-file-resolution.md +209 -0
- package/skills/agentic-actions-auditor/references/foundations.md +94 -0
- package/skills/agentic-actions-auditor/references/vector-a-env-var-intermediary.md +77 -0
- package/skills/agentic-actions-auditor/references/vector-b-direct-expression-injection.md +83 -0
- package/skills/agentic-actions-auditor/references/vector-c-cli-data-fetch.md +83 -0
- package/skills/agentic-actions-auditor/references/vector-d-pr-target-checkout.md +88 -0
- package/skills/agentic-actions-auditor/references/vector-e-error-log-injection.md +88 -0
- package/skills/agentic-actions-auditor/references/vector-f-subshell-expansion.md +82 -0
- package/skills/agentic-actions-auditor/references/vector-g-eval-of-ai-output.md +91 -0
- package/skills/agentic-actions-auditor/references/vector-h-dangerous-sandbox-configs.md +102 -0
- package/skills/agentic-actions-auditor/references/vector-i-wildcard-allowlists.md +88 -0
- package/skills/audit/SKILL.md +562 -0
- package/skills/audit/assets/icon.svg +7 -0
- package/skills/audit/hooks/scripts/validate_phase_output.py +550 -0
- package/skills/audit/references/adversarial-review.md +148 -0
- package/skills/audit/references/architecture-aware-sast.md +306 -0
- package/skills/audit/references/audit-workflow.md +737 -0
- package/skills/audit/references/chamber-protocol.md +384 -0
- package/skills/audit/references/creative-attack-modes.md +221 -0
- package/skills/audit/references/deep-analysis.md +273 -0
- package/skills/audit/references/domain-attack-playbooks.md +1129 -0
- package/skills/audit/references/knowledge-base-template.md +513 -0
- package/skills/audit/references/real-env-validation.md +191 -0
- package/skills/audit/references/report-templates.md +417 -0
- package/skills/audit/references/triage-and-prereqs.md +134 -0
- package/skills/audit/scripts/consolidate_drafts.py +554 -0
- package/skills/audit/scripts/partition_findings.py +152 -0
- package/skills/audit/scripts/rg-hotspots.sh +121 -0
- package/skills/audit/scripts/stamp_file_state.py +349 -0
- package/skills/code-reviewer/SKILL.md +65 -0
- package/skills/codeql/SKILL.md +281 -0
- package/skills/codeql/references/build-fixes.md +90 -0
- package/skills/codeql/references/diagnostic-query-templates.md +339 -0
- package/skills/codeql/references/extension-yaml-format.md +209 -0
- package/skills/codeql/references/important-only-suite.md +153 -0
- package/skills/codeql/references/language-details.md +207 -0
- package/skills/codeql/references/macos-arm64e-workaround.md +179 -0
- package/skills/codeql/references/performance-tuning.md +111 -0
- package/skills/codeql/references/quality-assessment.md +172 -0
- package/skills/codeql/references/ruleset-catalog.md +63 -0
- package/skills/codeql/references/run-all-suite.md +92 -0
- package/skills/codeql/references/sarif-processing.md +79 -0
- package/skills/codeql/references/threat-models.md +51 -0
- package/skills/codeql/workflows/build-database.md +280 -0
- package/skills/codeql/workflows/create-data-extensions.md +261 -0
- package/skills/codeql/workflows/run-analysis.md +301 -0
- package/skills/differential-review/SKILL.md +220 -0
- package/skills/differential-review/adversarial.md +203 -0
- package/skills/differential-review/methodology.md +234 -0
- package/skills/differential-review/patterns.md +300 -0
- package/skills/differential-review/reporting.md +369 -0
- package/skills/fp-check/SKILL.md +125 -0
- package/skills/fp-check/references/bug-class-verification.md +114 -0
- package/skills/fp-check/references/deep-verification.md +143 -0
- package/skills/fp-check/references/evidence-templates.md +91 -0
- package/skills/fp-check/references/false-positive-patterns.md +115 -0
- package/skills/fp-check/references/gate-reviews.md +27 -0
- package/skills/fp-check/references/standard-verification.md +78 -0
- package/skills/insecure-defaults/SKILL.md +117 -0
- package/skills/insecure-defaults/references/examples.md +409 -0
- package/skills/last30days/SKILL.md +444 -0
- package/skills/sarif-parsing/SKILL.md +483 -0
- package/skills/sarif-parsing/resources/jq-queries.md +162 -0
- package/skills/sarif-parsing/resources/sarif_helpers.py +331 -0
- package/skills/security-threat-model/LICENSE.txt +201 -0
- package/skills/security-threat-model/SKILL.md +81 -0
- package/skills/security-threat-model/agents/openai.yaml +4 -0
- package/skills/security-threat-model/references/prompt-template.md +255 -0
- package/skills/security-threat-model/references/security-controls-and-assets.md +32 -0
- package/skills/semgrep/SKILL.md +212 -0
- package/skills/semgrep/references/rulesets.md +162 -0
- package/skills/semgrep/references/scan-modes.md +110 -0
- package/skills/semgrep/references/scanner-task-prompt.md +140 -0
- package/skills/semgrep/scripts/merge_sarif.py +203 -0
- package/skills/semgrep/workflows/scan-workflow.md +311 -0
- package/skills/semgrep-rule-creator/SKILL.md +168 -0
- package/skills/semgrep-rule-creator/references/quick-reference.md +202 -0
- package/skills/semgrep-rule-creator/references/workflow.md +240 -0
- package/skills/semgrep-rule-variant-creator/SKILL.md +205 -0
- package/skills/semgrep-rule-variant-creator/references/applicability-analysis.md +250 -0
- package/skills/semgrep-rule-variant-creator/references/language-syntax-guide.md +324 -0
- package/skills/semgrep-rule-variant-creator/references/workflow.md +518 -0
- package/skills/sharp-edges/SKILL.md +292 -0
- package/skills/sharp-edges/references/auth-patterns.md +252 -0
- package/skills/sharp-edges/references/case-studies.md +274 -0
- package/skills/sharp-edges/references/config-patterns.md +333 -0
- package/skills/sharp-edges/references/crypto-apis.md +190 -0
- package/skills/sharp-edges/references/lang-c.md +205 -0
- package/skills/sharp-edges/references/lang-csharp.md +285 -0
- package/skills/sharp-edges/references/lang-go.md +270 -0
- package/skills/sharp-edges/references/lang-java.md +263 -0
- package/skills/sharp-edges/references/lang-javascript.md +269 -0
- package/skills/sharp-edges/references/lang-kotlin.md +265 -0
- package/skills/sharp-edges/references/lang-php.md +245 -0
- package/skills/sharp-edges/references/lang-python.md +274 -0
- package/skills/sharp-edges/references/lang-ruby.md +273 -0
- package/skills/sharp-edges/references/lang-rust.md +272 -0
- package/skills/sharp-edges/references/lang-swift.md +287 -0
- package/skills/sharp-edges/references/language-specific.md +588 -0
- package/skills/spec-to-code-compliance/SKILL.md +357 -0
- package/skills/spec-to-code-compliance/resources/COMPLETENESS_CHECKLIST.md +69 -0
- package/skills/spec-to-code-compliance/resources/IR_EXAMPLES.md +417 -0
- package/skills/spec-to-code-compliance/resources/OUTPUT_REQUIREMENTS.md +105 -0
- package/skills/supply-chain-risk-auditor/SKILL.md +67 -0
- package/skills/supply-chain-risk-auditor/resources/results-template.md +41 -0
- package/skills/variant-analysis/METHODOLOGY.md +327 -0
- package/skills/variant-analysis/SKILL.md +142 -0
- package/skills/variant-analysis/resources/codeql/cpp.ql +119 -0
- package/skills/variant-analysis/resources/codeql/go.ql +69 -0
- package/skills/variant-analysis/resources/codeql/java.ql +71 -0
- package/skills/variant-analysis/resources/codeql/javascript.ql +63 -0
- package/skills/variant-analysis/resources/codeql/python.ql +80 -0
- package/skills/variant-analysis/resources/semgrep/cpp.yaml +98 -0
- package/skills/variant-analysis/resources/semgrep/go.yaml +63 -0
- package/skills/variant-analysis/resources/semgrep/java.yaml +61 -0
- package/skills/variant-analysis/resources/semgrep/javascript.yaml +60 -0
- package/skills/variant-analysis/resources/semgrep/python.yaml +72 -0
- package/skills/variant-analysis/resources/variant-report-template.md +75 -0
- package/skills/vuln-report/SKILL.md +137 -0
- package/skills/vuln-report/agents/openai.yaml +4 -0
- package/skills/vuln-report/references/report-template.md +135 -0
- package/skills/wooyun-legacy/SKILL.md +367 -0
- package/skills/wooyun-legacy/references/bank-penetration.md +222 -0
- package/skills/wooyun-legacy/references/checklists/command-execution-checklist.md +119 -0
- package/skills/wooyun-legacy/references/checklists/csrf-checklist.md +74 -0
- package/skills/wooyun-legacy/references/checklists/file-upload-checklist.md +108 -0
- package/skills/wooyun-legacy/references/checklists/info-disclosure-checklist.md +114 -0
- package/skills/wooyun-legacy/references/checklists/logic-flaws-checklist.md +95 -0
- package/skills/wooyun-legacy/references/checklists/misconfig-checklist.md +124 -0
- package/skills/wooyun-legacy/references/checklists/path-traversal-checklist.md +87 -0
- package/skills/wooyun-legacy/references/checklists/rce-checklist.md +93 -0
- package/skills/wooyun-legacy/references/checklists/sql-injection-checklist.md +97 -0
- package/skills/wooyun-legacy/references/checklists/ssrf-checklist.md +99 -0
- package/skills/wooyun-legacy/references/checklists/unauthorized-access-checklist.md +89 -0
- package/skills/wooyun-legacy/references/checklists/weak-password-checklist.md +115 -0
- package/skills/wooyun-legacy/references/checklists/xss-checklist.md +103 -0
- package/skills/wooyun-legacy/references/checklists/xxe-checklist.md +130 -0
- package/skills/wooyun-legacy/references/info-disclosure.md +975 -0
- package/skills/wooyun-legacy/references/logic-flaws.md +721 -0
- package/skills/wooyun-legacy/references/path-traversal.md +1191 -0
- package/skills/wooyun-legacy/references/telecom-penetration.md +156 -0
- package/skills/wooyun-legacy/references/unauthorized-access.md +980 -0
- package/skills/wooyun-legacy/references/xss.md +746 -0
- package/skills/zeroize-audit/SKILL.md +371 -0
- package/skills/zeroize-audit/configs/c.yaml +21 -0
- package/skills/zeroize-audit/configs/default.yaml +128 -0
- package/skills/zeroize-audit/configs/rust.yaml +83 -0
- package/skills/zeroize-audit/prompts/report_template.md +238 -0
- package/skills/zeroize-audit/prompts/system.md +163 -0
- package/skills/zeroize-audit/prompts/task.md +97 -0
- package/skills/zeroize-audit/references/compile-commands.md +231 -0
- package/skills/zeroize-audit/references/detection-strategy.md +191 -0
- package/skills/zeroize-audit/references/ir-analysis.md +252 -0
- package/skills/zeroize-audit/references/mcp-analysis.md +221 -0
- package/skills/zeroize-audit/references/poc-generation.md +470 -0
- package/skills/zeroize-audit/references/rust-zeroization-patterns.md +867 -0
- package/skills/zeroize-audit/schemas/input.json +83 -0
- package/skills/zeroize-audit/schemas/output.json +140 -0
- package/skills/zeroize-audit/tools/analyze_asm.sh +202 -0
- package/skills/zeroize-audit/tools/analyze_cfg.py +381 -0
- package/skills/zeroize-audit/tools/analyze_heap.sh +211 -0
- package/skills/zeroize-audit/tools/analyze_ir_semantic.py +429 -0
- package/skills/zeroize-audit/tools/diff_ir.sh +135 -0
- package/skills/zeroize-audit/tools/diff_rust_mir.sh +189 -0
- package/skills/zeroize-audit/tools/emit_asm.sh +67 -0
- package/skills/zeroize-audit/tools/emit_ir.sh +77 -0
- package/skills/zeroize-audit/tools/emit_rust_asm.sh +178 -0
- package/skills/zeroize-audit/tools/emit_rust_ir.sh +150 -0
- package/skills/zeroize-audit/tools/emit_rust_mir.sh +158 -0
- package/skills/zeroize-audit/tools/extract_compile_flags.py +284 -0
- package/skills/zeroize-audit/tools/generate_poc.py +1329 -0
- package/skills/zeroize-audit/tools/mcp/apply_confidence_gates.py +113 -0
- package/skills/zeroize-audit/tools/mcp/check_mcp.sh +68 -0
- package/skills/zeroize-audit/tools/mcp/normalize_mcp_evidence.py +125 -0
- package/skills/zeroize-audit/tools/scripts/check_llvm_patterns.py +481 -0
- package/skills/zeroize-audit/tools/scripts/check_mir_patterns.py +554 -0
- package/skills/zeroize-audit/tools/scripts/check_rust_asm.py +424 -0
- package/skills/zeroize-audit/tools/scripts/check_rust_asm_aarch64.py +300 -0
- package/skills/zeroize-audit/tools/scripts/check_rust_asm_x86.py +283 -0
- package/skills/zeroize-audit/tools/scripts/find_dangerous_apis.py +375 -0
- package/skills/zeroize-audit/tools/scripts/semantic_audit.py +923 -0
- package/skills/zeroize-audit/tools/track_dataflow.sh +196 -0
- package/skills/zeroize-audit/tools/validate_rust_toolchain.sh +298 -0
- package/skills/zeroize-audit/workflows/phase-0-preflight.md +150 -0
- package/skills/zeroize-audit/workflows/phase-1-source-analysis.md +144 -0
- package/skills/zeroize-audit/workflows/phase-2-compiler-analysis.md +139 -0
- package/skills/zeroize-audit/workflows/phase-3-interim-report.md +46 -0
- package/skills/zeroize-audit/workflows/phase-4-poc-generation.md +46 -0
- package/skills/zeroize-audit/workflows/phase-5-poc-validation.md +136 -0
- package/skills/zeroize-audit/workflows/phase-6-final-report.md +44 -0
- package/skills/zeroize-audit/workflows/phase-7-test-generation.md +42 -0
- package/themes/piolium-srcery.json +94 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for working with `piolium/findings-draft/` and
|
|
3
|
+
* `piolium/findings/<id>-<slug>/`.
|
|
4
|
+
*
|
|
5
|
+
* The on-disk layout matches archon-audit so artifact files written by
|
|
6
|
+
* either system are interchangeable. Promotion (draft → finding directory)
|
|
7
|
+
* happens after Review Chamber phases when the orchestrator decides which
|
|
8
|
+
* drafts have survived dedup / FP elimination.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
readdirSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
statSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { basename, join } from "node:path";
|
|
21
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "./_vendor/yaml.bundle.mjs";
|
|
22
|
+
import { splitFrontmatter } from "./agents.ts";
|
|
23
|
+
|
|
24
|
+
export interface FindingDraft {
|
|
25
|
+
path: string;
|
|
26
|
+
id: string;
|
|
27
|
+
slug: string;
|
|
28
|
+
severity: "critical" | "high" | "medium" | "low" | "info";
|
|
29
|
+
phase?: string;
|
|
30
|
+
status?: string;
|
|
31
|
+
verdict?: string;
|
|
32
|
+
body: string;
|
|
33
|
+
frontmatter: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FindingDir {
|
|
37
|
+
id: string;
|
|
38
|
+
slug: string;
|
|
39
|
+
path: string;
|
|
40
|
+
hasReport: boolean;
|
|
41
|
+
hasPoc: boolean;
|
|
42
|
+
hasEvidence: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function findingsDraftDir(cwd: string): string {
|
|
46
|
+
return join(cwd, "piolium", "findings-draft");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function findingsDir(cwd: string): string {
|
|
50
|
+
return join(cwd, "piolium", "findings");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The theoretical / unconfirmed bucket. Drafts the triager (or Intent
|
|
55
|
+
* Reconciliation) marked skip, and findings whose PoC never executed, land
|
|
56
|
+
* here. They still get a `report.md` but are kept out of the main
|
|
57
|
+
* Summary-of-Findings table. Matches upstream archon-audit's
|
|
58
|
+
* `findings-theoretical/`.
|
|
59
|
+
*/
|
|
60
|
+
export function findingsTheoreticalDir(cwd: string): string {
|
|
61
|
+
return join(cwd, "piolium", "findings-theoretical");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const SEVERITIES = new Set<FindingDraft["severity"]>(["critical", "high", "medium", "low", "info"]);
|
|
65
|
+
|
|
66
|
+
function normalizeSeverity(value: unknown): FindingDraft["severity"] {
|
|
67
|
+
if (typeof value !== "string") return "info";
|
|
68
|
+
const lower = value.toLowerCase().trim() as FindingDraft["severity"];
|
|
69
|
+
return SEVERITIES.has(lower) ? lower : "info";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function listDraftFindings(cwd: string, prefix?: string): FindingDraft[] {
|
|
73
|
+
const dir = findingsDraftDir(cwd);
|
|
74
|
+
if (!existsSync(dir)) return [];
|
|
75
|
+
const out: FindingDraft[] = [];
|
|
76
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
77
|
+
if (!entry.endsWith(".md")) continue;
|
|
78
|
+
if (prefix && !entry.startsWith(prefix)) continue;
|
|
79
|
+
const path = join(dir, entry);
|
|
80
|
+
try {
|
|
81
|
+
const raw = readFileSync(path, "utf8");
|
|
82
|
+
const { frontmatter, body } = splitFrontmatter(raw);
|
|
83
|
+
const id =
|
|
84
|
+
typeof frontmatter.id === "string"
|
|
85
|
+
? frontmatter.id
|
|
86
|
+
: (basename(entry, ".md").split("-")[0] ?? basename(entry, ".md"));
|
|
87
|
+
const slug =
|
|
88
|
+
typeof frontmatter.slug === "string"
|
|
89
|
+
? frontmatter.slug
|
|
90
|
+
: basename(entry, ".md").replace(/^[a-z0-9]+-\d+-?/i, "");
|
|
91
|
+
out.push({
|
|
92
|
+
path,
|
|
93
|
+
id,
|
|
94
|
+
slug,
|
|
95
|
+
severity: normalizeSeverity(frontmatter.severity),
|
|
96
|
+
...(typeof frontmatter.phase === "string" ? { phase: frontmatter.phase } : {}),
|
|
97
|
+
...(typeof frontmatter.status === "string" ? { status: frontmatter.status } : {}),
|
|
98
|
+
...(typeof frontmatter.verdict === "string" ? { verdict: frontmatter.verdict } : {}),
|
|
99
|
+
body,
|
|
100
|
+
frontmatter,
|
|
101
|
+
});
|
|
102
|
+
} catch {
|
|
103
|
+
// skip malformed files
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Match `<id>-<slug>` directory names. Ordered alternatives (longest-first):
|
|
110
|
+
// FP-<phase>-<seq> (e.g. FP-p8-001-…)
|
|
111
|
+
// <phase>-<seq> (e.g. p8-001-…, q1-002-…)
|
|
112
|
+
// FP-<sev><n> (e.g. FP-H1-…)
|
|
113
|
+
// <sev><n> (e.g. C1-…, H1-…, M1-…)
|
|
114
|
+
// <seq> (legacy bare-numeric ids)
|
|
115
|
+
const FINDING_DIR_RE = /^((?:FP-)?[A-Za-z0-9]+-\d+|(?:FP-)?[A-Za-z]+\d+|\d+)-(.+)$/;
|
|
116
|
+
|
|
117
|
+
export function parseFindingDirName(entry: string): { id: string; slug: string } {
|
|
118
|
+
const m = entry.match(FINDING_DIR_RE);
|
|
119
|
+
if (m?.[1] && m?.[2]) return { id: m[1], slug: m[2] };
|
|
120
|
+
const parts = entry.split("-");
|
|
121
|
+
return parts.length > 1
|
|
122
|
+
? { id: parts.slice(0, -1).join("-"), slug: parts[parts.length - 1] ?? entry }
|
|
123
|
+
: { id: entry, slug: entry };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function listFindingDirsIn(root: string): FindingDir[] {
|
|
127
|
+
if (!existsSync(root)) return [];
|
|
128
|
+
const out: FindingDir[] = [];
|
|
129
|
+
for (const entry of readdirSync(root).sort()) {
|
|
130
|
+
const path = join(root, entry);
|
|
131
|
+
if (!statSync(path).isDirectory()) continue;
|
|
132
|
+
const { id, slug } = parseFindingDirName(entry);
|
|
133
|
+
out.push({
|
|
134
|
+
id,
|
|
135
|
+
slug,
|
|
136
|
+
path,
|
|
137
|
+
hasReport: existsSync(join(path, "report.md")),
|
|
138
|
+
hasPoc: readdirSync(path).some((f) => f.startsWith("poc.")),
|
|
139
|
+
hasEvidence: existsSync(join(path, "evidence")),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function listFindingDirs(cwd: string): FindingDir[] {
|
|
146
|
+
return listFindingDirsIn(findingsDir(cwd));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function listTheoreticalFindingDirs(cwd: string): FindingDir[] {
|
|
150
|
+
return listFindingDirsIn(findingsTheoreticalDir(cwd));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Both buckets — used by finalize/report phases that cover everything. */
|
|
154
|
+
export function listAllFindingDirs(cwd: string): FindingDir[] {
|
|
155
|
+
return [...listFindingDirs(cwd), ...listTheoreticalFindingDirs(cwd)];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const TRIAGE_SKIP_KEYS = ["Triage-Priority", "triage_priority", "triage-priority"];
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A draft routed to the theoretical bucket: the triager or Intent
|
|
162
|
+
* Reconciliation set `Triage-Priority: skip`, or the draft self-declares a
|
|
163
|
+
* theoretical/no-poc disposition.
|
|
164
|
+
*/
|
|
165
|
+
export function isTheoreticalDraft(frontmatter: Record<string, unknown>): boolean {
|
|
166
|
+
for (const key of TRIAGE_SKIP_KEYS) {
|
|
167
|
+
const v = frontmatter[key];
|
|
168
|
+
if (typeof v === "string" && v.trim().toLowerCase() === "skip") return true;
|
|
169
|
+
}
|
|
170
|
+
const disposition = frontmatter.disposition ?? frontmatter.bucket;
|
|
171
|
+
return typeof disposition === "string" && disposition.trim().toLowerCase() === "theoretical";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Move a finding directory between buckets, preserving its `<id>-<slug>`
|
|
176
|
+
* name. Idempotent: a no-op when already at the destination.
|
|
177
|
+
*/
|
|
178
|
+
function moveFindingDir(fromPath: string, toRoot: string): string {
|
|
179
|
+
const name = basename(fromPath);
|
|
180
|
+
mkdirSync(toRoot, { recursive: true });
|
|
181
|
+
const dest = join(toRoot, name);
|
|
182
|
+
if (fromPath === dest || existsSync(dest)) return dest;
|
|
183
|
+
// Cross-bucket move within the same filesystem.
|
|
184
|
+
renameSync(fromPath, dest);
|
|
185
|
+
return dest;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface PartitionResult {
|
|
189
|
+
confirmed: string[];
|
|
190
|
+
theoretical: string[];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Demote every `piolium/findings/<id>-<slug>/` whose `draft.md` did not
|
|
195
|
+
* reach an executed PoC (or is flagged theoretical) into
|
|
196
|
+
* `piolium/findings-theoretical/`. Mirrors upstream
|
|
197
|
+
* `partition_findings.py`. Idempotent; safe to call when there are no
|
|
198
|
+
* findings.
|
|
199
|
+
*/
|
|
200
|
+
export function partitionFindings(cwd: string): PartitionResult {
|
|
201
|
+
const confirmed: string[] = [];
|
|
202
|
+
const theoretical: string[] = [];
|
|
203
|
+
for (const dir of listFindingDirs(cwd)) {
|
|
204
|
+
const fm: Record<string, unknown> = readFindingFrontmatter(dir.path) ?? {};
|
|
205
|
+
const pocStatus = String(fm["PoC-Status"] ?? fm.poc_status ?? fm["poc-status"] ?? "")
|
|
206
|
+
.trim()
|
|
207
|
+
.toLowerCase();
|
|
208
|
+
const pocExecuted = dir.hasPoc && (pocStatus === "" || pocStatus === "executed");
|
|
209
|
+
if (isTheoreticalDraft(fm) || !pocExecuted) {
|
|
210
|
+
theoretical.push(moveFindingDir(dir.path, findingsTheoreticalDir(cwd)));
|
|
211
|
+
} else {
|
|
212
|
+
confirmed.push(dir.path);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { confirmed, theoretical };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Promote a draft into a finding directory. Idempotent — calling twice with
|
|
220
|
+
* the same draft is a no-op once the finding dir exists. Returns the
|
|
221
|
+
* finding dir path.
|
|
222
|
+
*/
|
|
223
|
+
export function promoteDraftToFinding(cwd: string, draft: FindingDraft): string {
|
|
224
|
+
const dirName = `${draft.id}-${draft.slug}`;
|
|
225
|
+
const dir = join(findingsDir(cwd), dirName);
|
|
226
|
+
if (existsSync(dir)) return dir;
|
|
227
|
+
mkdirSync(dir, { recursive: true });
|
|
228
|
+
const out: Record<string, unknown> = { ...draft.frontmatter };
|
|
229
|
+
out.id = draft.id;
|
|
230
|
+
out.slug = draft.slug;
|
|
231
|
+
out.severity = draft.severity;
|
|
232
|
+
const draftPath = join(dir, "draft.md");
|
|
233
|
+
const yaml = stringifyYaml(out, { lineWidth: 0 }).trimEnd();
|
|
234
|
+
writeFileSync(draftPath, `---\n${yaml}\n---\n\n${draft.body.trimStart()}`);
|
|
235
|
+
return dir;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface PromotionResult {
|
|
239
|
+
promoted: string[]; // finding dir paths
|
|
240
|
+
skipped: string[]; // already-existing finding dir paths
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Promote every draft in `piolium/findings-draft/` matching `prefix` to a
|
|
245
|
+
* finding directory. Returns the list of dirs written and the list of dirs
|
|
246
|
+
* that were already present.
|
|
247
|
+
*/
|
|
248
|
+
export function promoteDraftsByPrefix(cwd: string, prefix: string): PromotionResult {
|
|
249
|
+
const drafts = listDraftFindings(cwd, prefix);
|
|
250
|
+
const promoted: string[] = [];
|
|
251
|
+
const skipped: string[] = [];
|
|
252
|
+
for (const draft of drafts) {
|
|
253
|
+
if (isRejectedDraft(draft) || draft.severity === "low" || draft.severity === "info") continue;
|
|
254
|
+
const dirName = `${draft.id}-${draft.slug}`;
|
|
255
|
+
const dirPath = join(findingsDir(cwd), dirName);
|
|
256
|
+
const existed = existsSync(dirPath);
|
|
257
|
+
promoteDraftToFinding(cwd, draft);
|
|
258
|
+
if (existed) skipped.push(dirPath);
|
|
259
|
+
else promoted.push(dirPath);
|
|
260
|
+
}
|
|
261
|
+
return { promoted, skipped };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface ConsolidationEntry {
|
|
265
|
+
/** New severity-prefixed id assigned during consolidation, e.g. "C1", "H1", "M1". */
|
|
266
|
+
id: string;
|
|
267
|
+
slug: string;
|
|
268
|
+
severity: "critical" | "high" | "medium";
|
|
269
|
+
/** Original draft id from frontmatter, e.g. "q2-001", "p8-003". */
|
|
270
|
+
originalId: string;
|
|
271
|
+
/** Phase id from the draft frontmatter, when present. */
|
|
272
|
+
phase?: string;
|
|
273
|
+
/** Path to the source draft file in `piolium/findings-draft/`. */
|
|
274
|
+
sourcePath: string;
|
|
275
|
+
/** Path to the created finding directory under `piolium/findings/`. */
|
|
276
|
+
findingDir: string;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface ConsolidationDropped {
|
|
280
|
+
originalId: string;
|
|
281
|
+
severity: FindingDraft["severity"];
|
|
282
|
+
sourcePath: string;
|
|
283
|
+
reason: "below-threshold" | "rejected";
|
|
284
|
+
status?: string;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface ConsolidationResult {
|
|
288
|
+
promoted: ConsolidationEntry[];
|
|
289
|
+
dropped: ConsolidationDropped[];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const SEVERITY_RANK: Record<FindingDraft["severity"], number> = {
|
|
293
|
+
critical: 0,
|
|
294
|
+
high: 1,
|
|
295
|
+
medium: 2,
|
|
296
|
+
low: 3,
|
|
297
|
+
info: 4,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const SEVERITY_LETTER = { critical: "C", high: "H", medium: "M" } as const;
|
|
301
|
+
|
|
302
|
+
const REJECTED_VALUES = new Set([
|
|
303
|
+
"rejected",
|
|
304
|
+
"rejected-fp",
|
|
305
|
+
"false-positive",
|
|
306
|
+
"false_positive",
|
|
307
|
+
"fp",
|
|
308
|
+
"invalid",
|
|
309
|
+
"not-valid",
|
|
310
|
+
"not_valid",
|
|
311
|
+
"noise",
|
|
312
|
+
"non-security",
|
|
313
|
+
"non_security",
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
export function isRejectedDraft(draft: FindingDraft): boolean {
|
|
317
|
+
const values = [
|
|
318
|
+
draft.status,
|
|
319
|
+
draft.verdict,
|
|
320
|
+
draft.frontmatter.outcome,
|
|
321
|
+
draft.frontmatter.decision,
|
|
322
|
+
draft.frontmatter.fp_status,
|
|
323
|
+
draft.frontmatter.confirm_status,
|
|
324
|
+
draft.frontmatter["Confirm-Status"],
|
|
325
|
+
];
|
|
326
|
+
return values.some((value) => {
|
|
327
|
+
if (typeof value !== "string") return false;
|
|
328
|
+
const normalized = value.toLowerCase().trim();
|
|
329
|
+
return REJECTED_VALUES.has(normalized) || normalized.includes("false-positive");
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function isPromotableDraft(draft: FindingDraft): boolean {
|
|
334
|
+
return !isRejectedDraft(draft) && draft.severity !== "low" && draft.severity !== "info";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Promote drafts matching any of `prefixes` into severity-prefixed finding
|
|
339
|
+
* directories. Drafts with severity below "medium" are dropped (not
|
|
340
|
+
* promoted) — matching the upstream archon-audit convention. Returns a
|
|
341
|
+
* manifest of the promotions and drops, suitable for serializing as a
|
|
342
|
+
* consolidation manifest.
|
|
343
|
+
*
|
|
344
|
+
* IDs are assigned deterministically: drafts are stable-sorted by
|
|
345
|
+
* (severity, source path), then numbered C1.. / H1.. / M1.. per severity.
|
|
346
|
+
*
|
|
347
|
+
* If a finding directory with the same `<id>-<slug>` name already exists,
|
|
348
|
+
* the existing directory is reused (idempotent for re-runs / resume).
|
|
349
|
+
*/
|
|
350
|
+
export function consolidateDrafts(cwd: string, prefixes: string[]): ConsolidationResult {
|
|
351
|
+
const seen = new Set<string>();
|
|
352
|
+
const drafts: FindingDraft[] = [];
|
|
353
|
+
for (const prefix of prefixes) {
|
|
354
|
+
for (const draft of listDraftFindings(cwd, prefix)) {
|
|
355
|
+
if (seen.has(draft.path)) continue;
|
|
356
|
+
seen.add(draft.path);
|
|
357
|
+
drafts.push(draft);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
drafts.sort((a, b) => {
|
|
361
|
+
const sa = SEVERITY_RANK[a.severity];
|
|
362
|
+
const sb = SEVERITY_RANK[b.severity];
|
|
363
|
+
if (sa !== sb) return sa - sb;
|
|
364
|
+
return a.path.localeCompare(b.path);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const counters: Record<"critical" | "high" | "medium", number> = {
|
|
368
|
+
critical: 0,
|
|
369
|
+
high: 0,
|
|
370
|
+
medium: 0,
|
|
371
|
+
};
|
|
372
|
+
const promoted: ConsolidationEntry[] = [];
|
|
373
|
+
const dropped: ConsolidationDropped[] = [];
|
|
374
|
+
for (const draft of drafts) {
|
|
375
|
+
if (isRejectedDraft(draft)) {
|
|
376
|
+
dropped.push({
|
|
377
|
+
originalId: draft.id,
|
|
378
|
+
severity: draft.severity,
|
|
379
|
+
sourcePath: draft.path,
|
|
380
|
+
reason: "rejected",
|
|
381
|
+
...((draft.status ?? draft.verdict) ? { status: draft.status ?? draft.verdict } : {}),
|
|
382
|
+
});
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (draft.severity === "low" || draft.severity === "info") {
|
|
386
|
+
dropped.push({
|
|
387
|
+
originalId: draft.id,
|
|
388
|
+
severity: draft.severity,
|
|
389
|
+
sourcePath: draft.path,
|
|
390
|
+
reason: "below-threshold",
|
|
391
|
+
});
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
counters[draft.severity] += 1;
|
|
395
|
+
const newId = `${SEVERITY_LETTER[draft.severity]}${counters[draft.severity]}`;
|
|
396
|
+
const promotedDraft: FindingDraft = {
|
|
397
|
+
...draft,
|
|
398
|
+
id: newId,
|
|
399
|
+
frontmatter: {
|
|
400
|
+
...draft.frontmatter,
|
|
401
|
+
original_id: draft.id,
|
|
402
|
+
...(draft.phase ? { phase: draft.phase } : {}),
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
const findingDir = promoteDraftToFinding(cwd, promotedDraft);
|
|
406
|
+
mkdirSync(join(findingDir, "evidence"), { recursive: true });
|
|
407
|
+
promoted.push({
|
|
408
|
+
id: newId,
|
|
409
|
+
slug: draft.slug,
|
|
410
|
+
severity: draft.severity,
|
|
411
|
+
originalId: draft.id,
|
|
412
|
+
...(draft.phase ? { phase: draft.phase } : {}),
|
|
413
|
+
sourcePath: draft.path,
|
|
414
|
+
findingDir,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return { promoted, dropped };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export interface FindingFrontmatter {
|
|
421
|
+
id: string;
|
|
422
|
+
slug: string;
|
|
423
|
+
severity: FindingDraft["severity"];
|
|
424
|
+
[k: string]: unknown;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function readFindingFrontmatter(findingDir: string): FindingFrontmatter | undefined {
|
|
428
|
+
const draft = join(findingDir, "draft.md");
|
|
429
|
+
if (!existsSync(draft)) return undefined;
|
|
430
|
+
let frontmatter: Record<string, unknown>;
|
|
431
|
+
try {
|
|
432
|
+
({ frontmatter } = splitFrontmatter(readFileSync(draft, "utf8")));
|
|
433
|
+
} catch {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
const id = typeof frontmatter.id === "string" ? frontmatter.id : "";
|
|
437
|
+
const slug = typeof frontmatter.slug === "string" ? frontmatter.slug : "";
|
|
438
|
+
if (!id || !slug) return undefined;
|
|
439
|
+
return {
|
|
440
|
+
...frontmatter,
|
|
441
|
+
id,
|
|
442
|
+
slug,
|
|
443
|
+
severity: normalizeSeverity(frontmatter.severity),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Re-export for callers that already imported via this module. */
|
|
448
|
+
export { parseYaml, stringifyYaml };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export interface PhaseHeartbeat {
|
|
4
|
+
phase: string;
|
|
5
|
+
label: string;
|
|
6
|
+
startedAtMs: number;
|
|
7
|
+
lastEventAtMs: number;
|
|
8
|
+
nowMs: number;
|
|
9
|
+
lastToolName?: string;
|
|
10
|
+
lastToolSummary?: string;
|
|
11
|
+
lastAssistantSummary?: string;
|
|
12
|
+
runId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PhaseHeartbeatTracker {
|
|
16
|
+
recordEvent(event: AgentSessionEvent): void;
|
|
17
|
+
snapshot(nowMs?: number): PhaseHeartbeat;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
21
|
+
export const DEFAULT_HEARTBEAT_QUIET_MS = 90_000;
|
|
22
|
+
export const DEFAULT_HEARTBEAT_STALLED_MS = 5 * 60_000;
|
|
23
|
+
export const PHASE_HEARTBEAT_UI_COLOR = "mdLink";
|
|
24
|
+
|
|
25
|
+
export function createPhaseHeartbeatTracker(options: {
|
|
26
|
+
phase: string;
|
|
27
|
+
label: string;
|
|
28
|
+
runId?: string;
|
|
29
|
+
nowMs?: number;
|
|
30
|
+
}): PhaseHeartbeatTracker {
|
|
31
|
+
const startedAtMs = options.nowMs ?? Date.now();
|
|
32
|
+
let lastEventAtMs = startedAtMs;
|
|
33
|
+
let lastToolName: string | undefined;
|
|
34
|
+
let lastToolSummary: string | undefined;
|
|
35
|
+
let lastAssistantSummary: string | undefined;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
recordEvent(event) {
|
|
39
|
+
lastEventAtMs = Date.now();
|
|
40
|
+
if (event.type === "tool_execution_start") {
|
|
41
|
+
const toolName = stringFromUnknown((event as { toolName?: unknown }).toolName);
|
|
42
|
+
if (toolName) lastToolName = toolName;
|
|
43
|
+
const summary = summarizeArgs((event as { args?: unknown }).args);
|
|
44
|
+
lastToolSummary = summary || undefined;
|
|
45
|
+
}
|
|
46
|
+
if (event.type === "message_end") {
|
|
47
|
+
const message = (event as { message?: unknown }).message;
|
|
48
|
+
const text = extractAssistantText(
|
|
49
|
+
message && typeof message === "object"
|
|
50
|
+
? (message as { content?: unknown }).content
|
|
51
|
+
: undefined,
|
|
52
|
+
);
|
|
53
|
+
lastAssistantSummary = compactLine(text, 80) || undefined;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
snapshot(nowMs = Date.now()) {
|
|
57
|
+
return {
|
|
58
|
+
phase: options.phase,
|
|
59
|
+
label: options.label,
|
|
60
|
+
startedAtMs,
|
|
61
|
+
lastEventAtMs,
|
|
62
|
+
nowMs,
|
|
63
|
+
...(lastToolName ? { lastToolName } : {}),
|
|
64
|
+
...(lastToolSummary ? { lastToolSummary } : {}),
|
|
65
|
+
...(lastAssistantSummary ? { lastAssistantSummary } : {}),
|
|
66
|
+
...(options.runId ? { runId: options.runId } : {}),
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function formatPhaseHeartbeat(
|
|
73
|
+
heartbeat: PhaseHeartbeat,
|
|
74
|
+
options: {
|
|
75
|
+
quietMs?: number;
|
|
76
|
+
stalledMs?: number;
|
|
77
|
+
nowMs?: number;
|
|
78
|
+
includePhase?: boolean;
|
|
79
|
+
} = {},
|
|
80
|
+
): string {
|
|
81
|
+
const nowMs = options.nowMs ?? heartbeat.nowMs;
|
|
82
|
+
const quietMs = options.quietMs ?? DEFAULT_HEARTBEAT_QUIET_MS;
|
|
83
|
+
const stalledMs = options.stalledMs ?? DEFAULT_HEARTBEAT_STALLED_MS;
|
|
84
|
+
const elapsedMs = Math.max(0, nowMs - heartbeat.startedAtMs);
|
|
85
|
+
const idleMs = Math.max(0, nowMs - heartbeat.lastEventAtMs);
|
|
86
|
+
const prefix = options.includePhase === false ? "" : `${heartbeat.phase} `;
|
|
87
|
+
const status = idleMs >= stalledMs ? "may be stalled" : "running";
|
|
88
|
+
const idle =
|
|
89
|
+
idleMs >= quietMs
|
|
90
|
+
? `quiet for ${formatDuration(idleMs)}`
|
|
91
|
+
: `last output ${formatDuration(idleMs)} ago`;
|
|
92
|
+
const tool = heartbeat.lastToolName
|
|
93
|
+
? `last tool: ${compactLine(
|
|
94
|
+
`${heartbeat.lastToolName}${heartbeat.lastToolSummary ? ` ${heartbeat.lastToolSummary}` : ""}`,
|
|
95
|
+
90,
|
|
96
|
+
)}`
|
|
97
|
+
: heartbeat.lastAssistantSummary
|
|
98
|
+
? `last note: ${heartbeat.lastAssistantSummary}`
|
|
99
|
+
: "waiting for first event";
|
|
100
|
+
return `${prefix}${status} ${formatDuration(elapsedMs)} · ${idle} · ${tool}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function formatPhaseHeartbeatStatusLine(
|
|
104
|
+
heartbeat: PhaseHeartbeat,
|
|
105
|
+
options: Parameters<typeof formatPhaseHeartbeat>[1] = {},
|
|
106
|
+
): string {
|
|
107
|
+
return `↳ health: ${formatPhaseHeartbeat(heartbeat, options)}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function phaseHeartbeatColor(
|
|
111
|
+
heartbeat: PhaseHeartbeat,
|
|
112
|
+
options: { quietMs?: number; stalledMs?: number; nowMs?: number } = {},
|
|
113
|
+
): "warning" | "muted" | "dim" {
|
|
114
|
+
const nowMs = options.nowMs ?? heartbeat.nowMs;
|
|
115
|
+
const quietMs = options.quietMs ?? DEFAULT_HEARTBEAT_QUIET_MS;
|
|
116
|
+
const stalledMs = options.stalledMs ?? DEFAULT_HEARTBEAT_STALLED_MS;
|
|
117
|
+
const idleMs = Math.max(0, nowMs - heartbeat.lastEventAtMs);
|
|
118
|
+
if (idleMs >= stalledMs) return "warning";
|
|
119
|
+
if (idleMs >= quietMs) return "muted";
|
|
120
|
+
return "dim";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function heartbeatStateFields(heartbeat: PhaseHeartbeat): {
|
|
124
|
+
heartbeat_at: string;
|
|
125
|
+
last_event_at: string;
|
|
126
|
+
last_tool: string | null;
|
|
127
|
+
last_tool_summary: string | null;
|
|
128
|
+
run_id: string | null;
|
|
129
|
+
} {
|
|
130
|
+
return {
|
|
131
|
+
heartbeat_at: new Date(heartbeat.nowMs).toISOString(),
|
|
132
|
+
last_event_at: new Date(heartbeat.lastEventAtMs).toISOString(),
|
|
133
|
+
last_tool: heartbeat.lastToolName ?? null,
|
|
134
|
+
last_tool_summary: heartbeat.lastToolSummary ?? null,
|
|
135
|
+
run_id: heartbeat.runId ?? null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatDuration(ms: number): string {
|
|
140
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
141
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
142
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
143
|
+
const seconds = totalSeconds % 60;
|
|
144
|
+
if (hours > 0) {
|
|
145
|
+
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
146
|
+
}
|
|
147
|
+
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function summarizeArgs(args: unknown): string {
|
|
151
|
+
if (!args || typeof args !== "object") return "";
|
|
152
|
+
const obj = args as Record<string, unknown>;
|
|
153
|
+
const pickKey = ["file_path", "path", "command", "pattern", "query", "url"].find(
|
|
154
|
+
(k) => typeof obj[k] === "string",
|
|
155
|
+
);
|
|
156
|
+
if (pickKey) return compactLine(String(obj[pickKey]), 90);
|
|
157
|
+
try {
|
|
158
|
+
return compactLine(JSON.stringify(obj), 90);
|
|
159
|
+
} catch {
|
|
160
|
+
return "";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function extractAssistantText(content: unknown): string {
|
|
165
|
+
if (typeof content === "string") return content;
|
|
166
|
+
if (!Array.isArray(content)) return "";
|
|
167
|
+
return content
|
|
168
|
+
.filter((c) => c && typeof c === "object" && (c as { type?: string }).type === "text")
|
|
169
|
+
.map((c) => (c as { text?: unknown }).text)
|
|
170
|
+
.filter((text): text is string => typeof text === "string")
|
|
171
|
+
.join("");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function stringFromUnknown(value: unknown): string | undefined {
|
|
175
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function compactLine(text: string, max: number): string {
|
|
179
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
180
|
+
if (collapsed.length <= max) return collapsed;
|
|
181
|
+
return `${collapsed.slice(0, max - 1)}…`;
|
|
182
|
+
}
|