portable-agent-layer 0.71.0 → 0.72.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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/cli/migrate.ts +1 -1
  3. package/src/cli/skill.ts +1 -1
  4. package/src/hooks/CompactRecover.ts +28 -86
  5. package/src/hooks/LedgerUnapplied.ts +3 -28
  6. package/src/hooks/LoadContext.ts +33 -60
  7. package/src/hooks/SecurityValidator.ts +16 -109
  8. package/src/hooks/handlers/failure-principle.ts +19 -44
  9. package/src/hooks/handlers/session-intelligence.ts +13 -70
  10. package/src/hooks/lib/capture-store.ts +103 -0
  11. package/src/hooks/lib/compact-recall.ts +89 -0
  12. package/src/hooks/lib/failure-principle.ts +98 -0
  13. package/src/hooks/lib/ledger-hook.ts +35 -0
  14. package/src/hooks/lib/ledger.ts +48 -1
  15. package/src/hooks/lib/security-gate.ts +159 -0
  16. package/src/hooks/lib/session-context.ts +74 -0
  17. package/src/tools/agent/algorithm-reflect.ts +28 -97
  18. package/src/tools/agent/analyze.ts +19 -120
  19. package/src/tools/agent/handoff-note.ts +29 -77
  20. package/src/tools/agent/project.ts +13 -134
  21. package/src/tools/agent/relationship-note.ts +27 -46
  22. package/src/tools/agent/synthesize.ts +1 -1
  23. package/src/tools/agent/thread.ts +43 -123
  24. package/src/tools/control-room/data.ts +2 -2
  25. package/src/tools/control-room/matrix.ts +1 -1
  26. package/src/tools/control-room/ui/ledger.tsx +2 -1
  27. package/src/tools/ledger/view.ts +3 -0
  28. package/src/tools/lib/algorithm-reflect.ts +84 -0
  29. package/src/tools/lib/analyze-report.ts +120 -0
  30. package/src/tools/lib/handoff-note.ts +88 -0
  31. package/src/tools/lib/note-flags.ts +59 -0
  32. package/src/tools/lib/project-isc.ts +151 -0
  33. package/src/tools/lib/relationship-reflect.ts +402 -0
  34. package/src/tools/lib/self-model.ts +499 -0
  35. package/src/tools/lib/session-usage.ts +216 -0
  36. package/src/tools/lib/skill-doctor.ts +457 -0
  37. package/src/tools/lib/thread.ts +119 -0
  38. package/src/tools/lib/token-report.ts +173 -0
  39. package/src/tools/lib/transcript-usage.ts +42 -0
  40. package/src/tools/lib/usage-buckets.ts +329 -0
  41. package/src/tools/relationship-reflect.ts +48 -412
  42. package/src/tools/self-model.ts +76 -558
  43. package/src/tools/session-summary.ts +8 -215
  44. package/src/tools/skill-doctor.ts +9 -444
  45. package/src/tools/token-cost.ts +18 -428
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The gate's decision, separated from the process that carries it out.
3
+ *
4
+ * SecurityValidator is spawned, so nothing can import it and nothing measures
5
+ * it — every rule about which tool names run a shell, which argument spells the
6
+ * path, and what the agent is told was unreachable from a test. The decision
7
+ * lives here instead; the entrypoint is left with stdin, stdout and the ledger.
8
+ */
9
+
10
+ import { normalizeToolUse } from "./agent";
11
+ import { logDebug } from "./log";
12
+ import { checkBashCommand, checkFilePath } from "./security";
13
+
14
+ /** beforeShellExecution (Cursor only) — flat, no tool-name wrapper. */
15
+ interface ShellExecInput {
16
+ command: string;
17
+ sandbox?: boolean;
18
+ }
19
+
20
+ export type SecurityInput = Record<string, unknown> | ShellExecInput;
21
+
22
+ export interface GateRefusal {
23
+ tool: string;
24
+ /** The file, or the directory a refused command would have run in. */
25
+ target: string;
26
+ /** Set only when a shell was refused: it has no file to name. */
27
+ command?: string;
28
+ /** Why, for the ledger. */
29
+ reason: string;
30
+ /** Why, in the words the agent is given — the two differ only by framing. */
31
+ message: string;
32
+ hookEventName?: string;
33
+ }
34
+
35
+ // A name this list misses is a command this hook waves through, so both sets mirror
36
+ // the tool names VS Code's own Copilot build ships in its shell and edit tool sets.
37
+ const SHELL_TOOLS = [
38
+ "bash",
39
+ "shell",
40
+ "powershell",
41
+ "local_shell",
42
+ "runinterminal",
43
+ "run_in_terminal",
44
+ "terminal",
45
+ "execute_command",
46
+ ];
47
+
48
+ const FILE_WRITE_TOOLS = [
49
+ "write",
50
+ "edit",
51
+ "multiedit",
52
+ "write_file",
53
+ "apply_patch",
54
+ "applypatch",
55
+ "create",
56
+ "create_file",
57
+ "createfile",
58
+ "str_replace",
59
+ "str_replace_editor",
60
+ "insert",
61
+ "insert_edit_into_file",
62
+ "replace_string_in_file",
63
+ "multi_replace_string_in_file",
64
+ "replacestring",
65
+ "edit_notebook_file",
66
+ "notebookedit",
67
+ ];
68
+
69
+ function isShellExec(input: SecurityInput): input is ShellExecInput {
70
+ return !("tool_name" in input) && !("toolName" in input) && "command" in input;
71
+ }
72
+
73
+ /** First of `keys` present as a non-empty string — agents disagree on argument spelling. */
74
+ function firstStringArg(
75
+ args: Record<string, unknown>,
76
+ keys: string[]
77
+ ): string | undefined {
78
+ for (const key of keys) {
79
+ const value = args[key];
80
+ if (typeof value === "string" && value.length > 0) return value;
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ /** Tool names that run a shell command, across every agent's naming. */
86
+ function runsShellCommand(toolName: string): boolean {
87
+ return SHELL_TOOLS.includes(toolName.toLowerCase());
88
+ }
89
+
90
+ /** Tool names that write to a file, across every agent's naming. */
91
+ function writesFile(toolName: string): boolean {
92
+ return FILE_WRITE_TOOLS.includes(toolName.toLowerCase());
93
+ }
94
+
95
+ /**
96
+ * What the gate would do with this call, or null to let it through.
97
+ *
98
+ * `cwd` is passed rather than read so a test can pin the target of a refused
99
+ * command, which has no file of its own to name.
100
+ */
101
+ export function decideRefusal(input: SecurityInput, cwd: string): GateRefusal | null {
102
+ if (isShellExec(input)) {
103
+ const reason = checkBashCommand(input.command);
104
+ if (!reason) return null;
105
+ return {
106
+ tool: "shell",
107
+ target: cwd,
108
+ command: input.command,
109
+ reason,
110
+ message: `Blocked: ${reason}`,
111
+ };
112
+ }
113
+
114
+ const toolUse = normalizeToolUse(input);
115
+ if (!toolUse) return null;
116
+
117
+ // Each agent names its shell/write tools differently; log the real name so an
118
+ // unrecognized one shows up here instead of silently skipping the check.
119
+ logDebug(
120
+ "SecurityValidator",
121
+ `toolName=${toolUse.toolName} args=${Object.keys(toolUse.toolInput).join(",")}`
122
+ );
123
+
124
+ const command = firstStringArg(toolUse.toolInput, ["command", "commandLine", "script"]);
125
+ if (runsShellCommand(toolUse.toolName) && command) {
126
+ const reason = checkBashCommand(command);
127
+ // "No output" from a downstream tool is indistinguishable between "denied,
128
+ // never ran" and "ran, produced nothing" — logging the verdict here, next
129
+ // to the literal command, is what actually tells the two apart.
130
+ const verdict = reason ? `BLOCK(${reason})` : "ALLOW";
131
+ logDebug("SecurityValidator", `bashVerdict=${verdict} command=${command}`);
132
+ if (reason) {
133
+ return {
134
+ tool: toolUse.toolName,
135
+ target: cwd,
136
+ command,
137
+ reason,
138
+ message: `Blocked: ${reason}`,
139
+ hookEventName: toolUse.hookEventName,
140
+ };
141
+ }
142
+ }
143
+
144
+ const filePath = firstStringArg(toolUse.toolInput, ["file_path", "filePath", "path"]);
145
+ if (writesFile(toolUse.toolName) && filePath) {
146
+ const reason = checkFilePath(filePath);
147
+ if (reason) {
148
+ return {
149
+ tool: toolUse.toolName,
150
+ target: filePath,
151
+ reason,
152
+ message: reason,
153
+ hookEventName: toolUse.hookEventName,
154
+ };
155
+ }
156
+ }
157
+
158
+ return null;
159
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * What each agent is handed at session start, and in which envelope.
3
+ *
4
+ * The four runtimes disagree on all of it: whether AGENTS.md is already loaded,
5
+ * which JSON key carries injected context, and whether it is read from stdout at
6
+ * all. This has been wrong in production before — Copilot silently received no
7
+ * context for weeks — and none of it was reachable from a test.
8
+ */
9
+
10
+ export interface ContextEnvelope {
11
+ /** Whether the agent parses stdout as JSON or reads it as raw text. */
12
+ kind: "json" | "text";
13
+ payload: string;
14
+ /** Copilot only: the same context, also written where its extension reads. */
15
+ file?: string;
16
+ }
17
+
18
+ /**
19
+ * A subagent gets none of this. Its parent already carries the context, and
20
+ * paying for it again on every spawn is the whole cost of a cheap subagent.
21
+ */
22
+ export function isSubagentSession(env: NodeJS.ProcessEnv): boolean {
23
+ if (env.CLAUDE_AGENT_TYPE !== undefined) return true;
24
+ return env.CLAUDE_PROJECT_DIR?.includes("/.claude/Agents/") ?? false;
25
+ }
26
+
27
+ /**
28
+ * Copilot and Cursor read AGENTS.md natively from nothing, so it is prepended
29
+ * here; Codex reaches it through a symlink and Claude Code loads it itself.
30
+ */
31
+ export function needsAgentsMd(agent: string): boolean {
32
+ return agent === "copilot" || agent === "cursor";
33
+ }
34
+
35
+ /**
36
+ * Copilot also writes the same text to a file, because its CLI reads stdout
37
+ * while the VS Code extension reads only ~/.copilot/instructions/.
38
+ */
39
+ export function contextEnvelope(
40
+ agent: string,
41
+ reminder: string,
42
+ agentsMd: string
43
+ ): ContextEnvelope | null {
44
+ const merged = needsAgentsMd(agent)
45
+ ? [agentsMd, reminder].filter(Boolean).join("\n\n")
46
+ : reminder;
47
+ if (!merged) return null;
48
+
49
+ if (agent === "copilot") {
50
+ return {
51
+ kind: "json",
52
+ payload: JSON.stringify({ additionalContext: merged }),
53
+ file: merged,
54
+ };
55
+ }
56
+ if (agent === "cursor") {
57
+ return { kind: "json", payload: JSON.stringify({ additional_context: merged }) };
58
+ }
59
+ if (agent === "codex") {
60
+ return {
61
+ kind: "json",
62
+ payload: JSON.stringify({
63
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: merged },
64
+ }),
65
+ };
66
+ }
67
+ // Claude Code, and opencode which uses the plugin path rather than this hook.
68
+ return { kind: "text", payload: merged };
69
+ }
70
+
71
+ /** The file Copilot's VS Code extension reads, with the applyTo header it needs. */
72
+ export function copilotInstructions(context: string): string {
73
+ return `---\napplyTo: "**"\n---\n\n${context}`;
74
+ }
@@ -15,87 +15,36 @@
15
15
 
16
16
  import { appendFileSync } from "node:fs";
17
17
  import { parseArgs } from "node:util";
18
- import { currentAttribution, type RecordAttribution } from "../../hooks/lib/actor";
19
- import { encodeAnchor } from "../../hooks/lib/anchor";
20
18
  import { paths } from "../../hooks/lib/paths";
19
+ import { buildReflection, intOr, reflectionLine } from "../lib/algorithm-reflect";
21
20
  import { emit } from "../lib/emit";
22
21
 
23
- // ── Types ──
22
+ const HELP = `
23
+ AlgorithmReflect — Log algorithm performance after LEARN phase
24
24
 
25
- interface AlgorithmReflection extends RecordAttribution {
26
- timestamp: string;
27
- cwd: string;
28
- task: string;
29
- criteria_count: number;
30
- criteria_passed: number;
31
- criteria_failed: number;
32
- sentiment: number;
33
- q1: string;
34
- q2: string;
35
- q3: string;
36
- /** general = the improvement ideas generalize to the algorithm; task-specific = bound to this task. */
37
- scope: "general" | "task-specific";
38
- }
25
+ Usage:
26
+ bun run tool:algorithm-reflect --task "description" --criteria N --passed N --failed N --sentiment 1-10 \\
27
+ --q1 "self reflection" --q2 "algorithm reflection" --q3 "AI reflection"
28
+
29
+ Arguments:
30
+ --task Brief task description
31
+ --criteria Total criteria count
32
+ --passed Criteria passed
33
+ --failed Criteria failed
34
+ --sentiment Implied satisfaction 1-10
35
+ --q1 Q1 — Self: what I'd do differently
36
+ --q2 Q2 Algorithm: structural improvement
37
+ --q3 Q3 — AI: reasoning blind spot
38
+ --scope general (default) | task-specific — is the algorithm idea reusable or task-bound?
39
39
 
40
- // ── Core ──
40
+ Output: algorithm-reflections.jsonl in memory/learning/reflections/
41
+ `;
41
42
 
42
43
  function reflectionsPath(): string {
43
- paths.reflections(); // ensures the directory exists
44
+ paths.reflections();
44
45
  return paths.reflectionsFile();
45
46
  }
46
47
 
47
- /**
48
- * Assemble a reflection record from CLI-style input, stamping the current
49
- * cwd (anchored) and this machine's id. Exported so the stamping logic is
50
- * directly testable without going through argv parsing.
51
- */
52
- export function buildReflection(input: {
53
- task: string;
54
- q1: string;
55
- q2: string;
56
- q3: string;
57
- criteria_count?: number;
58
- criteria_passed?: number;
59
- criteria_failed?: number;
60
- sentiment?: number;
61
- scope?: string;
62
- }): AlgorithmReflection {
63
- return {
64
- timestamp: new Date().toISOString(),
65
- cwd: encodeAnchor(process.cwd()),
66
- ...currentAttribution(),
67
- task: input.task,
68
- criteria_count: input.criteria_count ?? 0,
69
- criteria_passed: input.criteria_passed ?? 0,
70
- criteria_failed: input.criteria_failed ?? 0,
71
- sentiment: Math.max(1, Math.min(10, input.sentiment ?? 5)),
72
- q1: input.q1,
73
- q2: input.q2,
74
- q3: input.q3,
75
- // Default to general (the ~94% case); only "task-specific" suppresses it
76
- // from algorithm-update clustering.
77
- scope: input.scope === "task-specific" ? "task-specific" : "general",
78
- };
79
- }
80
-
81
- function appendReflection(reflection: AlgorithmReflection): {
82
- success: boolean;
83
- message: string;
84
- path: string;
85
- } {
86
- const p = reflectionsPath();
87
- const line = `${JSON.stringify(reflection)}\n`;
88
- appendFileSync(p, line, "utf-8");
89
-
90
- return {
91
- success: true,
92
- message: `Reflection logged: ${reflection.criteria_passed}/${reflection.criteria_count} passed, sentiment ${reflection.sentiment}/10`,
93
- path: p,
94
- };
95
- }
96
-
97
- // ── CLI ──
98
-
99
48
  function run() {
100
49
  const { values } = parseArgs({
101
50
  args: Bun.argv.slice(2),
@@ -114,26 +63,7 @@ function run() {
114
63
  });
115
64
 
116
65
  if (values.help) {
117
- console.log(`
118
- AlgorithmReflect — Log algorithm performance after LEARN phase
119
-
120
- Usage:
121
- bun run tool:algorithm-reflect --task "description" --criteria N --passed N --failed N --sentiment 1-10 \\
122
- --q1 "self reflection" --q2 "algorithm reflection" --q3 "AI reflection"
123
-
124
- Arguments:
125
- --task Brief task description
126
- --criteria Total criteria count
127
- --passed Criteria passed
128
- --failed Criteria failed
129
- --sentiment Implied satisfaction 1-10
130
- --q1 Q1 — Self: what I'd do differently
131
- --q2 Q2 — Algorithm: structural improvement
132
- --q3 Q3 — AI: reasoning blind spot
133
- --scope general (default) | task-specific — is the algorithm idea reusable or task-bound?
134
-
135
- Output: algorithm-reflections.jsonl in memory/learning/reflections/
136
- `);
66
+ console.log(HELP);
137
67
  process.exit(0);
138
68
  }
139
69
 
@@ -147,15 +77,16 @@ Output: algorithm-reflections.jsonl in memory/learning/reflections/
147
77
  q1: values.q1,
148
78
  q2: values.q2,
149
79
  q3: values.q3,
150
- criteria_count: parseInt(values.criteria || "0", 10),
151
- criteria_passed: parseInt(values.passed || "0", 10),
152
- criteria_failed: parseInt(values.failed || "0", 10),
153
- sentiment: parseInt(values.sentiment || "5", 10),
80
+ criteria_count: intOr(values.criteria, 0),
81
+ criteria_passed: intOr(values.passed, 0),
82
+ criteria_failed: intOr(values.failed, 0),
83
+ sentiment: intOr(values.sentiment, 5),
154
84
  scope: values.scope,
155
85
  });
156
86
 
157
- const result = appendReflection(reflection);
158
- emit.receipt(result.path, {
87
+ const path = reflectionsPath();
88
+ appendFileSync(path, reflectionLine(reflection), "utf-8");
89
+ emit.receipt(path, {
159
90
  passed: reflection.criteria_passed,
160
91
  of: reflection.criteria_count,
161
92
  scope: reflection.scope,
@@ -5,130 +5,17 @@
5
5
  * Reads failures and session learnings, finds recurring patterns,
6
6
  * summarizes ratings, and generates recommendations.
7
7
  *
8
+ * What the report says is in lib/analyze-report.ts.
9
+ *
8
10
  * Usage: bun run tool:analyze
9
11
  */
10
12
 
11
13
  import { parseArgs } from "node:util";
12
14
  import { writeLastAnalyzeDate } from "../../hooks/lib/analyze-nudge";
13
- import { type AnalysisResult, analyze } from "../../hooks/lib/graduation";
14
-
15
- // ── ANSI Colors ──
16
-
17
- const c = {
18
- bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
19
- dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
20
- cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
21
- yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
22
- green: (s: string) => `\x1b[32m${s}\x1b[0m`,
23
- red: (s: string) => `\x1b[31m${s}\x1b[0m`,
24
- magenta: (s: string) => `\x1b[35m${s}\x1b[0m`,
25
- };
26
-
27
- function printReport(result: AnalysisResult): void {
28
- const hasPatterns = result.candidates.length > 0 || result.emerging.length > 0;
29
- const hasRatings = result.ratings !== null;
30
-
31
- if (!hasPatterns && !hasRatings) {
32
- console.log("\n No patterns or ratings data found.\n");
33
- return;
34
- }
35
-
36
- if (result.ratings) {
37
- const r = result.ratings;
38
- const lowOrMid = r.average <= 4 ? c.red : c.yellow;
39
- const avgColor = r.average >= 7 ? c.green : lowOrMid;
40
- const ratingStr = `${r.average.toFixed(1)}/10`;
41
- const lowStr = `Low (≤4): ${r.low.count}`;
42
- const highStr = `High (≥7): ${r.high.count}`;
43
- console.log(
44
- `\n ${c.bold("Ratings:")} ${avgColor(ratingStr)} avg (${r.total} total)`
45
- );
46
- console.log(` ${c.red(lowStr)} | ${c.green(highStr)}`);
47
- }
48
-
49
- if (result.candidates.length > 0) {
50
- const graduationHeader = `Graduation Report — ${result.candidates.length} pattern(s) detected`;
51
- console.log(`\n ${c.bold(c.green(graduationHeader))}\n`);
52
- console.log(` ${c.dim("─────────────────────────────────────────────────")}\n`);
53
-
54
- for (const candidate of result.candidates) {
55
- const domain = `[${candidate.domain}]`;
56
- const count = `${candidate.entries.length}x`;
57
- console.log(` ${c.cyan(domain)} ${c.bold(count)} occurrences`);
58
- console.log("");
59
-
60
- for (const entry of candidate.entries) {
61
- const sourceType = entry.source.startsWith("failure:") ? "failure" : "learning";
62
- const tag =
63
- sourceType === "failure"
64
- ? c.red(`[${sourceType}]`)
65
- : c.yellow(`[${sourceType}]`);
66
- console.log(
67
- ` ${c.dim(entry.date || "unknown")} ${tag} ${entry.text.slice(0, 100)}`
68
- );
69
- }
70
-
71
- console.log(`\n ${c.dim("Files:")}`);
72
- for (const entry of candidate.entries) {
73
- console.log(` ${c.dim(entry.path)}`);
74
- }
75
-
76
- console.log("");
77
- const framePath = `memory/wisdom/frames/${candidate.domain}.md`;
78
- console.log(` Target frame: ${c.magenta(framePath)}`);
79
- console.log(` ${c.dim("─────────────────────────────────────────────────")}\n`);
80
- }
81
- }
82
-
83
- if (result.emerging.length > 0) {
84
- console.log(` ${c.bold(c.yellow("Emerging (2x — one more to graduate)"))}\n`);
85
- for (const group of result.emerging) {
86
- const domain = `[${group.domain}]`;
87
- const count = `${group.entries.length}x`;
88
- console.log(` ${c.cyan(domain)} ${c.bold(count)}`);
89
- for (const entry of group.entries) {
90
- const sourceType = entry.source.startsWith("failure:") ? "failure" : "learning";
91
- const tag =
92
- sourceType === "failure"
93
- ? c.red(`[${sourceType}]`)
94
- : c.yellow(`[${sourceType}]`);
95
- console.log(
96
- ` ${c.dim(entry.date || "unknown")} ${tag} ${entry.text.slice(0, 80)}`
97
- );
98
- }
99
- console.log(" Files:");
100
- for (const entry of group.entries) {
101
- console.log(` ${c.dim(entry.path)}`);
102
- }
103
- console.log("");
104
- }
105
- }
106
-
107
- if (result.recommendations.length > 0) {
108
- console.log(` ${c.bold("Recommendations:")}\n`);
109
- for (const rec of result.recommendations) {
110
- console.log(` ${rec}`);
111
- }
112
- console.log("");
113
- }
114
-
115
- if (result.candidates.length > 0) {
116
- console.log(` To crystallize: add a line to the wisdom frame file.`);
117
- console.log(` Format: ${c.green("- Your principle here [CRYSTAL: 85%]")}\n`);
118
- }
119
- }
15
+ import { analyze } from "../../hooks/lib/graduation";
16
+ import { reportLines } from "../lib/analyze-report";
120
17
 
121
- export async function run(argv: string[] = Bun.argv.slice(2)) {
122
- const { values } = parseArgs({
123
- args: argv,
124
- options: {
125
- help: { type: "boolean", short: "h" },
126
- actionable: { type: "boolean", short: "a" },
127
- },
128
- });
129
-
130
- if (values.help) {
131
- console.log(`
18
+ const HELP = `
132
19
  PAL Learning Analysis — unified graduation + ratings report
133
20
 
134
21
  Reads all captured failures (rating ≤3) and session learnings,
@@ -147,12 +34,24 @@ export async function run(argv: string[] = Bun.argv.slice(2)) {
147
34
  - Your principle here [CRYSTAL: 85%]
148
35
 
149
36
  Usage: pal cli analyze [--actionable]
150
- `);
37
+ `;
38
+
39
+ export async function run(argv: string[] = Bun.argv.slice(2)) {
40
+ const { values } = parseArgs({
41
+ args: argv,
42
+ options: {
43
+ help: { type: "boolean", short: "h" },
44
+ actionable: { type: "boolean", short: "a" },
45
+ },
46
+ });
47
+
48
+ if (values.help) {
49
+ console.log(HELP);
151
50
  process.exit(0);
152
51
  }
153
52
 
154
53
  const result = await analyze({ actionable: values.actionable });
155
- printReport(result);
54
+ for (const line of reportLines(result)) console.log(line);
156
55
  writeLastAnalyzeDate(new Date().toISOString());
157
56
  }
158
57