@event4u/agent-config 8.4.0 → 8.4.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.
Files changed (126) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +6 -0
  3. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  4. package/dist/discovery/deprecation-report.md +1 -1
  5. package/dist/discovery/discovery-manifest.json +1 -1
  6. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  7. package/dist/discovery/discovery-manifest.summary.md +1 -1
  8. package/dist/discovery/orphan-report.md +1 -1
  9. package/dist/discovery/packs.json +1 -1
  10. package/dist/discovery/trust-report.md +1 -1
  11. package/dist/discovery/workspaces.json +1 -1
  12. package/dist/mcp/registry-manifest.json +1 -1
  13. package/package.json +4 -1
  14. package/src/agent-src/README.md +64 -0
  15. package/src/agent-src/scripts/archive_completed_roadmaps.ts +497 -0
  16. package/src/agent-src/scripts/scan-seeder-violations.php +145 -0
  17. package/src/agent-src/scripts/update_roadmap_progress.ts +1087 -0
  18. package/src/agent-src/templates/AGENTS.md +34 -0
  19. package/src/agent-src/templates/scripts/README.md +195 -0
  20. package/src/agent-src/templates/scripts/check_memory.ts +662 -0
  21. package/src/agent-src/templates/scripts/check_memory_proposal.ts +370 -0
  22. package/src/agent-src/templates/scripts/historical-bug-patterns.example.yml +84 -0
  23. package/src/agent-src/templates/scripts/implement_ticket/__main__.ts +27 -0
  24. package/src/agent-src/templates/scripts/memory_hash.ts +355 -0
  25. package/src/agent-src/templates/scripts/memory_lookup.ts +1089 -0
  26. package/src/agent-src/templates/scripts/memory_report.ts +868 -0
  27. package/src/agent-src/templates/scripts/memory_signal.ts +444 -0
  28. package/src/agent-src/templates/scripts/memory_status.ts +215 -0
  29. package/src/agent-src/templates/scripts/ownership-map.example.yml +87 -0
  30. package/src/agent-src/templates/scripts/pr-risk-config.example.yml +76 -0
  31. package/src/agent-src/templates/scripts/pr_review_routing.ts +544 -0
  32. package/src/agent-src/templates/scripts/pr_risk_review.ts +0 -0
  33. package/src/agent-src/templates/scripts/telemetry/aggregator.ts +0 -0
  34. package/src/agent-src/templates/scripts/telemetry/boundary.ts +164 -0
  35. package/src/agent-src/templates/scripts/telemetry/engagement.ts +479 -0
  36. package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +394 -0
  37. package/src/agent-src/templates/scripts/telemetry/settings.ts +210 -0
  38. package/src/agent-src/templates/scripts/telemetry_record.ts +276 -0
  39. package/src/agent-src/templates/scripts/telemetry_report.ts +211 -0
  40. package/src/agent-src/templates/scripts/telemetry_status.ts +333 -0
  41. package/src/agent-src/templates/scripts/tier_usage_report.ts +619 -0
  42. package/src/agent-src/templates/scripts/work_engine/__main__.ts +14 -0
  43. package/src/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +1261 -0
  44. package/src/agent-src/templates/scripts/work_engine/_lib/user_global_paths.ts +329 -0
  45. package/src/agent-src/templates/scripts/work_engine/cli.ts +206 -0
  46. package/src/agent-src/templates/scripts/work_engine/cli_args.ts +249 -0
  47. package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +225 -0
  48. package/src/agent-src/templates/scripts/work_engine/directives/backend/analyze.ts +125 -0
  49. package/src/agent-src/templates/scripts/work_engine/directives/backend/implement.ts +189 -0
  50. package/src/agent-src/templates/scripts/work_engine/directives/backend/index.ts +94 -0
  51. package/src/agent-src/templates/scripts/work_engine/directives/backend/memory.ts +193 -0
  52. package/src/agent-src/templates/scripts/work_engine/directives/backend/plan.ts +267 -0
  53. package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +518 -0
  54. package/src/agent-src/templates/scripts/work_engine/directives/backend/report.ts +379 -0
  55. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +268 -0
  56. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +258 -0
  57. package/src/agent-src/templates/scripts/work_engine/directives/index.ts +32 -0
  58. package/src/agent-src/templates/scripts/work_engine/directives/mixed/contract.ts +243 -0
  59. package/src/agent-src/templates/scripts/work_engine/directives/mixed/index.ts +108 -0
  60. package/src/agent-src/templates/scripts/work_engine/directives/mixed/stitch.ts +259 -0
  61. package/src/agent-src/templates/scripts/work_engine/directives/mixed/ui.ts +216 -0
  62. package/src/agent-src/templates/scripts/work_engine/directives/ui/_passthrough.ts +40 -0
  63. package/src/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +241 -0
  64. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +216 -0
  65. package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +506 -0
  66. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +325 -0
  67. package/src/agent-src/templates/scripts/work_engine/directives/ui/index.ts +102 -0
  68. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +462 -0
  69. package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +474 -0
  70. package/src/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +352 -0
  71. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.ts +33 -0
  72. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.ts +213 -0
  73. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/index.ts +111 -0
  74. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.ts +126 -0
  75. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/report.ts +112 -0
  76. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/test.ts +164 -0
  77. package/src/agent-src/templates/scripts/work_engine/dispatcher.ts +515 -0
  78. package/src/agent-src/templates/scripts/work_engine/emitters.ts +119 -0
  79. package/src/agent-src/templates/scripts/work_engine/errors.ts +24 -0
  80. package/src/agent-src/templates/scripts/work_engine/hook_bootstrap.ts +104 -0
  81. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.ts +189 -0
  82. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.ts +41 -0
  83. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.ts +89 -0
  84. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +193 -0
  85. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.ts +304 -0
  86. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.ts +110 -0
  87. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.ts +118 -0
  88. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/index.ts +17 -0
  89. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.ts +161 -0
  90. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.ts +45 -0
  91. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/trace.ts +134 -0
  92. package/src/agent-src/templates/scripts/work_engine/hooks/context.ts +94 -0
  93. package/src/agent-src/templates/scripts/work_engine/hooks/events.ts +58 -0
  94. package/src/agent-src/templates/scripts/work_engine/hooks/exceptions.ts +85 -0
  95. package/src/agent-src/templates/scripts/work_engine/hooks/index.ts +27 -0
  96. package/src/agent-src/templates/scripts/work_engine/hooks/registry.ts +66 -0
  97. package/src/agent-src/templates/scripts/work_engine/hooks/runner.ts +90 -0
  98. package/src/agent-src/templates/scripts/work_engine/hooks/settings.ts +260 -0
  99. package/src/agent-src/templates/scripts/work_engine/input_builders.ts +260 -0
  100. package/src/agent-src/templates/scripts/work_engine/intent/classify.ts +466 -0
  101. package/src/agent-src/templates/scripts/work_engine/migration/v0_to_v1.ts +531 -0
  102. package/src/agent-src/templates/scripts/work_engine/orchestration.ts +366 -0
  103. package/src/agent-src/templates/scripts/work_engine/persona_policy.ts +97 -0
  104. package/src/agent-src/templates/scripts/work_engine/resolvers/diff.ts +135 -0
  105. package/src/agent-src/templates/scripts/work_engine/resolvers/file.ts +175 -0
  106. package/src/agent-src/templates/scripts/work_engine/resolvers/prompt.ts +115 -0
  107. package/src/agent-src/templates/scripts/work_engine/scoring/confidence.ts +415 -0
  108. package/src/agent-src/templates/scripts/work_engine/scoring/decision_engine.ts +466 -0
  109. package/src/agent-src/templates/scripts/work_engine/scoring/decision_trace.ts +298 -0
  110. package/src/agent-src/templates/scripts/work_engine/scoring/memory_visibility.ts +444 -0
  111. package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +252 -0
  112. package/src/agent-src/templates/scripts/work_engine/stack/runner.ts +745 -0
  113. package/src/agent-src/templates/scripts/work_engine/state.ts +1151 -0
  114. package/src/agent-src/templates/scripts/work_engine/state_io.ts +413 -0
  115. package/src/install/atomic.ts +111 -0
  116. package/src/install/conflict.ts +226 -0
  117. package/src/install/detect.ts +280 -0
  118. package/src/install/paths.ts +90 -0
  119. package/src/install/plan.ts +205 -0
  120. package/src/install/preflight.ts +210 -0
  121. package/src/install/selectedTools.ts +52 -0
  122. package/src/install/toolDetection.ts +120 -0
  123. package/src/install/txlog.ts +161 -0
  124. package/src/install/types.ts +175 -0
  125. package/src/install/wizard-plan.ts +216 -0
  126. package/src/scripts/prepack-check.mjs +108 -5
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Boundary detection + concurrent-safe recording (Phase 2).
3
+ *
4
+ * TypeScript twin of `boundary.py` (ADR-200). Byte-for-byte parity on the
5
+ * written JSONL and on the `BoundarySession` coalescing semantics.
6
+ *
7
+ * 1. `BoundarySession` — in-process coalescing. Multiple `add_*` calls
8
+ * within one boundary merge into a single emitted event (set-union on
9
+ * `consulted` / `applied`). Idempotent flush.
10
+ * 2. `record_event` — durable append. The Python original takes a POSIX
11
+ * `fcntl.flock` when available and falls back to a best-effort append
12
+ * otherwise (`_HAS_FCNTL = False`). Node has no portable advisory lock
13
+ * in stdlib, so this twin is the documented best-effort-append branch:
14
+ * the bytes written are identical; only the cross-process interleave
15
+ * guarantee differs (the package ships on POSIX CI, single-writer in
16
+ * tests, so output parity is unaffected).
17
+ */
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+
21
+ import {
22
+ ALLOWED_BOUNDARY_KINDS,
23
+ ALLOWED_KINDS,
24
+ EngagementEvent,
25
+ EngagementSchemaError,
26
+ now_utc_iso,
27
+ } from './engagement.js';
28
+
29
+ function _reprTuple(items: readonly string[]): string {
30
+ const inner = items.map((s) => `'${s}'`).join(', ');
31
+ return items.length === 1 ? `(${inner},)` : `(${inner})`;
32
+ }
33
+
34
+ function _reprScalar(v: unknown): string {
35
+ if (typeof v === 'string') {
36
+ return `'${v}'`;
37
+ }
38
+ return String(v);
39
+ }
40
+
41
+ /** Python `sorted()` on a list of str — lexicographic by code unit. */
42
+ function _sortedStrs(arr: Iterable<string>): string[] {
43
+ return [...arr].sort((a, b) => (a < b ? -1 : (a > b ? 1 : 0)));
44
+ }
45
+
46
+ export class BoundarySession {
47
+ task_id: string;
48
+ boundary_kind: string;
49
+ log_path: string;
50
+ consulted: Map<string, Set<string>>;
51
+ applied: Map<string, Set<string>>;
52
+ private _flushed = false;
53
+ private _has_data = false;
54
+
55
+ constructor(init: { task_id: string; boundary_kind: string; log_path: string }) {
56
+ this.task_id = init.task_id;
57
+ this.boundary_kind = init.boundary_kind;
58
+ this.log_path = init.log_path;
59
+ this.consulted = new Map();
60
+ this.applied = new Map();
61
+ // __post_init__
62
+ if (!(ALLOWED_BOUNDARY_KINDS as readonly string[]).includes(this.boundary_kind)) {
63
+ throw new EngagementSchemaError(
64
+ `boundary_kind must be one of ${_reprTuple(ALLOWED_BOUNDARY_KINDS)}`,
65
+ );
66
+ }
67
+ if (typeof this.task_id !== 'string' || !this.task_id) {
68
+ throw new EngagementSchemaError('task_id must be a non-empty string');
69
+ }
70
+ }
71
+
72
+ add_consulted(kind: string, ids: Iterable<string>): void {
73
+ this._merge(this.consulted, kind, ids);
74
+ }
75
+
76
+ add_applied(kind: string, ids: Iterable<string>): void {
77
+ this._merge(this.applied, kind, ids);
78
+ }
79
+
80
+ private _merge(bucket: Map<string, Set<string>>, kind: string, ids: Iterable<string>): void {
81
+ if (!(ALLOWED_KINDS as readonly string[]).includes(kind)) {
82
+ throw new EngagementSchemaError(
83
+ `${_reprScalar(kind)} is not an allowed artefact kind `
84
+ + `(allowed: ${_reprTuple(ALLOWED_KINDS)})`,
85
+ );
86
+ }
87
+ let target = bucket.get(kind);
88
+ if (target === undefined) {
89
+ target = new Set<string>();
90
+ bucket.set(kind, target);
91
+ }
92
+ for (const art_id of ids) {
93
+ if (typeof art_id !== 'string' || !art_id) {
94
+ throw new EngagementSchemaError(`${kind} ids must be non-empty strings`);
95
+ }
96
+ target.add(art_id);
97
+ this._has_data = true;
98
+ }
99
+ }
100
+
101
+ to_event(): EngagementEvent {
102
+ const consulted: Record<string, string[]> = {};
103
+ for (const [k, v] of this.consulted) {
104
+ if (v.size > 0) {
105
+ consulted[k] = _sortedStrs(v);
106
+ }
107
+ }
108
+ const applied: Record<string, string[]> = {};
109
+ for (const [k, v] of this.applied) {
110
+ if (v.size > 0) {
111
+ applied[k] = _sortedStrs(v);
112
+ }
113
+ }
114
+ return new EngagementEvent({
115
+ ts: now_utc_iso(),
116
+ task_id: this.task_id,
117
+ boundary_kind: this.boundary_kind,
118
+ consulted,
119
+ applied,
120
+ });
121
+ }
122
+
123
+ /** Write one merged event to the log. Returns true if written. */
124
+ flush(): boolean {
125
+ if (this._flushed || !this._has_data) {
126
+ return false;
127
+ }
128
+ record_event(this.log_path, this.to_event());
129
+ this._flushed = true;
130
+ return true;
131
+ }
132
+ }
133
+
134
+ /** Append one event. See module docstring for the lock-vs-bytes contract. */
135
+ export function record_event(log_path: string, event: EngagementEvent): void {
136
+ event.validate();
137
+ const payload = event.to_jsonl();
138
+ fs.mkdirSync(path.dirname(log_path) || '.', { recursive: true });
139
+ // O_WRONLY | O_CREAT | O_APPEND, 0o644 — best-effort append (no fcntl).
140
+ const fd = fs.openSync(log_path, 'a', 0o644);
141
+ try {
142
+ fs.writeSync(fd, Buffer.from(payload, 'utf-8'));
143
+ fs.fsyncSync(fd);
144
+ } finally {
145
+ fs.closeSync(fd);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Convenience helper around `BoundarySession`. The Python `open_boundary`
151
+ * is a context manager that flushes on clean exit and suppresses on
152
+ * exception. The TS analogue takes a body callback for the same shape.
153
+ */
154
+ export function open_boundary<T>(
155
+ task_id: string,
156
+ boundary_kind: string,
157
+ log_path: string,
158
+ body: (session: BoundarySession) => T,
159
+ ): T {
160
+ const session = new BoundarySession({ task_id, boundary_kind, log_path });
161
+ const result = body(session);
162
+ session.flush();
163
+ return result;
164
+ }
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Engagement event schema and JSONL appender (Phase 1).
3
+ *
4
+ * TypeScript twin of `engagement.py` (ADR-200). Byte-for-byte parity:
5
+ * same schema, same validation messages, same JSONL serialization
6
+ * (`json.dumps(sort_keys=True, separators=(",",":"))`), same redaction
7
+ * floor, same `EngagementSchemaError` semantics.
8
+ *
9
+ * Stdlib-only on the Python side. The contract:
10
+ *
11
+ * {
12
+ * "schema_version": 1,
13
+ * "ts": "<ISO-8601 UTC>",
14
+ * "task_id": "<repo-internal id>",
15
+ * "boundary_kind": "task" | "phase-step" | "tool-call",
16
+ * "consulted": {"skills": [...], "rules": [...], ...},
17
+ * "applied": {"skills": [...], "rules": [...], ...},
18
+ * "outcomes": ["blocked", "verification_failed", ...] # optional
19
+ * "tokens_estimate": {"consulted_load": <int>} # optional
20
+ * }
21
+ */
22
+ import * as fs from 'node:fs';
23
+ import * as path from 'node:path';
24
+
25
+ export const SCHEMA_VERSION = 1;
26
+ export const MAX_ID_LEN = 200;
27
+
28
+ export const ALLOWED_KINDS = [
29
+ 'skills',
30
+ 'rules',
31
+ 'commands',
32
+ 'guidelines',
33
+ 'personas',
34
+ ] as const;
35
+
36
+ export const ALLOWED_BOUNDARY_KINDS = [
37
+ 'task',
38
+ 'phase-step',
39
+ 'tool-call',
40
+ ] as const;
41
+
42
+ // Outcome categories — see module docstring for semantics. The set is
43
+ // intentionally small; widening requires an explicit follow-up roadmap
44
+ // step. Reports group by these labels, so renaming is breaking.
45
+ export const ALLOWED_OUTCOMES = [
46
+ 'blocked',
47
+ 'partial',
48
+ 'memory_influenced_decision',
49
+ 'verification_failed',
50
+ 'stop_rule_triggered',
51
+ ] as const;
52
+ export const MAX_OUTCOMES_PER_EVENT = ALLOWED_OUTCOMES.length;
53
+
54
+ // Phase 5 redaction validator — keep id fields from leaking paths,
55
+ // free-text, or filenames.
56
+ const _FORBIDDEN_ID_CHARS = ['/', '\\', '\n', '\r', '\t'] as const;
57
+ // Trailing alphabetic extension (`.md`, `.py`, `.json`, …). 1-8 alpha chars.
58
+ const _FILE_EXTENSION_RE = /\.[A-Za-z]{1,8}$/u;
59
+
60
+ export class EngagementSchemaError extends Error {
61
+ constructor(message: string) {
62
+ super(message);
63
+ this.name = 'EngagementSchemaError';
64
+ }
65
+ }
66
+
67
+ /** Mirror Python `repr()` for a single character — used in error text. */
68
+ function _reprChar(ch: string): string {
69
+ const map: Record<string, string> = {
70
+ '\n': "'\\n'",
71
+ '\r': "'\\r'",
72
+ '\t': "'\\t'",
73
+ '\\': "'\\\\'",
74
+ "'": '"\'"',
75
+ };
76
+ if (map[ch] !== undefined) {
77
+ return map[ch] as string;
78
+ }
79
+ return `'${ch}'`;
80
+ }
81
+
82
+ /** Mirror Python `repr()` of the ALLOWED tuples used in error text. */
83
+ function _reprTuple(items: readonly string[]): string {
84
+ const inner = items.map((s) => `'${s}'`).join(', ');
85
+ return items.length === 1 ? `(${inner},)` : `(${inner})`;
86
+ }
87
+
88
+ /**
89
+ * Phase 5 redaction validator — reject path- and free-text-shaped ids.
90
+ */
91
+ export function check_id_redaction(label: string, value: unknown): void {
92
+ if (typeof value !== 'string') {
93
+ throw new EngagementSchemaError(`${label} must be a string`);
94
+ }
95
+ if (!value) {
96
+ throw new EngagementSchemaError(`${label} must be non-empty`);
97
+ }
98
+ if (_pyLen(value) > MAX_ID_LEN) {
99
+ throw new EngagementSchemaError(`${label} exceeds ${MAX_ID_LEN} chars`);
100
+ }
101
+ for (const ch of _FORBIDDEN_ID_CHARS) {
102
+ if (value.includes(ch)) {
103
+ throw new EngagementSchemaError(
104
+ `${label} contains forbidden character ${_reprChar(ch)}; `
105
+ + 'id fields must be repository-internal artefact ids only '
106
+ + '(no paths, no free-text)',
107
+ );
108
+ }
109
+ }
110
+ if (value !== _pyStrip(value)) {
111
+ throw new EngagementSchemaError(
112
+ `${label} must not start or end with whitespace`,
113
+ );
114
+ }
115
+ if (_FILE_EXTENSION_RE.test(value)) {
116
+ throw new EngagementSchemaError(
117
+ `${label} ends in a file extension; `
118
+ + 'id fields must be repository-internal artefact ids only '
119
+ + '(strip path + extension before recording)',
120
+ );
121
+ }
122
+ }
123
+
124
+ export interface EngagementEventInit {
125
+ ts: string;
126
+ task_id: string;
127
+ boundary_kind: string;
128
+ consulted?: Record<string, string[]>;
129
+ applied?: Record<string, string[]>;
130
+ outcomes?: string[] | null;
131
+ tokens_estimate?: Record<string, number> | null;
132
+ schema_version?: number;
133
+ }
134
+
135
+ export class EngagementEvent {
136
+ ts: string;
137
+ task_id: string;
138
+ boundary_kind: string;
139
+ consulted: Record<string, string[]>;
140
+ applied: Record<string, string[]>;
141
+ outcomes: string[] | null;
142
+ tokens_estimate: Record<string, number> | null;
143
+ schema_version: number;
144
+
145
+ constructor(init: EngagementEventInit) {
146
+ this.ts = init.ts;
147
+ this.task_id = init.task_id;
148
+ this.boundary_kind = init.boundary_kind;
149
+ this.consulted = init.consulted ?? {};
150
+ this.applied = init.applied ?? {};
151
+ this.outcomes = init.outcomes ?? null;
152
+ this.tokens_estimate = init.tokens_estimate ?? null;
153
+ this.schema_version = init.schema_version ?? SCHEMA_VERSION;
154
+ }
155
+
156
+ validate(): void {
157
+ if (typeof this.ts !== 'string' || !this.ts) {
158
+ throw new EngagementSchemaError('ts must be a non-empty string');
159
+ }
160
+ if (typeof this.task_id !== 'string' || !this.task_id) {
161
+ throw new EngagementSchemaError('task_id must be a non-empty string');
162
+ }
163
+ if (_pyLen(this.task_id) > MAX_ID_LEN) {
164
+ throw new EngagementSchemaError(`task_id exceeds ${MAX_ID_LEN} chars`);
165
+ }
166
+ check_id_redaction('task_id', this.task_id);
167
+ if (!(ALLOWED_BOUNDARY_KINDS as readonly string[]).includes(this.boundary_kind)) {
168
+ throw new EngagementSchemaError(
169
+ `boundary_kind must be one of ${_reprTuple(ALLOWED_BOUNDARY_KINDS)}`,
170
+ );
171
+ }
172
+ _validate_artefact_dict('consulted', this.consulted);
173
+ _validate_artefact_dict('applied', this.applied);
174
+ if (this.outcomes !== null) {
175
+ _validate_outcomes(this.outcomes);
176
+ }
177
+ if (this.tokens_estimate !== null) {
178
+ if (!_isPlainObject(this.tokens_estimate)) {
179
+ throw new EngagementSchemaError(
180
+ 'tokens_estimate must be a dict[str,int] or None',
181
+ );
182
+ }
183
+ for (const [k, v] of Object.entries(this.tokens_estimate)) {
184
+ if (typeof k !== 'string' || !_isPyInt(v)) {
185
+ throw new EngagementSchemaError(
186
+ 'tokens_estimate keys must be str, values int',
187
+ );
188
+ }
189
+ }
190
+ }
191
+ if (this.schema_version !== SCHEMA_VERSION) {
192
+ throw new EngagementSchemaError(
193
+ `schema_version must be ${SCHEMA_VERSION}, got `
194
+ + `${_reprScalar(this.schema_version)}`,
195
+ );
196
+ }
197
+ }
198
+
199
+ to_dict(): Record<string, unknown> {
200
+ this.validate();
201
+ const out: Record<string, unknown> = {
202
+ schema_version: this.schema_version,
203
+ ts: this.ts,
204
+ task_id: this.task_id,
205
+ boundary_kind: this.boundary_kind,
206
+ consulted: _normalise_artefact_dict(this.consulted),
207
+ applied: _normalise_artefact_dict(this.applied),
208
+ };
209
+ if (this.outcomes && this.outcomes.length > 0) {
210
+ out['outcomes'] = [...this.outcomes];
211
+ }
212
+ if (this.tokens_estimate && Object.keys(this.tokens_estimate).length > 0) {
213
+ out['tokens_estimate'] = { ...this.tokens_estimate };
214
+ }
215
+ return out;
216
+ }
217
+
218
+ to_jsonl(): string {
219
+ return `${py_json_dumps_compact_sorted(this.to_dict())}\n`;
220
+ }
221
+ }
222
+
223
+ function _validate_artefact_dict(label: string, payload: unknown): void {
224
+ if (!_isPlainObject(payload)) {
225
+ throw new EngagementSchemaError(`${label} must be a dict[str,list[str]]`);
226
+ }
227
+ for (const [kind, ids] of Object.entries(payload)) {
228
+ if (!(ALLOWED_KINDS as readonly string[]).includes(kind)) {
229
+ throw new EngagementSchemaError(
230
+ `${label}.${_reprScalar(kind)} is not an allowed artefact kind `
231
+ + `(allowed: ${_reprTuple(ALLOWED_KINDS)})`,
232
+ );
233
+ }
234
+ if (!Array.isArray(ids)) {
235
+ throw new EngagementSchemaError(`${label}.${kind} must be a list of str`);
236
+ }
237
+ for (const art_id of ids) {
238
+ if (typeof art_id !== 'string' || !art_id) {
239
+ throw new EngagementSchemaError(
240
+ `${label}.${kind} must contain non-empty str ids`,
241
+ );
242
+ }
243
+ if (_pyLen(art_id) > MAX_ID_LEN) {
244
+ throw new EngagementSchemaError(
245
+ `${label}.${kind} id exceeds ${MAX_ID_LEN} chars`,
246
+ );
247
+ }
248
+ check_id_redaction(`${label}.${kind}`, art_id);
249
+ }
250
+ }
251
+ }
252
+
253
+ function _normalise_artefact_dict(
254
+ payload: Record<string, string[]>,
255
+ ): Record<string, string[]> {
256
+ // Stable shape: only non-empty kinds, ids preserved in declared order.
257
+ const out: Record<string, string[]> = {};
258
+ for (const kind of ALLOWED_KINDS) {
259
+ const v = payload[kind];
260
+ if (v && v.length > 0) {
261
+ out[kind] = [...v];
262
+ }
263
+ }
264
+ return out;
265
+ }
266
+
267
+ function _validate_outcomes(payload: unknown): void {
268
+ if (!Array.isArray(payload)) {
269
+ throw new EngagementSchemaError('outcomes must be a list of str or None');
270
+ }
271
+ if (payload.length > MAX_OUTCOMES_PER_EVENT) {
272
+ throw new EngagementSchemaError(
273
+ `outcomes exceeds ${MAX_OUTCOMES_PER_EVENT} entries`,
274
+ );
275
+ }
276
+ const seen = new Set<string>();
277
+ for (const label of payload) {
278
+ if (typeof label !== 'string' || !label) {
279
+ throw new EngagementSchemaError('outcomes must contain non-empty str labels');
280
+ }
281
+ if (!(ALLOWED_OUTCOMES as readonly string[]).includes(label)) {
282
+ throw new EngagementSchemaError(
283
+ `outcomes contains ${_reprScalar(label)}; allowed: ${_reprTuple(ALLOWED_OUTCOMES)}`,
284
+ );
285
+ }
286
+ if (seen.has(label)) {
287
+ throw new EngagementSchemaError(`outcomes contains duplicate ${_reprScalar(label)}`);
288
+ }
289
+ seen.add(label);
290
+ }
291
+ }
292
+
293
+ export function parse_event(line: string): EngagementEvent {
294
+ if (typeof line !== 'string' || !line.trim()) {
295
+ throw new EngagementSchemaError('line must be a non-empty JSONL record');
296
+ }
297
+ let raw: unknown;
298
+ try {
299
+ raw = JSON.parse(line);
300
+ } catch (exc) {
301
+ throw new EngagementSchemaError(`line is not valid JSON: ${_jsonErr(exc)}`);
302
+ }
303
+ if (!_isPlainObject(raw)) {
304
+ throw new EngagementSchemaError('event must be a JSON object');
305
+ }
306
+ const r = raw;
307
+ const event = new EngagementEvent({
308
+ ts: (r['ts'] as string) ?? '',
309
+ task_id: (r['task_id'] as string) ?? '',
310
+ boundary_kind: (r['boundary_kind'] as string) ?? '',
311
+ consulted: _orEmptyDict(r['consulted']),
312
+ applied: _orEmptyDict(r['applied']),
313
+ outcomes: (r['outcomes'] as string[] | null | undefined) ?? null,
314
+ tokens_estimate: (r['tokens_estimate'] as Record<string, number> | null | undefined) ?? null,
315
+ schema_version: r['schema_version'] === undefined
316
+ ? SCHEMA_VERSION
317
+ : (r['schema_version'] as number),
318
+ });
319
+ event.validate();
320
+ return event;
321
+ }
322
+
323
+ /** Mirror Python `raw.get(k, {}) or {}` — falsy → {}. */
324
+ function _orEmptyDict(v: unknown): Record<string, string[]> {
325
+ if (v && _isPlainObject(v)) {
326
+ return v as Record<string, string[]>;
327
+ }
328
+ return {};
329
+ }
330
+
331
+ export function append_event(log_path: string, event: EngagementEvent): void {
332
+ const payload = event.to_jsonl();
333
+ fs.mkdirSync(path.dirname(log_path) || '.', { recursive: true });
334
+ fs.appendFileSync(log_path, payload, { encoding: 'utf-8' });
335
+ }
336
+
337
+ /** ISO-8601 UTC timestamp, second precision, `Z` suffix. */
338
+ export function now_utc_iso(): string {
339
+ const d = new Date();
340
+ const p = (n: number, w = 2): string => String(n).padStart(w, '0');
341
+ return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`
342
+ + `T${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}Z`;
343
+ }
344
+
345
+ // ── Python-parity helpers ───────────────────────────────────────────────
346
+
347
+ /** Python `len(str)` — code-point count, not UTF-16 unit count. */
348
+ export function _pyLen(s: string): number {
349
+ let count = 0;
350
+
351
+ for (const _ of s) {
352
+ count += 1;
353
+ }
354
+ return count;
355
+ }
356
+
357
+ /** Python `str.strip()` — strip ASCII + Unicode whitespace from both ends. */
358
+ function _pyStrip(s: string): string {
359
+ // Python str.strip() with no arg strips a fixed whitespace set. JS \s in a
360
+ // Unicode regex covers the same set used by the id contract (space, tab,
361
+ // CR, LF, FF, VT, and Unicode spaces). The redaction floor only needs the
362
+ // ASCII subset in practice.
363
+ return s.replace(/^\s+/u, '').replace(/\s+$/u, '');
364
+ }
365
+
366
+ function _isPlainObject(v: unknown): v is Record<string, unknown> {
367
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
368
+ }
369
+
370
+ /** Python `isinstance(v, int)` — JSON ints arrive as integer-valued numbers. */
371
+ function _isPyInt(v: unknown): v is number {
372
+ return typeof v === 'number' && Number.isInteger(v);
373
+ }
374
+
375
+ /** Mirror Python `repr()` for a scalar used in `{x!r}` error text. */
376
+ function _reprScalar(v: unknown): string {
377
+ if (typeof v === 'string') {
378
+ return `'${v}'`;
379
+ }
380
+ if (v === null) {
381
+ return 'None';
382
+ }
383
+ if (typeof v === 'boolean') {
384
+ return v ? 'True' : 'False';
385
+ }
386
+ return String(v);
387
+ }
388
+
389
+ function _jsonErr(exc: unknown): string {
390
+ return exc instanceof Error ? exc.message : String(exc);
391
+ }
392
+
393
+ // ── Python-parity JSON (compact, sorted) ────────────────────────────────
394
+
395
+ /**
396
+ * Mirror `json.dumps(obj, sort_keys=True, separators=(",", ":"))`.
397
+ *
398
+ * ensure_ascii defaults to True → non-ASCII escaped as `\uXXXX`. Integers
399
+ * render without a decimal point; the schema only ever serializes ints,
400
+ * strings, lists, and dicts here.
401
+ */
402
+ export function py_json_dumps_compact_sorted(value: unknown): string {
403
+ return _dumpCompact(value);
404
+ }
405
+
406
+ function _dumpCompact(value: unknown): string {
407
+ if (value === null || value === undefined) {
408
+ return 'null';
409
+ }
410
+ if (typeof value === 'boolean') {
411
+ return value ? 'true' : 'false';
412
+ }
413
+ if (typeof value === 'number') {
414
+ return _pyJsonNumber(value);
415
+ }
416
+ if (typeof value === 'string') {
417
+ return _pyJsonStringAscii(value);
418
+ }
419
+ if (Array.isArray(value)) {
420
+ return `[${value.map((v) => _dumpCompact(v)).join(',')}]`;
421
+ }
422
+ const obj = value as Record<string, unknown>;
423
+ const keys = Object.keys(obj).sort((a, b) => (a < b ? -1 : (a > b ? 1 : 0)));
424
+ const parts = keys.map((k) => `${_pyJsonStringAscii(k)}:${_dumpCompact(obj[k])}`);
425
+ return `{${parts.join(',')}}`;
426
+ }
427
+
428
+ /** json.dumps number rendering for the int-only payloads used here. */
429
+ function _pyJsonNumber(n: number): string {
430
+ if (Number.isInteger(n)) {
431
+ return String(n);
432
+ }
433
+ return _pyFloatRepr(n);
434
+ }
435
+
436
+ /** Python repr(float) — used only if a non-int ever reaches the serializer. */
437
+ export function _pyFloatRepr(n: number): string {
438
+ if (Number.isInteger(n)) {
439
+ return `${n}.0`;
440
+ }
441
+ return String(n);
442
+ }
443
+
444
+ /** json.dumps default ensure_ascii=True string rendering (escape >= 0x7F). */
445
+ export function _pyJsonStringAscii(s: string): string {
446
+ let out = '"';
447
+ for (const ch of s) {
448
+ const code = ch.codePointAt(0) as number;
449
+ if (ch === '"') {
450
+ out += '\\"';
451
+ } else if (ch === '\\') {
452
+ out += '\\\\';
453
+ } else if (ch === '\n') {
454
+ out += '\\n';
455
+ } else if (ch === '\r') {
456
+ out += '\\r';
457
+ } else if (ch === '\t') {
458
+ out += '\\t';
459
+ } else if (ch === '\b') {
460
+ out += '\\b';
461
+ } else if (ch === '\f') {
462
+ out += '\\f';
463
+ } else if (code < 0x20) {
464
+ out += `\\u${code.toString(16).padStart(4, '0')}`;
465
+ } else if (code < 0x7f) {
466
+ out += ch;
467
+ } else if (code <= 0xffff) {
468
+ out += `\\u${code.toString(16).padStart(4, '0')}`;
469
+ } else {
470
+ // Surrogate pair, matching CPython's \uXXXX\uXXXX rendering.
471
+ const c = code - 0x10000;
472
+ const hi = 0xd800 + (c >> 10);
473
+ const lo = 0xdc00 + (c & 0x3ff);
474
+ out += `\\u${hi.toString(16).padStart(4, '0')}`;
475
+ out += `\\u${lo.toString(16).padStart(4, '0')}`;
476
+ }
477
+ }
478
+ return `${out}"`;
479
+ }