kushi-agents 5.0.4 → 5.2.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 (43) hide show
  1. package/README.md +78 -0
  2. package/bin/cli.mjs +201 -1
  3. package/package.json +2 -2
  4. package/plugin/agents/kushi.agent.md +6 -2
  5. package/plugin/instructions/hooks.instructions.md +84 -0
  6. package/plugin/instructions/living-wiki.instructions.md +88 -0
  7. package/plugin/instructions/log-format.instructions.md +78 -0
  8. package/plugin/instructions/otel.instructions.md +75 -0
  9. package/plugin/instructions/parallel-execution.instructions.md +81 -0
  10. package/plugin/instructions/schema-evolve.instructions.md +73 -0
  11. package/plugin/instructions/wiki-lint.instructions.md +110 -0
  12. package/plugin/skills/_shared/Append-StateLog.ps1 +73 -0
  13. package/plugin/skills/_shared/Emit-OtelSpan.ps1 +111 -0
  14. package/plugin/skills/_shared/Invoke-Hooks.ps1 +177 -0
  15. package/plugin/skills/_shared/Update-StateIndex.ps1 +47 -0
  16. package/plugin/skills/_shared/hook-templates/console-debug.ps1 +15 -0
  17. package/plugin/skills/_shared/hook-templates/teams-notify.ps1 +47 -0
  18. package/plugin/skills/ask-project/SKILL.md +30 -0
  19. package/plugin/skills/build-state/SKILL.md +18 -2
  20. package/plugin/skills/lint-state/.created-by-skill-creator +0 -0
  21. package/plugin/skills/lint-state/SKILL.md +98 -0
  22. package/plugin/skills/lint-state/evals/evals.json +34 -0
  23. package/plugin/skills/lint-state/lint.ps1 +218 -0
  24. package/plugin/skills/refresh-project/SKILL.md +8 -4
  25. package/plugin/skills/schema-evolve/.created-by-skill-creator +0 -0
  26. package/plugin/skills/schema-evolve/SKILL.md +106 -0
  27. package/plugin/skills/schema-evolve/evals/evals.json +37 -0
  28. package/plugin/skills/self-check/SKILL.md +12 -55
  29. package/plugin/skills/self-check/references/algorithm.md +55 -0
  30. package/plugin/skills/self-check/run.ps1 +225 -3
  31. package/plugin/skills/skill-checker/check-skill.ps1 +1 -1
  32. package/plugin/skills/teach/.created-by-skill-creator +0 -0
  33. package/plugin/skills/teach/SKILL.md +77 -0
  34. package/plugin/skills/teach/evals/evals.json +37 -0
  35. package/plugin/templates/state/answers.README.md +7 -0
  36. package/plugin/templates/state/hot.template.md +12 -0
  37. package/plugin/templates/state/review-queue.template.md +10 -0
  38. package/src/eval-runner.test.mjs +1 -1
  39. package/src/hooks-dispatcher.test.mjs +135 -0
  40. package/src/otel-emit.test.mjs +73 -0
  41. package/src/parallel-refresh.test.mjs +50 -0
  42. package/src/schema-evolve.test.mjs +78 -0
  43. package/src/teach.test.mjs +45 -0
