@meetless/mla 0.1.5 → 0.1.6

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 (41) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli.js +31 -5
  3. package/dist/commands/activate.js +39 -18
  4. package/dist/commands/agent-memory.js +333 -0
  5. package/dist/commands/enrich.js +211 -2
  6. package/dist/commands/internal-auto-index.js +64 -1
  7. package/dist/commands/internal-pretool-observe.js +86 -1
  8. package/dist/commands/internal-redact-capture.js +130 -0
  9. package/dist/commands/pilot.js +385 -0
  10. package/dist/lib/agent-memory-capture/binding.js +115 -0
  11. package/dist/lib/agent-memory-capture/classify.js +68 -0
  12. package/dist/lib/agent-memory-capture/collector.js +69 -0
  13. package/dist/lib/agent-memory-capture/containment.js +74 -0
  14. package/dist/lib/agent-memory-capture/ledger.js +43 -0
  15. package/dist/lib/agent-memory-capture/live-collector.js +148 -0
  16. package/dist/lib/agent-memory-capture/live-ledger.js +45 -0
  17. package/dist/lib/agent-memory-capture/live-pipeline.js +344 -0
  18. package/dist/lib/agent-memory-capture/lock.js +98 -0
  19. package/dist/lib/agent-memory-capture/paths.js +47 -0
  20. package/dist/lib/agent-memory-capture/pipeline.js +222 -0
  21. package/dist/lib/agent-memory-capture/report.js +131 -0
  22. package/dist/lib/agent-memory-capture/types.js +14 -0
  23. package/dist/lib/agent-memory-capture/upsert-client.js +104 -0
  24. package/dist/lib/analytics/enforcement-classify.js +65 -0
  25. package/dist/lib/analytics/enforcement-incident.js +83 -0
  26. package/dist/lib/analytics/envelope.js +55 -1
  27. package/dist/lib/analytics/pilot.js +313 -0
  28. package/dist/lib/enrichment/ingest.js +98 -13
  29. package/dist/lib/enrichment/materialize-rules.js +81 -0
  30. package/dist/lib/enrichment/plan.js +72 -15
  31. package/dist/lib/enrichment/protocol.js +85 -5
  32. package/dist/lib/enrichment/scout-brief.js +35 -6
  33. package/dist/lib/redactor.js +104 -1
  34. package/dist/lib/scanner/agent-memory.js +55 -4
  35. package/dist/lib/scanner/managed-rules.js +0 -0
  36. package/dist/lib/scanner/scan.js +52 -1
  37. package/dist/lib/scanner/score.js +41 -3
  38. package/dist/lib/scanner/scout-mission.js +9 -7
  39. package/dist/lib/upgrade-apply.js +30 -0
  40. package/dist/lib/wire.js +2 -0
  41. package/package.json +1 -1
@@ -19,10 +19,16 @@ exports.resolveBudgetMs = resolveBudgetMs;
19
19
  exports.parsePlanArgs = parsePlanArgs;
20
20
  exports.parseIngestArgs = parseIngestArgs;
21
21
  exports.extractResults = extractResults;
22
+ exports.renderIngestSummary = renderIngestSummary;
22
23
  exports.parseBriefArgs = parseBriefArgs;
24
+ exports.parseMaterializeArgs = parseMaterializeArgs;
25
+ exports.extractAcceptedCandidates = extractAcceptedCandidates;
26
+ exports.validateAcceptedCandidates = validateAcceptedCandidates;
27
+ exports.renderMaterializeSummary = renderMaterializeSummary;
23
28
  exports.runEnrich = runEnrich;
24
29
  const node_child_process_1 = require("node:child_process");
25
30
  const node_fs_1 = require("node:fs");
31
+ const node_path_1 = require("node:path");
26
32
  const node_crypto_1 = require("node:crypto");
27
33
  const config_1 = require("../lib/config");
28
34
  const workspace_1 = require("../lib/workspace");
@@ -31,7 +37,9 @@ const plan_1 = require("../lib/enrichment/plan");
31
37
  const ingest_1 = require("../lib/enrichment/ingest");
32
38
  const scout_brief_1 = require("../lib/enrichment/scout-brief");
