kushi-agents 5.1.0 → 5.3.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.
- package/README.md +85 -0
- package/bin/cli.mjs +188 -1
- package/package.json +2 -2
- package/plugin/agents/kushi.agent.md +5 -0
- package/plugin/instructions/global-wiki.instructions.md +79 -0
- package/plugin/instructions/hooks.instructions.md +84 -0
- package/plugin/instructions/multi-wiki-routing.instructions.md +117 -0
- package/plugin/instructions/otel.instructions.md +75 -0
- package/plugin/instructions/parallel-execution.instructions.md +81 -0
- package/plugin/instructions/schema-evolve.instructions.md +73 -0
- package/plugin/skills/_shared/Emit-OtelSpan.ps1 +111 -0
- package/plugin/skills/_shared/Invoke-Hooks.ps1 +177 -0
- package/plugin/skills/_shared/hook-templates/console-debug.ps1 +15 -0
- package/plugin/skills/_shared/hook-templates/teams-notify.ps1 +47 -0
- package/plugin/skills/ask-project/SKILL.md +14 -0
- package/plugin/skills/global-wiki/.created-by-skill-creator +1 -0
- package/plugin/skills/global-wiki/SKILL.md +87 -0
- package/plugin/skills/global-wiki/evals/evals.json +43 -0
- package/plugin/skills/promote/.created-by-skill-creator +1 -0
- package/plugin/skills/promote/SKILL.md +125 -0
- package/plugin/skills/promote/evals/evals.json +35 -0
- package/plugin/skills/refresh-project/SKILL.md +8 -4
- package/plugin/skills/schema-evolve/.created-by-skill-creator +0 -0
- package/plugin/skills/schema-evolve/SKILL.md +106 -0
- package/plugin/skills/schema-evolve/evals/evals.json +37 -0
- package/plugin/skills/self-check/SKILL.md +12 -54
- package/plugin/skills/self-check/references/algorithm.md +55 -0
- package/plugin/skills/self-check/run.ps1 +155 -0
- package/plugin/skills/teach/.created-by-skill-creator +0 -0
- package/plugin/skills/teach/SKILL.md +79 -0
- package/plugin/skills/teach/evals/evals.json +59 -0
- package/src/global-wiki-cli.mjs +158 -0
- package/src/global-wiki.mjs +503 -0
- package/src/global-wiki.test.mjs +135 -0
- package/src/hooks-dispatcher.test.mjs +135 -0
- package/src/otel-emit.test.mjs +73 -0
- package/src/parallel-refresh.test.mjs +50 -0
- package/src/promote.test.mjs +161 -0
- package/src/schema-evolve.test.mjs +78 -0
- 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,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
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Dispatches hooks for a kushi pipeline event.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
Idempotent hook dispatcher. Reads .kushi/hooks.yml + .kushi/hooks/*.ps1,
|
|
7
|
+
invokes each configured hook for the given event, captures output/duration,
|
|
8
|
+
and logs results to Evidence/<alias>/State/hooks-log.md.
|
|
9
|
+
|
|
10
|
+
Failures are warn-only — never blocks the pipeline.
|
|
11
|
+
|
|
12
|
+
.PARAMETER ProjectRoot
|
|
13
|
+
Path to the project root (contains .kushi/).
|
|
14
|
+
|
|
15
|
+
.PARAMETER Event
|
|
16
|
+
One of: post-pull, post-state, post-contradiction, post-lint.
|
|
17
|
+
|
|
18
|
+
.PARAMETER Payload
|
|
19
|
+
Hashtable of event data (serialized to JSON for hooks).
|
|
20
|
+
|
|
21
|
+
.PARAMETER StateDir
|
|
22
|
+
Path to Evidence/<alias>/State/ for logging.
|
|
23
|
+
#>
|
|
24
|
+
[CmdletBinding()]
|
|
25
|
+
param(
|
|
26
|
+
[Parameter(Mandatory)][string]$ProjectRoot,
|
|
27
|
+
[Parameter(Mandatory)][ValidateSet('post-pull','post-state','post-contradiction','post-lint')][string]$Event,
|
|
28
|
+
[Parameter(Mandatory)][hashtable]$Payload,
|
|
29
|
+
[string]$StateDir
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
$ErrorActionPreference = 'Continue'
|
|
33
|
+
|
|
34
|
+
$hooksYml = Join-Path $ProjectRoot '.kushi/hooks.yml'
|
|
35
|
+
$hooksDir = Join-Path $ProjectRoot '.kushi/hooks'
|
|
36
|
+
$payloadJson = $Payload | ConvertTo-Json -Depth 10 -Compress
|
|
37
|
+
|
|
38
|
+
# Collect hook definitions
|
|
39
|
+
$hookDefs = @()
|
|
40
|
+
|
|
41
|
+
# Parse hooks.yml if present
|
|
42
|
+
if (Test-Path $hooksYml) {
|
|
43
|
+
$lines = Get-Content -Path $hooksYml -ErrorAction SilentlyContinue
|
|
44
|
+
$inEvent = $false
|
|
45
|
+
$eventIndent = 0
|
|
46
|
+
foreach ($line in $lines) {
|
|
47
|
+
if ($line -match "^\s*$Event\s*:") {
|
|
48
|
+
$inEvent = $true
|
|
49
|
+
$eventIndent = ($line -replace '[^\s].*','').Length
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if ($inEvent) {
|
|
53
|
+
# Determine current indent
|
|
54
|
+
$currentIndent = if ($line -match '^(\s*)') { $Matches[1].Length } else { 0 }
|
|
55
|
+
# Exit if we hit a line at same/lower indent that's not empty
|
|
56
|
+
if ($line.Trim() -ne '' -and $currentIndent -le $eventIndent -and $line -notmatch '^\s*#') {
|
|
57
|
+
$inEvent = $false
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if ($line -match '^\s*-\s*type:\s*(\w+)') { $currentType = $Matches[1]; continue }
|
|
61
|
+
if ($line -match '^\s*url:\s*(.+)') {
|
|
62
|
+
$hookDefs += @{ type = 'webhook'; target = $Matches[1].Trim() }
|
|
63
|
+
}
|
|
64
|
+
if ($line -match '^\s*path:\s*(.+)') {
|
|
65
|
+
$hookDefs += @{ type = 'script'; target = $Matches[1].Trim() }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Also check for event-named script in hooks dir
|
|
72
|
+
$eventScript = Join-Path $hooksDir "$Event.ps1"
|
|
73
|
+
if ((Test-Path $eventScript) -and -not ($hookDefs | Where-Object { $_.target -eq $eventScript })) {
|
|
74
|
+
$hookDefs += @{ type = 'script'; target = $eventScript }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if ($hookDefs.Count -eq 0) {
|
|
78
|
+
Write-Verbose "No hooks configured for event '$Event'"
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# Emit OTel span for each hook invocation
|
|
83
|
+
$emitOtel = Join-Path $PSScriptRoot 'Emit-OtelSpan.ps1'
|
|
84
|
+
|
|
85
|
+
$results = @()
|
|
86
|
+
foreach ($hook in $hookDefs) {
|
|
87
|
+
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
|
88
|
+
$status = 'success'
|
|
89
|
+
$output = ''
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
if ($hook.type -eq 'webhook') {
|
|
93
|
+
$resp = Invoke-RestMethod -Uri $hook.target -Method POST -Body $payloadJson `
|
|
94
|
+
-ContentType 'application/json' -TimeoutSec 5 -ErrorAction Stop
|
|
95
|
+
} elseif ($hook.type -eq 'script') {
|
|
96
|
+
$scriptPath = if ([System.IO.Path]::IsPathRooted($hook.target)) {
|
|
97
|
+
$hook.target
|
|
98
|
+
} else {
|
|
99
|
+
Join-Path (Resolve-Path $ProjectRoot).Path $hook.target
|
|
100
|
+
}
|
|
101
|
+
if (Test-Path $scriptPath) {
|
|
102
|
+
try {
|
|
103
|
+
$output = $payloadJson | & pwsh -NoProfile -Command "try { & '$scriptPath' } catch { Write-Output `$_.Exception.Message; exit 1 }" 2>&1 | Out-String
|
|
104
|
+
if ($LASTEXITCODE -ne 0) {
|
|
105
|
+
$status = 'failed'
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
$status = 'failed'
|
|
109
|
+
$output = $_.Exception.Message
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
$status = 'skipped'
|
|
113
|
+
$output = "Script not found: $scriptPath"
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
$status = 'failed'
|
|
118
|
+
$output = $_.Exception.Message
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
$sw.Stop()
|
|
122
|
+
$results += @{
|
|
123
|
+
hook = $hook
|
|
124
|
+
status = $status
|
|
125
|
+
duration_ms = $sw.ElapsedMilliseconds
|
|
126
|
+
output = $output
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# Emit OTel span if helper exists
|
|
130
|
+
if (Test-Path $emitOtel) {
|
|
131
|
+
$otelAttrs = @{
|
|
132
|
+
project = $Payload.project ?? ''
|
|
133
|
+
alias = $Payload.alias ?? ''
|
|
134
|
+
event = $Event
|
|
135
|
+
hook_type = $hook.type
|
|
136
|
+
target = $hook.target
|
|
137
|
+
duration_ms = $sw.ElapsedMilliseconds
|
|
138
|
+
success = ($status -eq 'success')
|
|
139
|
+
}
|
|
140
|
+
& $emitOtel -SpanName 'kushi.hook.invoked' -Attributes $otelAttrs
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# Write hooks-log.md
|
|
145
|
+
if ($StateDir -and (Test-Path $StateDir)) {
|
|
146
|
+
$logFile = Join-Path $StateDir 'hooks-log.md'
|
|
147
|
+
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
|
148
|
+
|
|
149
|
+
$entries = @()
|
|
150
|
+
foreach ($r in $results) {
|
|
151
|
+
$entry = @"
|
|
152
|
+
|
|
153
|
+
## [$timestamp] $Event | hook: $($r.hook.target | Split-Path -Leaf)
|
|
154
|
+
|
|
155
|
+
- status: $($r.status)
|
|
156
|
+
- duration_ms: $($r.duration_ms)
|
|
157
|
+
- target: $($r.hook.target)
|
|
158
|
+
"@
|
|
159
|
+
if ($r.status -eq 'failed') {
|
|
160
|
+
$entry += "`n- error: $($r.output)"
|
|
161
|
+
}
|
|
162
|
+
$entries += $entry
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
$newContent = $entries -join "`n"
|
|
166
|
+
|
|
167
|
+
if (Test-Path $logFile) {
|
|
168
|
+
$existing = Get-Content -Raw $logFile
|
|
169
|
+
Set-Content -Path $logFile -Value ($existing + "`n" + $newContent) -Encoding utf8NoBOM
|
|
170
|
+
} else {
|
|
171
|
+
$header = "---`nkushi_hooks_log: true`n---`n`n# Hooks Log`n"
|
|
172
|
+
Set-Content -Path $logFile -Value ($header + $newContent) -Encoding utf8NoBOM
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
# Return results for caller inspection
|
|
177
|
+
$results
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Hook template: Debug output — prints event payload to console.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
Copy this file to .kushi/hooks/<event>.ps1 for debugging.
|
|
7
|
+
Prints the full event payload to stdout with timestamp.
|
|
8
|
+
#>
|
|
9
|
+
|
|
10
|
+
$payload = $input | Out-String
|
|
11
|
+
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
|
12
|
+
|
|
13
|
+
Write-Output "=== KUSHI HOOK DEBUG [$timestamp] ==="
|
|
14
|
+
Write-Output $payload
|
|
15
|
+
Write-Output "=== END HOOK DEBUG ==="
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Hook template: Posts event to a Microsoft Teams webhook.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
Copy this file to .kushi/hooks/post-pull.ps1 (or any event name) and
|
|
7
|
+
set $WebhookUrl to your Teams Incoming Webhook URL.
|
|
8
|
+
|
|
9
|
+
Receives event payload as JSON on stdin.
|
|
10
|
+
#>
|
|
11
|
+
|
|
12
|
+
# === CONFIGURE THIS ===
|
|
13
|
+
$WebhookUrl = $env:KUSHI_TEAMS_WEBHOOK_URL # Or hard-code your URL
|
|
14
|
+
|
|
15
|
+
if (-not $WebhookUrl) {
|
|
16
|
+
Write-Warning "KUSHI_TEAMS_WEBHOOK_URL not set — skipping Teams notification"
|
|
17
|
+
exit 0
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# Read event payload from stdin
|
|
21
|
+
$payload = $input | Out-String | ConvertFrom-Json
|
|
22
|
+
|
|
23
|
+
# Build Teams Adaptive Card message
|
|
24
|
+
$card = @{
|
|
25
|
+
type = 'message'
|
|
26
|
+
attachments = @(@{
|
|
27
|
+
contentType = 'application/vnd.microsoft.card.adaptive'
|
|
28
|
+
content = @{
|
|
29
|
+
'$schema' = 'http://adaptivecards.io/schemas/adaptive-card.json'
|
|
30
|
+
type = 'AdaptiveCard'
|
|
31
|
+
version = '1.4'
|
|
32
|
+
body = @(
|
|
33
|
+
@{ type = 'TextBlock'; text = "🔔 Kushi: $($payload.event ?? 'event')"; weight = 'Bolder'; size = 'Medium' }
|
|
34
|
+
@{ type = 'FactSet'; facts = @(
|
|
35
|
+
@{ title = 'Project'; value = $payload.project ?? 'unknown' }
|
|
36
|
+
@{ title = 'Source'; value = $payload.source ?? 'n/a' }
|
|
37
|
+
@{ title = 'Status'; value = if ($payload.success) { '✅ Success' } else { '⚠️ Issue' } }
|
|
38
|
+
@{ title = 'Duration'; value = "$($payload.duration_ms ?? 0)ms" }
|
|
39
|
+
)}
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
$json = $card | ConvertTo-Json -Depth 20 -Compress
|
|
46
|
+
Invoke-RestMethod -Uri $WebhookUrl -Method POST -Body $json -ContentType 'application/json' | Out-Null
|
|
47
|
+
Write-Output "Teams notification sent for $($payload.project)"
|
|
@@ -72,6 +72,20 @@ When the user passes `--file-back` (or says "file this answer back", "save this
|
|
|
72
72
|
|
|
73
73
|
The `--file-back` flag is OPTIONAL. Without it, ask-project behaves exactly as before (read-only, no writes).
|
|
74
74
|
|
|
75
|
+
## --global / --project-only (v5.3.0+)
|
|
76
|
+
|
|
77
|
+
Multi-wiki routing flags governed by `multi-wiki-routing.instructions.md`:
|
|
78
|
+
|
|
79
|
+
| Flag | Behavior |
|
|
80
|
+
|------|----------|
|
|
81
|
+
| *(no flag)* | **Project-first.** Search project `Evidence/<alias>/State/` first; only fall back to the global wiki when project sources are missing/stale or no hit is found. Cite each hit with `[project: <alias>]` or `[global]`. |
|
|
82
|
+
| `--global` | **Global-first.** Search `$KUSHI_GLOBAL_ROOT/State/answers/` first; fall back to the project. Use when you trust the cross-engagement note more than project state. |
|
|
83
|
+
| `--project-only` | **Hard-suppress global.** Never read the global wiki for this question (privacy-sensitive or strictly project-internal). |
|
|
84
|
+
|
|
85
|
+
Provenance is never silent. Every citation tags `[project: <alias>]` or `[global]`. If the answer mixes both, list each source with its provenance.
|
|
86
|
+
|
|
87
|
+
The global wiki itself is opt-in (created via `kushi global init`). If it does not exist on disk, all three modes degrade silently to project-only.
|
|
88
|
+
|
|
75
89
|
## Inputs
|
|
76
90
|
|
|
77
91
|
- `<project>` — fuzzy-matched project name. If multiple plausible matches, ask the user.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
global-wiki
|