@@ -0,0 +1,75 @@
1
+ # OpenTelemetry export
2
+
3
+ > Doctrine: `otel.instructions.md` — kushi v5.2.0+
4
+
5
+ ## Purpose
6
+
7
+ Opt-in observability for kushi pipelines via OpenTelemetry Protocol (OTLP). Enables enterprise teams to trace kushi runs in their existing observability stack (Jaeger, Grafana Tempo, Azure Monitor, Datadog, etc.).
8
+
9
+ ## Opt-in activation
10
+
11
+ Set the environment variable:
12
+
13
+ ```
14
+ KUSHI_OTEL_ENDPOINT=http://localhost:4318/v1/traces
15
+ ```
16
+
17
+ When unset or empty, all OTel helpers are **no-ops** with zero overhead (no HTTP calls, no object allocation beyond the guard check).
18
+
19
+ ## Events emitted
20
+
21
+ | Span name | Fires during | Attributes |
22
+ |-----------|-------------|------------|
23
+ | `kushi.pull` | Each `pull-*` source | `project`, `alias`, `source`, `duration_ms`, `success`, `evidence_count` |
24
+ | `kushi.build_state` | `build-state` | `project`, `alias`, `pages_written`, `contradictions_flagged`, `duration_ms`, `success` |
25
+ | `kushi.lint` | `lint-state` | `project`, `alias`, `findings_count`, `duration_ms`, `success` |
26
+ | `kushi.contradiction.flagged` | Inside `build-state` when a new contradiction is detected | `project`, `alias`, `entity`, `field`, `source` |
27
+ | `kushi.hook.invoked` | Each hook invocation | `project`, `alias`, `event`, `hook_type`, `target`, `duration_ms`, `success` |
28
+
29
+ ## Wire format
30
+
31
+ Uses the [OTLP/HTTP JSON](https://opentelemetry.io/docs/specs/otlp/#otlphttp) format:
32
+ - Endpoint: `$KUSHI_OTEL_ENDPOINT` (must include path, e.g. `/v1/traces`)
33
+ - Method: POST
34
+ - Content-Type: `application/json`
35
+ - No external dependencies — pure PowerShell via `Invoke-RestMethod`
36
+
37
+ ## Privacy contract
38
+
39
+ **CRITICAL**: OTel spans carry ONLY:
40
+ - Operation metadata (names, counts, durations, success/failure)
41
+ - Project/alias identifiers
42
+
43
+ OTel spans NEVER carry:
44
+ - Evidence content (email bodies, meeting transcripts, etc.)
45
+ - PII (user emails, file paths with usernames beyond alias)
46
+ - Authentication tokens or secrets
47
+
48
+ This is enforced structurally: `Emit-OtelSpan.ps1` accepts only a fixed set of attribute keys.
49
+
50
+ ## Implementation
51
+
52
+ The shared helper `plugin/skills/_shared/Emit-OtelSpan.ps1`:
53
+ 1. Checks `$env:KUSHI_OTEL_ENDPOINT` — returns immediately if unset/empty.
54
+ 2. Constructs a minimal OTLP JSON payload with a single span.
55
+ 3. POSTs via `Invoke-RestMethod` with a 5s timeout.
56
+ 4. On failure: logs a warning to stderr, never throws, never blocks the pipeline.
57
+
58
+ ## Service identity
59
+
60
+ ```json
61
+ {
62
+ "resource": {
63
+ "attributes": [
64
+ { "key": "service.name", "value": { "stringValue": "kushi" } },
65
+ { "key": "service.version", "value": { "stringValue": "<package.json version>" } }
66
+ ]
67
+ }
68
+ }
69
+ ```
70
+
71
+ ## References
72
+
73
+ - `Emit-OtelSpan.ps1` — shared helper (`plugin/skills/_shared/Emit-OtelSpan.ps1`)
74
+ - `hooks.instructions.md` — hook invocations emit `kushi.hook.invoked` spans
75
+ - `parallel-execution.instructions.md` — parallel pulls emit one span per worker
@@ -0,0 +1,81 @@
1
+ # Parallel execution
2
+
3
+ > Doctrine: `parallel-execution.instructions.md` — kushi v5.2.0+
4
+
5
+ ## Purpose
6
+
7
+ Defines when and how kushi parallelizes work. The primary use case is parallel `pull-*` dispatch during `refresh-project`, but the patterns apply to any future parallel workload.
8
+
9
+ ## When to parallelize
10
+
11
+ | Scenario | Parallel? | Why |
12
+ |----------|-----------|-----|
13
+ | Multiple `pull-*` in a refresh | ✅ Yes | Sources are independent; each writes to its own `Evidence/<alias>/<source>/` subtree |
14
+ | `build-state` page writes | ❌ No | Pages may reference each other; deterministic ordering required |
15
+ | `lint-state` finding checks | ✅ Yes (future) | Read-only checks are embarrassingly parallel |
16
+ | Hook dispatch | ❌ No | Sequential for predictable log ordering |
17
+
18
+ ## Configuration
19
+
20
+ In `.kushi/config.yml` (project-level) or `~/.kushi/config.yml` (user-level):
21
+
22
+ ```yaml
23
+ parallel:
24
+ max_workers: 4 # default; 1 = sequential
25
+ throttle_ms: 200 # minimum delay between worker starts (rate-limit courtesy)
26
+ ```
27
+
28
+ Override at runtime: `kushi refresh <project> --sequential` forces `max_workers: 1`.
29
+
30
+ ## Throttling considerations
31
+
32
+ M365 Graph (via WorkIQ) enforces per-user rate limits:
33
+ - **429 Too Many Requests** — back off exponentially.
34
+ - Kushi's `throttle_ms` provides a minimum inter-start delay (not a per-request throttle — individual pull-* skills handle their own retries via `auth-and-retry.instructions.md`).
35
+ - Default 200ms is conservative; increase for tenants with aggressive throttling.
36
+
37
+ ## Error aggregation
38
+
39
+ - Each worker runs independently. A failure in `pull-email` does NOT halt `pull-teams`.
40
+ - Worker results are collected into a `Map<source, {status, items, duration, error?}>`.
41
+ - After all workers complete, results are sorted in **canonical source order** (same order as sequential dispatch per `refresh-project/SKILL.md` Step 2).
42
+ - The run summary and run-log entries are written in canonical order regardless of completion order.
43
+
44
+ ## Deterministic output ordering
45
+
46
+ Despite parallel execution, all observable outputs (run-log entries, refresh report tables, State/log.md entries) are written in **canonical source order**:
47
+
48
+ 1. pull-email
49
+ 2. pull-teams
50
+ 3. pull-meetings
51
+ 4. pull-onenote
52
+ 5. pull-loop
53
+ 6. pull-sharepoint
54
+ 7. pull-crm
55
+ 8. pull-ado
56
+ 9. pull-misc
57
+
58
+ This ensures `git diff` is stable across runs and self-check D38 passes.
59
+
60
+ ## Implementation pattern (PowerShell)
61
+
62
+ ```powershell
63
+ # Worker pool using Start-ThreadJob (PowerShell 7+)
64
+ $maxWorkers = $config.parallel.max_workers ?? 4
65
+ $throttleMs = $config.parallel.throttle_ms ?? 200
66
+ $jobs = @()
67
+ foreach ($source in $enabledSources) {
68
+ while (($jobs | Where-Object { $_.State -eq 'Running' }).Count -ge $maxWorkers) {
69
+ Start-Sleep -Milliseconds 100
70
+ }
71
+ $jobs += Start-ThreadJob -ScriptBlock { param($s) & pull-$s ... } -ArgumentList $source
72
+ Start-Sleep -Milliseconds $throttleMs
73
+ }
74
+ $results = $jobs | Receive-Job -Wait -AutoRemoveJob
75
+ ```
76
+
77
+ ## References
78
+
79
+ - `refresh-project/SKILL.md` — orchestrator that dispatches parallel pulls
80
+ - `auth-and-retry.instructions.md` — per-request throttling within each worker
81
+ - `hooks.instructions.md` — hooks fire AFTER parallel aggregation completes
@@ -0,0 +1,73 @@
1
+ # Schema evolve
2
+
3
+ > Doctrine: `schema-evolve.instructions.md` — kushi v5.2.0+
4
+
5
+ ## Purpose
6
+
7
+ Allows users to teach kushi project-specific conventions that persist across runs. When a user says "from now on always do X for this project," kushi captures the rule and applies it in all future operations.
8
+
9
+ ## Storage location
10
+
11
+ Rules are stored in `Evidence/<alias>/State/CLAUDE.md` under the project's evidence tree.
12
+
13
+ **Decision rationale**: `CLAUDE.md` is the Karpathy-pattern file for agent-specific conventions (already present in the State layout since v5.0.0). Placing rules here means:
14
+ - They're versioned alongside evidence (git-tracked engagement roots get history).
15
+ - They're co-located with the State pages that apply them.
16
+ - They're readable by any agent (not kushi-specific format).
17
+
18
+ ## Rule format
19
+
20
+ Rules are appended to `CLAUDE.md` with structure:
21
+
22
+ ```markdown
23
+ ## Rule: <short-title>
24
+
25
+ - **Added**: 2026-05-29T14:30:00Z
26
+ - **Scope**: project | alias | global
27
+ - **Source**: user (natural language) | schema-evolve skill
28
+
29
+ <rule text as stated by the user>
30
+ ```
31
+
32
+ ## Scope levels
33
+
34
+ | Scope | Stored at | Applies to |
35
+ |-------|-----------|------------|
36
+ | `project` | `Evidence/<alias>/State/CLAUDE.md` | All operations on this project |
37
+ | `alias` | `Evidence/<alias>/State/CLAUDE.md` | Only this contributor's operations |
38
+ | `global` | `~/.kushi/conventions.md` | All projects (future — v5.3.0) |
39
+
40
+ v5.2.0 implements `project` scope only. `alias` and `global` are documented for forward compatibility.
41
+
42
+ ## How rules are applied
43
+
44
+ 1. At the start of `build-state`, `ask-project`, and `refresh-project`, the skill reads `Evidence/<alias>/State/CLAUDE.md`.
45
+ 2. Rules are parsed into a list and included as context for the operation.
46
+ 3. Rules affect preferences (formatting, naming, emphasis) but NEVER override hard doctrine (WorkIQ-only, verbatim-by-default, CSC format).
47
+
48
+ ## Conflict resolution
49
+
50
+ - Hard doctrine always wins over user rules.
51
+ - Later rules override earlier rules on the same topic.
52
+ - If a rule contradicts doctrine, it's logged as a warning but not applied.
53
+
54
+ ## CLI verb
55
+
56
+ `kushi remember <rule>` — captures a rule at project scope.
57
+
58
+ Auto-detection: when the user says "from now on...", "always...", "never...", "for this project..." in `ask-project`, the skill offers to persist it as a convention.
59
+
60
+ ## Examples
61
+
62
+ ```
63
+ > kushi remember "always use 'HCA' not 'Healthcare Accelerator' in summaries"
64
+ > kushi remember "treat John Smith as the primary EM for this project"
65
+ > kushi remember "CRM entity 'opportunity' maps to our internal term 'deal'"
66
+ ```
67
+
68
+ ## References
69
+
70
+ - `karpathy-state-layout.instructions.md` — CLAUDE.md file in State layout
71
+ - `living-wiki.instructions.md` — rules interact with incremental State maintenance
72
+ - `build-state` skill — reads rules at start of run
73
+ - `ask-project` skill — reads rules + offers to capture new ones
@@ -0,0 +1,110 @@
1
+ ---
2
+ name: "wiki-lint"
3
+ description: "v5.1.0 — Wiki lint finding classes for State/. Detects contradictions, stale claims, orphan pages, missing cross-refs, and data gaps. Each finding includes a ready-to-paste fix snippet. Adapted from sametbrr/llm-wiki-manager lint patterns. Authored to agentskills.io spec; deltas: kushi-specific finding classes (contradiction-flagged, stale-claim, orphan-page, missing-cross-ref, data-gap), fix-snippet convention."
4
+ applies_to: "lint-state skill"
5
+ since: "kushi v5.1.0"
6
+ ---
7
+
8
+ # wiki-lint — doctrine
9
+
10
+ > **Authored to [agentskills.io](https://agentskills.io/skill-creation/best-practices) spec.**
11
+ > Deltas from source (sametbrr/llm-wiki-manager): kushi-specific finding classes mapped to State/ layout, Obsidian-callout aware, fix-snippet per finding (not just a diagnostic), integration with `_review-queue.md`.
12
+
13
+ Adapted from [sametbrr/llm-wiki-manager](https://github.com/sametbrr/llm-wiki-manager) — wiki lint patterns.
14
+
15
+ ## Finding classes
16
+
17
+ ### 1. `contradiction-flagged`
18
+
19
+ **What:** Count unresolved `> [!warning] Contradicted` callouts in State/ pages.
20
+
21
+ **Detection:** Regex `^> \[!warning\] Contradicted` in any `State/**/*.md`.
22
+
23
+ **Severity:** warning (informational — contradictions are expected during active projects).
24
+
25
+ **Fix snippet:**
26
+ ```markdown
27
+ <!-- To resolve: review both values, pick the correct one, then either:
28
+ (a) Delete the callout pair and keep the winning value, OR
29
+ (b) Add <!-- kushi:human-override --> above the old value to prevent auto-resolve. -->
30
+ ```
31
+
32
+ ### 2. `stale-claim`
33
+
34
+ **What:** A claim in a State page is ≥ 60 days old (based on its `[source: ... · YYYY-MM-DD]` citation date) with no corroborating refresh since.
35
+
36
+ **Detection:** Parse inline citations `[source: ... · YYYY-MM-DD]`; flag if the newest citation for an entity property is > 60 days old AND no `build-state` or `refresh` log entry touched that entity since.
37
+
38
+ **Severity:** warning.
39
+
40
+ **Fix snippet:**
41
+ ```markdown
42
+ <!-- Stale claim (>60 days). Run '@Kushi refresh <project>' to pull fresh evidence,
43
+ then '@Kushi state <project>' to rebuild. If the claim is still valid, add a
44
+ corroborating citation from a recent source. -->
45
+ ```
46
+
47
+ ### 3. `orphan-page`
48
+
49
+ **What:** A page in `State/` (with `kushi_state_page: true`) that is NOT linked from `index.md` or any other State page.
50
+
51
+ **Detection:** Enumerate all `State/**/*.md` with `kushi_state_page: true`. For each, check if its path or `[[wikilink]]` slug appears in `index.md` or any sibling page body.
52
+
53
+ **Severity:** warning.
54
+
55
+ **Fix snippet:**
56
+ ```markdown
57
+ <!-- Orphan page: not linked from index.md or any other State page.
58
+ Fix: add a [[category/slug]] link to index.md under the correct category heading,
59
+ OR delete this page if it was generated in error. -->
60
+ ```
61
+
62
+ ### 4. `missing-cross-ref`
63
+
64
+ **What:** An entity mentioned in a page body (matching a known entity name from `Evidence/_graph/project-graph.json` nodes) but NOT listed in the page's `entity_ids` or `related` frontmatter arrays.
65
+
66
+ **Detection:** Load entity names from graph nodes. For each State page, scan body for entity-name matches not in frontmatter.
67
+
68
+ **Severity:** info.
69
+
70
+ **Fix snippet:**
71
+ ```markdown
72
+ <!-- Missing cross-ref: entity "<name>" mentioned in body but not in frontmatter.
73
+ Fix: add to `related:` array in front-matter:
74
+ related:
75
+ - category/entity-slug -->
76
+ ```
77
+
78
+ ### 5. `data-gap`
79
+
80
+ **What:** A required section header is present in a State page but its body (content between that header and the next header or EOF) is empty or contains only whitespace/placeholder text.
81
+
82
+ **Detection:** For each State page, parse `###` and `##` sections. Flag sections whose body is empty, contains only `<!-- TODO -->`, or is fewer than 2 non-blank lines.
83
+
84
+ **Severity:** info.
85
+
86
+ **Fix snippet:**
87
+ ```markdown
88
+ <!-- Data gap: section "<heading>" has no content.
89
+ Fix: run '@Kushi refresh <project>' to pull evidence that populates this section,
90
+ or remove the section header if it's not applicable to this entity. -->
91
+ ```
92
+
93
+ ## Output format
94
+
95
+ Each finding in the lint report:
96
+
97
+ ```markdown
98
+ ### <class>: <entity/page> — <short description>
99
+
100
+ **File:** `State/<path>`
101
+ **Line:** <N>
102
+ **Fix:**
103
+ <fix snippet>
104
+ ```
105
+
106
+ ## References
107
+
108
+ - [sametbrr/llm-wiki-manager](https://github.com/sametbrr/llm-wiki-manager)
109
+ - `living-wiki.instructions.md` (contradiction lifecycle)
110
+ - `karpathy-state-layout.instructions.md` (page convention)
@@ -0,0 +1,73 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Appends a canonical entry to State/log.md per log-format.instructions.md.
4
+
5
+ .DESCRIPTION
6
+ Idempotent helper called by every writer skill after completing a State/ write.
7
+ Prepends (reverse-chronological) a new entry after the file header.
8
+
9
+ .PARAMETER StateDir
10
+ Path to the State/ directory (e.g., Evidence/<alias>/State/).
11
+
12
+ .PARAMETER Op
13
+ Operation name from the closed taxonomy: bootstrap, refresh, build-state, lint-state,
14
+ ask-fileback, link-entities, dashboard, tour.
15
+
16
+ .PARAMETER Title
17
+ Short one-line title for the log entry.
18
+
19
+ .PARAMETER Summary
20
+ 1–3 line summary body.
21
+
22
+ .PARAMETER Sources
23
+ Comma-separated source pointers.
24
+ #>
25
+ [CmdletBinding()]
26
+ param(
27
+ [Parameter(Mandatory)][string]$StateDir,
28
+ [Parameter(Mandatory)][ValidateSet('bootstrap','refresh','build-state','lint-state','ask-fileback','link-entities','dashboard','tour')][string]$Op,
29
+ [Parameter(Mandatory)][string]$Title,
30
+ [string]$Summary = '',
31
+ [string]$Sources = ''
32
+ )
33
+
34
+ $ErrorActionPreference = 'Stop'
35
+
36
+ $logFile = Join-Path $StateDir 'log.md'
37
+ $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm'
38
+ $entry = "## [$timestamp] $Op | $Title`n"
39
+ if ($Summary) { $entry += "`n$Summary" }
40
+ if ($Sources) { $entry += "`nSources: $Sources" }
41
+ $entry += "`n"
42
+
43
+ if (-not (Test-Path $logFile)) {
44
+ # Create with header
45
+ $header = @"
46
+ ---
47
+ kushi_state_log: true
48
+ ---
49
+
50
+ # State Log
51
+
52
+ $entry
53
+ "@
54
+ New-Item -Path $logFile -ItemType File -Force | Out-Null
55
+ Set-Content -Path $logFile -Value $header -Encoding utf8NoBOM
56
+ } else {
57
+ $content = Get-Content -Raw $logFile
58
+ # Insert after the header block (after "# State Log" line or after front-matter)
59
+ if ($content -match '(?ms)(^---\r?\n.*?\r?\n---\r?\n\r?\n# [^\r\n]+\r?\n)(.*)$') {
60
+ $headerPart = $Matches[1]
61
+ $bodyPart = $Matches[2]
62
+ $newContent = $headerPart + "`n" + $entry + "`n" + $bodyPart
63
+ } elseif ($content -match '(?ms)(^# [^\r\n]+\r?\n)(.*)$') {
64
+ $headerPart = $Matches[1]
65
+ $bodyPart = $Matches[2]
66
+ $newContent = $headerPart + "`n" + $entry + "`n" + $bodyPart
67
+ } else {
68
+ $newContent = $entry + "`n" + $content
69
+ }
70
+ Set-Content -Path $logFile -Value $newContent -Encoding utf8NoBOM
71
+ }
72
+
73
+ Write-Verbose "Appended log entry: [$timestamp] $Op | $Title"
@@ -0,0 +1,111 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Emits an OpenTelemetry span via OTLP/HTTP JSON.
4
+
5
+ .DESCRIPTION
6
+ Opt-in OTel export. If KUSHI_OTEL_ENDPOINT is unset or empty, this is a
7
+ complete no-op (no HTTP calls, no allocations). Pure PowerShell — no
8
+ external dependencies.
9
+
10
+ .PARAMETER SpanName
11
+ The span name (e.g., 'kushi.pull', 'kushi.build_state').
12
+
13
+ .PARAMETER Attributes
14
+ Hashtable of span attributes. Only metadata — never evidence content.
15
+
16
+ .PARAMETER DurationMs
17
+ Duration in milliseconds (optional — computed from Start if provided).
18
+
19
+ .PARAMETER Start
20
+ Optional start time [datetime]. Defaults to (now - DurationMs).
21
+ #>
22
+ [CmdletBinding()]
23
+ param(
24
+ [Parameter(Mandatory)][string]$SpanName,
25
+ [hashtable]$Attributes = @{},
26
+ [int]$DurationMs = 0,
27
+ [datetime]$Start = [datetime]::MinValue
28
+ )
29
+
30
+ # No-op guard — zero overhead when endpoint is unset
31
+ $endpoint = $env:KUSHI_OTEL_ENDPOINT
32
+ if (-not $endpoint) { return }
33
+
34
+ $ErrorActionPreference = 'Continue'
35
+
36
+ try {
37
+ # Compute timestamps (nanoseconds since epoch)
38
+ $now = [DateTimeOffset]::UtcNow
39
+ if ($Start -eq [datetime]::MinValue) {
40
+ $startOffset = $now.AddMilliseconds(-$DurationMs)
41
+ } else {
42
+ $startOffset = [DateTimeOffset]::new($Start.ToUniversalTime())
43
+ }
44
+ $endOffset = $now
45
+
46
+ $startNs = ($startOffset.ToUnixTimeMilliseconds() * 1000000).ToString()
47
+ $endNs = ($endOffset.ToUnixTimeMilliseconds() * 1000000).ToString()
48
+
49
+ # Generate trace/span IDs
50
+ $traceId = [System.Guid]::NewGuid().ToString('N')
51
+ $spanId = [System.Guid]::NewGuid().ToString('N').Substring(0, 16)
52
+
53
+ # Build attributes array
54
+ $attrArray = @()
55
+ # Allowed keys only (privacy contract)
56
+ $allowedKeys = @('project','alias','source','duration_ms','success','evidence_count',
57
+ 'pages_written','contradictions_flagged','findings_count','entity',
58
+ 'field','event','hook_type','target','report_path','items_pulled')
59
+ foreach ($key in $Attributes.Keys) {
60
+ if ($key -notin $allowedKeys) { continue }
61
+ $val = $Attributes[$key]
62
+ $valObj = if ($val -is [bool]) {
63
+ @{ boolValue = $val }
64
+ } elseif ($val -is [int] -or $val -is [long] -or $val -is [double]) {
65
+ @{ intValue = [string]$val }
66
+ } else {
67
+ @{ stringValue = [string]$val }
68
+ }
69
+ $attrArray += @{ key = $key; value = $valObj }
70
+ }
71
+
72
+ # Get service version
73
+ $version = '5.2.0'
74
+ $pkgJson = Join-Path $PSScriptRoot '..\..\..\..\package.json'
75
+ if (Test-Path $pkgJson) {
76
+ try { $version = (Get-Content -Raw $pkgJson | ConvertFrom-Json).version } catch {}
77
+ }
78
+
79
+ # Build OTLP payload
80
+ $payload = @{
81
+ resourceSpans = @(@{
82
+ resource = @{
83
+ attributes = @(
84
+ @{ key = 'service.name'; value = @{ stringValue = 'kushi' } }
85
+ @{ key = 'service.version'; value = @{ stringValue = $version } }
86
+ )
87
+ }
88
+ scopeSpans = @(@{
89
+ scope = @{ name = 'kushi'; version = $version }
90
+ spans = @(@{
91
+ traceId = $traceId
92
+ spanId = $spanId
93
+ name = $SpanName
94
+ kind = 1 # SPAN_KIND_INTERNAL
95
+ startTimeUnixNano = $startNs
96
+ endTimeUnixNano = $endNs
97
+ attributes = $attrArray
98
+ status = @{
99
+ code = if ($Attributes['success'] -eq $false) { 2 } else { 1 }
100
+ }
101
+ })
102
+ })
103
+ })
104
+ }
105
+
106
+ $json = $payload | ConvertTo-Json -Depth 20 -Compress
107
+ Invoke-RestMethod -Uri $endpoint -Method POST -Body $json `
108
+ -ContentType 'application/json' -TimeoutSec 5 -ErrorAction Stop | Out-Null
109
+ } catch {
110
+ Write-Warning "OTel export failed (non-blocking): $($_.Exception.Message)"
111
+ }