33
39
  const protocol_1 = require("../lib/enrichment/protocol");
34
- const USAGE = `mla enrich: agent-orchestrated onboarding enrichment (two bookends).
40
+ const managed_rules_1 = require("../lib/scanner/managed-rules");
41
+ const materialize_rules_1 = require("../lib/enrichment/materialize-rules");
42
+ const USAGE = `mla enrich: agent-orchestrated onboarding enrichment.
35
43
 
36
44
  mla enrich plan [--json] [--budget-ms <n>] [--workspace <id>]
37
45
  Scan this repository into an immutable run record and print the plan the
@@ -50,7 +58,16 @@ const USAGE = `mla enrich: agent-orchestrated onboarding enrichment (two bookend
50
58
  stdin when no file is given (an array of scout results, or an object with a
51
59
  \`results\` array). Candidates land in the governed KB born PENDING.
52
60
  Exit: 0 clean, 1 a scout needs attention (persistence failed / malformed),
53
- 2 the request was rejected (unknown run, mismatch, corrupt record).`;
61
+ 2 the request was rejected (unknown run, mismatch, corrupt record).
62
+
63
+ mla enrich materialize [--accepted-file <path>] [--dry-run] [--json]
64
+ Write the accepted DURABLE rules (constraint, convention, boundary) into the
65
+ mla-managed rule file (.meetless/rules.md), the one canonical source the local
66
+ scanner injects from. Reads the accepted candidates as JSON from --accepted-file,
67
+ or from stdin (a bare array, or an object with an \`accepted\` array). Decisions and
68
+ deprecations are reported as skipped, never written. Local only: no commit, no push
69
+ (prints "Effective locally. Commit and push to share with teammates."). --dry-run
70
+ reports the change without writing. Exit: 0 done (or nothing to do), 2 bad input.`;
54
71
  // Mirror kb_add's ingest timeout heuristic (it is module-private there). Generous,
55
72
  // scales with document count: the kb-add route runs the full atomic-claim pipeline.
56
73
  function ingestTimeoutMs(docCount) {
@@ -242,13 +259,31 @@ function extractResults(raw, runId) {
242
259
  }
243
260
  function renderIngestSummary(outcomes, status) {
244
261
  const lines = [`Onboarding ingest complete (state: ${status ?? "unknown"}).`, ``];
262
+ let totalPersisted = 0;
245
263
  for (const o of outcomes) {
264
+ totalPersisted += o.persisted;
246
265
  lines.push(` ${o.scout}: ${o.accepted} accepted, ${o.rejected} rejected, ${o.persisted} persisted (received ${o.received})`);
247
266
  for (const e of o.errors) {
248
267
  const where = e.index >= 0 ? `candidate ${e.index}` : "scout";
249
268
  lines.push(` - ${where}: ${e.code} (${e.message})`);
250
269
  }
251
270
  }
271
+ // Review handoff: candidates land born PENDING, so the human reviews them next. The
272
+ // extraction cap (up to 20 per run) is NOT the review batch: show the first
273
+ // REVIEW_BATCH_DEFAULT and keep the rest behind "show more" so a big run does not bury
274
+ // the reviewer (Phase 2). When everything fits in one batch, say so plainly.
275
+ if (totalPersisted > 0) {
276
+ const batch = (0, protocol_1.selectReviewBatch)(totalPersisted);
277
+ lines.push(``);
278
+ if (batch.hasMore) {
279
+ lines.push(`Next: review ${totalPersisted} candidate${totalPersisted === 1 ? "" : "s"} born PENDING. ` +
280
+ `Run \`mla review\` to see the first ${batch.shown}; ${batch.remaining} more are behind "show more".`);
281
+ }
282
+ else {
283
+ lines.push(`Next: review ${totalPersisted} candidate${totalPersisted === 1 ? "" : "s"} born PENDING with \`mla review\`. ` +
284
+ `Nothing is accepted until you say so.`);
285
+ }
286
+ }
252
287
  return lines.join("\n");
253
288
  }
254
289
  async function runEnrichIngest(argv) {
@@ -405,6 +440,178 @@ function runEnrichBrief(argv) {
405
440
  console.log((0, scout_brief_1.buildScoutPrompt)(run, flags.role));
406
441
  return 0;
407
442
  }
443
+ function parseMaterializeArgs(argv) {
444
+ const flags = { json: false, dryRun: false };
445
+ for (let i = 0; i < argv.length; i++) {
446
+ const a = argv[i];
447
+ if (a === "--json")
448
+ flags.json = true;
449
+ else if (a === "--dry-run")
450
+ flags.dryRun = true;
451
+ else if (a === "--accepted-file") {
452
+ flags.acceptedFile = argv[++i];
453
+ if (!flags.acceptedFile)
454
+ throw new Error("--accepted-file requires a path");
455
+ }
456
+ else
457
+ throw new Error(`Unknown flag for \`mla enrich materialize\`: ${a}`);
458
+ }
459
+ return flags;
460
+ }
461
+ // Normalize the accepted payload into the candidate array. Accept a bare array, or an
462
+ // object with an `accepted` array (the natural name for "the ones the human accepted"),
463
+ // or a `candidates` array (so an onboard scout-result candidate list pastes through).
464
+ function extractAcceptedCandidates(raw) {
465
+ let parsed;
466
+ try {
467
+ parsed = JSON.parse(raw);
468
+ }
469
+ catch (e) {
470
+ throw new Error(`accepted candidates are not valid JSON: ${e.message}`);
471
+ }
472
+ if (Array.isArray(parsed))
473
+ return parsed;
474
+ if (parsed && typeof parsed === "object") {
475
+ const obj = parsed;
476
+ if (Array.isArray(obj.accepted))
477
+ return obj.accepted;
478
+ if (Array.isArray(obj.candidates))
479
+ return obj.candidates;
480
+ }
481
+ throw new Error("accepted candidates must be a JSON array, or an object with an `accepted` (or `candidates`) array");
482
+ }
483
+ function validateAcceptedCandidates(raw) {
484
+ const candidates = [];
485
+ const errors = [];
486
+ raw.forEach((r, i) => {
487
+ const res = (0, protocol_1.validateCandidateShape)(r, i);
488
+ if (res.ok)
489
+ candidates.push(res.candidate);
490
+ else
491
+ errors.push(...res.errors);
492
+ });
493
+ if (errors.length > 0)
494
+ return { ok: false, errors };
495
+ return { ok: true, candidates };
496
+ }
497
+ // Human-readable summary. Pure (no fs) and exported so its wording is pinned by a test.
498
+ // `dryRun` flips the verb from "materialized" to "would materialize" and suppresses the
499
+ // share line (nothing was written, so there is nothing local to share yet).
500
+ function renderMaterializeSummary(result, relPath, dryRun) {
501
+ const lines = [];
502
+ const added = [...result.materialized].sort((a, b) => a.statement.localeCompare(b.statement));
503
+ const skipped = [...result.skipped].sort((a, b) => a.statement.localeCompare(b.statement));
504
+ if (added.length > 0) {
505
+ const verb = dryRun ? "Would materialize" : "Materialized";
506
+ lines.push(`${verb} ${added.length} durable rule${added.length === 1 ? "" : "s"} into ${relPath}:`);
507
+ for (const r of added)
508
+ lines.push(` + ${r.statement}`);
509
+ }
510
+ else {
511
+ lines.push(`No durable rules to materialize (${relPath} unchanged).`);
512
+ }
513
+ if (skipped.length > 0) {
514
+ lines.push("");
515
+ lines.push(`Skipped ${skipped.length} non-rule candidate${skipped.length === 1 ? "" : "s"}:`);
516
+ for (const s of skipped) {
517
+ const why = s.reason === "empty_statement" ? "empty statement" : `${s.kind} (governed knowledge, not a rule)`;
518
+ lines.push(` - ${s.statement || "(empty)"}: ${why}`);
519
+ }
520
+ }
521
+ if (result.changed && !dryRun) {
522
+ lines.push("");
523
+ lines.push(materialize_rules_1.MATERIALIZE_SHARE_MESSAGE);
524
+ }
525
+ return lines.join("\n");
526
+ }
527
+ function readManagedFile(path) {
528
+ try {
529
+ return (0, node_fs_1.readFileSync)(path, "utf8");
530
+ }
531
+ catch {
532
+ return ""; // missing file is the empty starting point, not an error
533
+ }
534
+ }
535
+ // Atomic write: render to a sibling temp file then rename, so a crash mid-write never
536
+ // leaves a half-written rules file (the scanner reads it directly from disk every turn).
537
+ function writeManagedFile(path, text) {
538
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
539
+ const tmp = `${path}.tmp-${process.pid}`;
540
+ (0, node_fs_1.writeFileSync)(tmp, text, "utf8");
541
+ (0, node_fs_1.renameSync)(tmp, path);
542
+ }
543
+ async function runEnrichMaterialize(argv) {
544
+ let flags;
545
+ try {
546
+ flags = parseMaterializeArgs(argv);
547
+ }
548
+ catch (e) {
549
+ console.error(e.message);
550
+ return 2;
551
+ }
552
+ // Local-only: the managed file lives at the git root. No workspace id, no auth.
553
+ let repositoryRoot;
554
+ try {
555
+ repositoryRoot = resolveRepositoryRoot(process.cwd());
556
+ }
557
+ catch (e) {
558
+ console.error(e.message);
559
+ return 2;
560
+ }
561
+ let raw;
562
+ try {
563
+ if (flags.acceptedFile) {
564
+ raw = (0, node_fs_1.readFileSync)(flags.acceptedFile, "utf8");
565
+ }
566
+ else if (!process.stdin.isTTY) {
567
+ raw = await readStdin();
568
+ }
569
+ else {
570
+ console.error("provide --accepted-file <path> or pipe the accepted candidates JSON to stdin");
571
+ return 2;
572
+ }
573
+ }
574
+ catch (e) {
575
+ console.error(`could not read accepted candidates: ${e.message}`);
576
+ return 2;
577
+ }
578
+ let rawCandidates;
579
+ try {
580
+ rawCandidates = extractAcceptedCandidates(raw);
581
+ }
582
+ catch (e) {
583
+ console.error(e.message);
584
+ return 2;
585
+ }
586
+ const validation = validateAcceptedCandidates(rawCandidates);
587
+ if (!validation.ok) {
588
+ console.error(`refusing to materialize: ${validation.errors.length} invalid candidate(s).`);
589
+ for (const e of validation.errors) {
590
+ console.error(` - candidate ${e.index}: ${e.code}${e.field ? ` (${e.field})` : ""} ${e.message}`);
591
+ }
592
+ return 2;
593
+ }
594
+ const managedPath = (0, node_path_1.join)(repositoryRoot, managed_rules_1.MANAGED_RULES_PATH);
595
+ const existing = readManagedFile(managedPath);
596
+ const result = (0, materialize_rules_1.materializeRules)(existing, validation.candidates);
597
+ if (result.changed && !flags.dryRun) {
598
+ writeManagedFile(managedPath, result.text);
599
+ }
600
+ if (flags.json) {
601
+ console.log(JSON.stringify({
602
+ path: managed_rules_1.MANAGED_RULES_PATH,
603
+ changed: result.changed,
604
+ wrote: result.changed && !flags.dryRun,
605
+ dryRun: flags.dryRun,
606
+ materialized: result.materialized.map((r) => ({ id: r.id, statement: r.statement, strength: r.strength })),
607
+ skipped: result.skipped,
608
+ }, null, 2));
609
+ }
610
+ else {
611
+ console.log(renderMaterializeSummary(result, managed_rules_1.MANAGED_RULES_PATH, flags.dryRun));
612
+ }
613
+ return 0;
614
+ }
408
615
  async function runEnrich(argv) {
409
616
  const sub = argv[0];
410
617
  const rest = argv.slice(1);
@@ -419,6 +626,8 @@ async function runEnrich(argv) {
419
626
  return runEnrichBrief(rest);
420
627
  case "ingest":
421
628
  return runEnrichIngest(rest);
629
+ case "materialize":
630
+ return runEnrichMaterialize(rest);
422
631
  default:
423
632
  console.error(`unknown \`mla enrich\` subcommand: ${sub}\n`);
424
633
  console.error(USAGE);
@@ -64,6 +64,7 @@ const config_1 = require("../lib/config");
64
64
  const active_memory_1 = require("../lib/active-memory");
65
65
  const auto_index_1 = require("../lib/auto-index");
66
66
  const kb_acl_1 = require("../lib/kb_acl");
67
+ const live_collector_1 = require("../lib/agent-memory-capture/live-collector");
67
68
  const kb_add_1 = require("./kb_add");
68
69
  function activeMemoryStorePath() {
69
70
  return path.join(config_1.HOME, "logs", "kb-knowledge.jsonl");
@@ -87,6 +88,50 @@ function parseArgs(argv) {
87
88
  }
88
89
  return { sessionId };
89
90
  }
91
+ // Roll the per-binding live results into a flat outcome tally for the worker's
92
+ // JSON summary. Counts only; never carries content. `locked` is the number of
93
+ // bindings skipped because another collector held the lock this pass.
94
+ function tallyLive(results) {
95
+ let bindings = 0;
96
+ let uploaded = 0;
97
+ let deferred = 0;
98
+ let blocked = 0;
99
+ let withdrawn = 0;
100
+ let failed = 0;
101
+ // Bindings that did no work this pass: another collector held the lock, or the
102
+ // pipeline threw (fail-soft). Both surface as summary === null.
103
+ let skippedBindings = 0;
104
+ for (const r of results) {
105
+ if (!r.locked || !r.summary) {
106
+ skippedBindings++;
107
+ continue;
108
+ }
109
+ bindings++;
110
+ for (const rec of r.summary.records) {
111
+ switch (rec.outcome) {
112
+ case "uploaded":
113
+ uploaded++;
114
+ break;
115
+ case "deferred":
116
+ deferred++;
117
+ break;
118
+ case "blocked":
119
+ blocked++;
120
+ break;
121
+ case "reclassified":
122
+ case "deleted":
123
+ withdrawn++;
124
+ break;
125
+ case "failed":
126
+ failed++;
127
+ break;
128
+ default:
129
+ break; // unchanged / skipped: no-op, not surfaced
130
+ }
131
+ }
132
+ }
133
+ return { bindings, uploaded, deferred, blocked, withdrawn, failed, skippedBindings };
134
+ }
90
135
  // runKbAdd converts KbOwnerCheckError into stderr + exit 2 before it reaches
91
136
  // this loop, so a thrown denial only arrives from injected add fns or future
92
137
  // boundary changes. Match the class, the name (survives module-duplication
@@ -177,7 +222,25 @@ async function runInternalAutoIndex(argv, deps = {}) {
177
222
  failed++; // fail-soft: one bad add never aborts the batch.
178
223
  }
179
224
  }
180
- console.log(JSON.stringify({ indexed, skipped, failed, total: targets.length }));
225
+ // Live agent-memory capture (proposal §6): the collector attached to this
226
+ // existing Stop worker. DEFAULT OFF: it runs only when the operator opted in
227
+ // (MEETLESS_AGENT_MEMORY_LIVE) AND there is a consented binding + actor.
228
+ // Fully fail-soft and OUTSIDE the Zone-2 result above: any error here is
229
+ // swallowed and never changes the indexed/skipped/failed counts or the
230
+ // session. Runs last so it rides the same detached tail.
231
+ const summary = { indexed, skipped, failed, total: targets.length };
232
+ const liveEnabled = deps.liveEnabled ?? live_collector_1.liveCaptureEnabled;
233
+ if (liveEnabled()) {
234
+ try {
235
+ const runLive = deps.runLive ?? live_collector_1.runLiveCollector;
236
+ const liveResults = await runLive({ nowIso: new Date().toISOString() });
237
+ summary.liveCapture = tallyLive(liveResults);
238
+ }
239
+ catch {
240
+ // Live capture must never disturb the auto-index worker. Swallow.
241
+ }
242
+ }
243
+ console.log(JSON.stringify(summary));
181
244
  return 0;
182
245
  }
183
246
  catch {
@@ -18,6 +18,39 @@
18
18
  // `mla`) would block the tool; we must never do that. Every failure path -- unreadable stdin,
19
19
  // malformed payload, store open failure, a throwing dependency -- fails OPEN to the exit-0
20
20
  // pass-through. The decision travels in the JSON body, never the exit code.
21
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ var desc = Object.getOwnPropertyDescriptor(m, k);
24
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
25
+ desc = { enumerable: true, get: function() { return m[k]; } };
26
+ }
27
+ Object.defineProperty(o, k2, desc);
28
+ }) : (function(o, m, k, k2) {
29
+ if (k2 === undefined) k2 = k;
30
+ o[k2] = m[k];
31
+ }));
32
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
33
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
34
+ }) : function(o, v) {
35
+ o["default"] = v;
36
+ });
37
+ var __importStar = (this && this.__importStar) || (function () {
38
+ var ownKeys = function(o) {
39
+ ownKeys = Object.getOwnPropertyNames || function (o) {
40
+ var ar = [];
41
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
42
+ return ar;
43
+ };
44
+ return ownKeys(o);
45
+ };
46
+ return function (mod) {
47
+ if (mod && mod.__esModule) return mod;
48
+ var result = {};
49
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
50
+ __setModuleDefault(result, mod);
51
+ return result;
52
+ };
53
+ })();
21
54
  Object.defineProperty(exports, "__esModule", { value: true });
22
55
  exports.PRETOOL_PASS_THROUGH = void 0;
23
56
  exports.renderPreToolUseResponse = renderPreToolUseResponse;
@@ -28,6 +61,7 @@ const active_conflict_cache_1 = require("../lib/active-conflict-cache");
28
61
  const ce0_store_1 = require("../lib/rules/ce0-store");
29
62
  const enforce_notes_version_1 = require("../lib/rules/enforce-notes-version");
30
63
  const observe_adapter_1 = require("../lib/rules/observe-adapter");
64
+ const enforcement_classify_1 = require("../lib/analytics/enforcement-classify");
31
65
  const runtime_scope_1 = require("../lib/rules/runtime-scope");
32
66
  const live_input_authority_1 = require("../lib/rules/live-input-authority");
33
67
  const cache_1 = require("../lib/scanner/cache");
@@ -120,7 +154,7 @@ async function computePretoolDecision(raw, deps) {
120
154
  const clock = (deps.clock ?? defaultClock)();
121
155
  const resolveInputAuthority = deps.resolveInputAuthority ?? live_input_authority_1.resolveLiveInputAuthority;
122
156
  const directives = (deps.resolveDirectives ?? defaultResolveDirectives)();
123
- const { response } = await (0, enforce_notes_version_1.evaluateEnforceOrObserveNotesRule)(store, {
157
+ const { response, outcome } = await (0, enforce_notes_version_1.evaluateEnforceOrObserveNotesRule)(store, {
124
158
  rawStdin: raw,
125
159
  runtimeProjectRoot: scope.runtimeProjectRoot,
126
160
  runtimeScopeId: scope.runtimeScopeId,
@@ -135,6 +169,10 @@ async function computePretoolDecision(raw, deps) {
135
169
  // passes through do we layer the SOFT cross-session conflict warning (which can
136
170
  // only ever advise, never deny).
137
171
  if ("permissionDecision" in response && response.permissionDecision === "deny") {
172
+ // Emit the one analytics append the deny tile reads (§5.1). Awaited so the
173
+ // synchronous local spool write lands before this short-lived process exits;
174
+ // fail-soft so a telemetry fault can never turn into a thrown (blocking) hook.
175
+ await emitDenyIncident(raw, outcome, clock.now, deps);
138
176
  return renderPreToolUseResponse(response);
139
177
  }
140
178
  return computeConflictWarning(raw, deps);
@@ -185,6 +223,53 @@ function computeConflictWarning(raw, deps) {
185
223
  return renderPreToolUseResponse(exports.PRETOOL_PASS_THROUGH);
186
224
  }
187
225
  }
226
+ /**
227
+ * Emit the enforcement-incident analytics event for one fired deny (the deny tile, §5.1).
228
+ * Classifies the tool + blocked-path surface into PII-safe enums (the path never leaves the
229
+ * device), resolves the workspace fail-open, and hands the built input/coords to the injected
230
+ * emitter (production lazy-imports the real append-only emit). The whole thing is fail-soft: a
231
+ * deny must never be turned into a thrown, blocking hook by a telemetry fault. The emitter is
232
+ * awaited so its synchronous local append lands before this short-lived hook process exits.
233
+ */
234
+ async function emitDenyIncident(raw, outcome, nowMs, deps) {
235
+ try {
236
+ // The deny response is only ever returned alongside a DENIED outcome (the enforce path);
237
+ // guard defensively so a future shape change degrades to no-telemetry, never a wrong event.
238
+ if (outcome.kind !== "DENIED")
239
+ return;
240
+ const parsed = (0, observe_adapter_1.parsePreToolUseInput)(raw);
241
+ const filePath = typeof parsed?.tool_input?.file_path === "string" ? parsed.tool_input.file_path : null;
242
+ let workspaceId = null;
243
+ try {
244
+ workspaceId = (0, workspace_1.resolveWorkspaceIdWithEnv)() || null;
245
+ }
246
+ catch {
247
+ workspaceId = null;
248
+ }
249
+ const input = {
250
+ incidentId: outcome.attemptId,
251
+ decision: "deny",
252
+ tool: (0, enforcement_classify_1.normalizeEnforcedTool)(parsed?.tool_name),
253
+ touchedSurface: (0, enforcement_classify_1.classifyTouchedSurface)(filePath),
254
+ ruleVersionId: outcome.ruleVersionId,
255
+ };
256
+ const coords = {
257
+ workspaceId,
258
+ sessionId: parsed?.session_id ?? null,
259
+ nowMs,
260
+ };
261
+ await (deps.emitIncident ?? defaultEmitIncident)(input, coords);
262
+ }
263
+ catch {
264
+ // Fail-soft: deny telemetry must never escalate into a blocking hook.
265
+ }
266
+ }
267
+ /** Production deny emitter: lazy-imports the recorder-touching emit so the non-deny hot path
268
+ * never loads it, then performs the synchronous local append. Awaited by the caller. */
269
+ async function defaultEmitIncident(input, coords) {
270
+ const { emitEnforcementIncident } = await Promise.resolve().then(() => __importStar(require("../lib/analytics/enforcement-incident")));
271
+ emitEnforcementIncident(input, coords);
272
+ }
188
273
  function defaultResolveScope() {
189
274
  const root = (0, runtime_scope_1.resolveActiveRuntimeScopeId)();
190
275
  return { runtimeScopeId: root, runtimeProjectRoot: root };
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ // `mla _internal redact-capture` (governed-story capture, P1). The
3
+ // user-prompt-submit / post-tool-use hooks assemble injected-context blocks and
4
+ // MCP query text in shell, then pipe a single JSON payload through this command
5
+ // to redact secrets BEFORE the trace is spooled. We redact here (in a node
6
+ // child) rather than in bash so the capture path reuses the ONE parity-locked
7
+ // redactor (lib/redactor.ts, mirror of intel + control), instead of forking a
8
+ // third, drifting copy of the secret patterns into shell. Spec
9
+ // notes/20260627-session-detail-mla-actions-and-colored-injection-timeline-design.md §4.4.
10
+ //
11
+ // Contract (fail-closed telemetry, fail-open agent -- the agent's prompt is
12
+ // delivered by the hook independently of this call):
13
+ // stdin : { blocks?: [{kind, content, citations?, charCount?, itemCount?}], query?: string }
14
+ // stdout : { blocks: [{kind, content, contentStatus, citations, charCount, itemCount}], query }
15
+ // exit 0 : redaction succeeded; the hook spools the redacted output verbatim.
16
+ // exit 1 : ANY failure (unreadable/malformed stdin, serialization fault). The
17
+ // hook treats this as redaction_failed: it persists content:null +
18
+ // contentStatus:"redaction_failed" for every block and NEVER
19
+ // substitutes a raw body. Raw content never leaves this process on a
20
+ // failure path.
21
+ //
22
+ // charCount is computed HERE from the raw (pre-redaction) body so it is a single
23
+ // factual source the control boundary can check (summary.injectedCharCount ==
24
+ // sum(block.charCount)); the producer cannot drift it. citations and itemCount
25
+ // are producer metadata and pass through untouched (they are governance ids /
26
+ // counts, not secrets).
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.redactCapturePayload = redactCapturePayload;
29
+ exports.runInternalRedactCapture = runInternalRedactCapture;
30
+ const redactor_1 = require("../lib/redactor");
31
+ function asStringArray(value) {
32
+ if (!Array.isArray(value))
33
+ return [];
34
+ return value.filter((v) => typeof v === "string");
35
+ }
36
+ function asNonNegativeInt(value) {
37
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
38
+ return null;
39
+ }
40
+ return value;
41
+ }
42
+ // Count code points (not UTF-16 units) so the "N chars" the chip later shows is
43
+ // honest for multi-byte content. Computed from the RAW body, pre-redaction.
44
+ function charCountOf(content) {
45
+ return Array.from(content).length;
46
+ }
47
+ /**
48
+ * Pure. Redact every block body and the query with the shared redactor.
49
+ * - content null/empty stays as-is, contentStatus "available" (nothing to scrub).
50
+ * - content that the redactor changed -> "redacted"; unchanged -> "available".
51
+ * - charCount is the ORIGINAL (pre-redaction) length, factual.
52
+ * Never throws on well-formed string inputs (redact is a regex replace); the IO
53
+ * shell maps a throw to exit 1.
54
+ */
55
+ function redactCapturePayload(input) {
56
+ const rawBlocks = Array.isArray(input.blocks) ? input.blocks : [];
57
+ const blocks = rawBlocks.map((b) => {
58
+ const block = (b ?? {});
59
+ const kind = typeof block.kind === "string" ? block.kind : "unknown";
60
+ const citations = asStringArray(block.citations);
61
+ const itemCount = asNonNegativeInt(block.itemCount);
62
+ const rawContent = typeof block.content === "string" ? block.content : null;
63
+ if (rawContent === null || rawContent === "") {
64
+ return {
65
+ kind,
66
+ content: rawContent,
67
+ contentStatus: "available",
68
+ citations,
69
+ charCount: 0,
70
+ itemCount,
71
+ };
72
+ }
73
+ const redacted = (0, redactor_1.redact)(rawContent) ?? rawContent;
74
+ return {
75
+ kind,
76
+ content: redacted,
77
+ contentStatus: redacted === rawContent ? "available" : "redacted",
78
+ citations,
79
+ charCount: charCountOf(rawContent),
80
+ itemCount,
81
+ };
82
+ });
83
+ const rawQuery = typeof input.query === "string" ? input.query : null;
84
+ const query = rawQuery === null ? null : ((0, redactor_1.redact)(rawQuery) ?? rawQuery);
85
+ return { blocks, query };
86
+ }
87
+ function readStdinReal() {
88
+ return new Promise((resolve, reject) => {
89
+ const chunks = [];
90
+ process.stdin.on("data", (c) => chunks.push(c));
91
+ process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
92
+ process.stdin.on("error", reject);
93
+ });
94
+ }
95
+ const defaultDeps = {
96
+ readStdin: readStdinReal,
97
+ writeOut: (s) => process.stdout.write(s),
98
+ };
99
+ /**
100
+ * IO shell. Reads the JSON payload from stdin, redacts, writes the redacted JSON
101
+ * to stdout. Exit 1 on ANY failure (read error, malformed JSON, serialization
102
+ * fault) WITHOUT writing a partial/raw body, so the hook degrades to
103
+ * redaction_failed and never persists an unredacted secret. Takes no argv.
104
+ */
105
+ async function runInternalRedactCapture(_argv, deps = defaultDeps) {
106
+ let raw;
107
+ try {
108
+ raw = await deps.readStdin();
109
+ }
110
+ catch {
111
+ return 1;
112
+ }
113
+ let parsed;
114
+ try {
115
+ parsed = JSON.parse(raw);
116
+ }
117
+ catch {
118
+ return 1;
119
+ }
120
+ if (!parsed || typeof parsed !== "object")
121
+ return 1;
122
+ try {
123
+ const out = redactCapturePayload(parsed);
124
+ deps.writeOut(JSON.stringify(out));
125
+ return 0;
126
+ }
127
+ catch {
128
+ return 1;
129
+ }
130
+ }