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.
- package/README.md +78 -0
- package/bin/cli.mjs +201 -1
- package/package.json +2 -2
- package/plugin/agents/kushi.agent.md +6 -2
- package/plugin/instructions/hooks.instructions.md +84 -0
- package/plugin/instructions/living-wiki.instructions.md +88 -0
- package/plugin/instructions/log-format.instructions.md +78 -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/instructions/wiki-lint.instructions.md +110 -0
- package/plugin/skills/_shared/Append-StateLog.ps1 +73 -0
- package/plugin/skills/_shared/Emit-OtelSpan.ps1 +111 -0
- package/plugin/skills/_shared/Invoke-Hooks.ps1 +177 -0
- package/plugin/skills/_shared/Update-StateIndex.ps1 +47 -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 +30 -0
- package/plugin/skills/build-state/SKILL.md +18 -2
- package/plugin/skills/lint-state/.created-by-skill-creator +0 -0
- package/plugin/skills/lint-state/SKILL.md +98 -0
- package/plugin/skills/lint-state/evals/evals.json +34 -0
- package/plugin/skills/lint-state/lint.ps1 +218 -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 -55
- package/plugin/skills/self-check/references/algorithm.md +55 -0
- package/plugin/skills/self-check/run.ps1 +225 -3
- package/plugin/skills/skill-checker/check-skill.ps1 +1 -1
- package/plugin/skills/teach/.created-by-skill-creator +0 -0
- package/plugin/skills/teach/SKILL.md +77 -0
- package/plugin/skills/teach/evals/evals.json +37 -0
- package/plugin/templates/state/answers.README.md +7 -0
- package/plugin/templates/state/hot.template.md +12 -0
- package/plugin/templates/state/review-queue.template.md +10 -0
- package/src/eval-runner.test.mjs +1 -1
- 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/schema-evolve.test.mjs +78 -0
- package/src/teach.test.mjs +45 -0
|
@@ -120,7 +120,7 @@ if (-not (Test-Path $pluginDir)) {
|
|
|
120
120
|
exit 2
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
$skillDirs = Get-ChildItem $skillsDir -Directory | Sort-Object Name
|
|
123
|
+
$skillDirs = Get-ChildItem $skillsDir -Directory | Where-Object { $_.Name -notmatch '^\.' -and $_.Name -notmatch '^_' } | Sort-Object Name
|
|
124
124
|
$skillNames = $skillDirs.Name
|
|
125
125
|
$instructionFiles = Get-ChildItem $instructionsDir -Filter '*.instructions.md' -EA SilentlyContinue
|
|
126
126
|
$promptFiles = Get-ChildItem $promptsDir -Filter '*.prompt.md' -EA SilentlyContinue
|
|
@@ -264,7 +264,7 @@ if (Test-Path $pluginJsonFile) {
|
|
|
264
264
|
$chain += $name
|
|
265
265
|
return $chain
|
|
266
266
|
}
|
|
267
|
-
$skillNames = (Get-ChildItem $skillsDir -Directory).Name
|
|
267
|
+
$skillNames = (Get-ChildItem $skillsDir -Directory | Where-Object { $_.Name -notmatch '^\.' -and $_.Name -notmatch '^_' }).Name
|
|
268
268
|
$promptStems = (Get-ChildItem (Join-Path $pluginDir 'prompts') -File -Filter '*.prompt.md' -ErrorAction SilentlyContinue) `
|
|
269
269
|
| ForEach-Object { $_.Name -replace '\.prompt\.md$','' }
|
|
270
270
|
$instructionStems = (Get-ChildItem (Join-Path $pluginDir 'instructions') -File -Filter '*.instructions.md' -ErrorAction SilentlyContinue) `
|
|
@@ -1663,7 +1663,7 @@ process.stdout.write(JSON.stringify(out));
|
|
|
1663
1663
|
}
|
|
1664
1664
|
|
|
1665
1665
|
$skillsRoot = Join-Path $Root 'plugin/skills'
|
|
1666
|
-
$skillDirs = Get-ChildItem -Path $skillsRoot -Directory | Where-Object { $_.Name -notin @('eval', 'self-check') }
|
|
1666
|
+
$skillDirs = Get-ChildItem -Path $skillsRoot -Directory | Where-Object { $_.Name -notin @('eval', 'self-check') -and $_.Name -notmatch '^_' }
|
|
1667
1667
|
foreach ($sd in $skillDirs) {
|
|
1668
1668
|
$evalsFile = Join-Path $sd.FullName 'evals/evals.json'
|
|
1669
1669
|
if (-not (Test-Path $evalsFile)) {
|
|
@@ -1778,6 +1778,228 @@ process.stdout.write(JSON.stringify(out));
|
|
|
1778
1778
|
}
|
|
1779
1779
|
}
|
|
1780
1780
|
|
|
1781
|
+
# === D35.log — State/log.md format and presence (v5.1.0+) ===
|
|
1782
|
+
# Validates that writer-touched State dirs have log.md and that entries match canonical format.
|
|
1783
|
+
# Operates on .testtmp/ fixtures or real engagement roots.
|
|
1784
|
+
$logFixtureDir = Join-Path $Root '.testtmp'
|
|
1785
|
+
$logTestDirs = @()
|
|
1786
|
+
if (Test-Path $logFixtureDir) {
|
|
1787
|
+
$logTestDirs += Get-ChildItem -Path $logFixtureDir -Recurse -Directory -ErrorAction SilentlyContinue |
|
|
1788
|
+
Where-Object { $_.Name -eq 'State' -and (Test-Path (Join-Path $_.FullName 'log.md')) }
|
|
1789
|
+
}
|
|
1790
|
+
# Also check real engagement roots if available
|
|
1791
|
+
foreach ($engRoot in (Resolve-EngagementRoots -RepoRoot $Root)) {
|
|
1792
|
+
Get-ChildItem -Path $engRoot -Directory -ErrorAction SilentlyContinue | ForEach-Object {
|
|
1793
|
+
$stateDir = Join-Path $_.FullName 'State'
|
|
1794
|
+
if (Test-Path $stateDir) { $logTestDirs += Get-Item $stateDir }
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
foreach ($sd in $logTestDirs) {
|
|
1799
|
+
$logMd = Join-Path $sd.FullName 'log.md'
|
|
1800
|
+
|
|
1801
|
+
# D35.log-exists
|
|
1802
|
+
if (-not (Test-Path $logMd)) {
|
|
1803
|
+
Add-Finding 'D35.log-exists' 'State log' 'warning' "State directory '$($sd.FullName)' has no log.md" "Add log.md per log-format.instructions.md. Run build-state to auto-create it." $sd.FullName 0
|
|
1804
|
+
continue
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
# D35.log-format — every ## [ heading matches canonical format
|
|
1808
|
+
$logLines = Get-Content -Path $logMd -ErrorAction SilentlyContinue
|
|
1809
|
+
$prevTs = $null
|
|
1810
|
+
$formatOk = $true
|
|
1811
|
+
$monotonicOk = $true
|
|
1812
|
+
foreach ($line in $logLines) {
|
|
1813
|
+
if ($line -match '^\#\#\s+\[') {
|
|
1814
|
+
if ($line -notmatch '^\#\#\s+\[\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\]\s+\S+\s+\|\s+.+$') {
|
|
1815
|
+
Add-Finding 'D35.log-format' 'State log' 'warning' "log.md heading does not match canonical format: $line" "Heading must be: ## [YYYY-MM-DD HH:MM] <op> | <title>. See log-format.instructions.md." $logMd 0
|
|
1816
|
+
$formatOk = $false
|
|
1817
|
+
break
|
|
1818
|
+
}
|
|
1819
|
+
# D35.log-monotonic — timestamps non-decreasing (newest first = decreasing)
|
|
1820
|
+
if ($line -match '^\#\#\s+\[(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})\]') {
|
|
1821
|
+
try {
|
|
1822
|
+
$ts = [datetime]::ParseExact($Matches[1], 'yyyy-MM-dd HH:mm', $null)
|
|
1823
|
+
if ($prevTs -and $ts -gt $prevTs) {
|
|
1824
|
+
Add-Finding 'D35.log-monotonic' 'State log' 'warning' "log.md timestamps not reverse-chronological: $($Matches[1]) appears after an earlier timestamp" "Entries should be prepended (newest first). Re-order or re-run build-state." $logMd 0
|
|
1825
|
+
$monotonicOk = $false
|
|
1826
|
+
break
|
|
1827
|
+
}
|
|
1828
|
+
$prevTs = $ts
|
|
1829
|
+
} catch {}
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
# === D36.contradictions — contradiction handling integrity (v5.1.0+) ===
|
|
1836
|
+
# Validates callout syntax, review-queue freshness, and build-state non-fenced preservation.
|
|
1837
|
+
$contradictionDirs = @()
|
|
1838
|
+
if (Test-Path $logFixtureDir) {
|
|
1839
|
+
$contradictionDirs += Get-ChildItem -Path $logFixtureDir -Recurse -Directory -ErrorAction SilentlyContinue |
|
|
1840
|
+
Where-Object { $_.Name -eq 'State' }
|
|
1841
|
+
}
|
|
1842
|
+
foreach ($engRoot in (Resolve-EngagementRoots -RepoRoot $Root)) {
|
|
1843
|
+
Get-ChildItem -Path $engRoot -Directory -ErrorAction SilentlyContinue | ForEach-Object {
|
|
1844
|
+
$stateDir = Join-Path $_.FullName 'State'
|
|
1845
|
+
if (Test-Path $stateDir) { $contradictionDirs += Get-Item $stateDir }
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
foreach ($sd in $contradictionDirs) {
|
|
1850
|
+
$mdFiles = Get-ChildItem -Path $sd.FullName -Recurse -Filter '*.md' -ErrorAction SilentlyContinue
|
|
1851
|
+
|
|
1852
|
+
# D36.callout-syntax — every > [!warning] Contradicted callout has required structure
|
|
1853
|
+
foreach ($mf in $mdFiles) {
|
|
1854
|
+
$content = Get-Content -Raw $mf.FullName -ErrorAction SilentlyContinue
|
|
1855
|
+
if (-not $content) { continue }
|
|
1856
|
+
if ($content -match '>\s*\[!warning\]\s*Contradicted') {
|
|
1857
|
+
# Must have 'by' clause and a following > [!info] block
|
|
1858
|
+
if ($content -notmatch '>\s*\[!warning\]\s*Contradicted\s+by\s+') {
|
|
1859
|
+
Add-Finding 'D36.callout-syntax' 'Contradictions' 'warning' "Callout in $($mf.Name) missing 'by <source>' clause" "Format: > [!warning] Contradicted by <skill> run <date>. See living-wiki.instructions.md." $mf.FullName 0
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
# D36.review-queue-fresh — if _review-queue.md has open items, most recent log entry is within 30 days
|
|
1865
|
+
$reviewQueue = Join-Path $sd.FullName '_review-queue.md'
|
|
1866
|
+
if (Test-Path $reviewQueue) {
|
|
1867
|
+
$rqContent = Get-Content -Raw $reviewQueue -ErrorAction SilentlyContinue
|
|
1868
|
+
# Check if review-queue has data rows (not just header + separator)
|
|
1869
|
+
$rqLines = ($rqContent -split '\r?\n') | Where-Object { $_ -match '^\|' -and $_ -notmatch '^\|\s*-' -and $_ -notmatch '^\|\s*Entity' }
|
|
1870
|
+
if ($rqLines.Count -gt 0) {
|
|
1871
|
+
# Has table rows (open items)
|
|
1872
|
+
$logMd2 = Join-Path $sd.FullName 'log.md'
|
|
1873
|
+
if (Test-Path $logMd2) {
|
|
1874
|
+
$logContent2 = Get-Content -Raw $logMd2
|
|
1875
|
+
$recentEntry = $false
|
|
1876
|
+
if ($logContent2 -match '(?m)^\#\#\s+\[(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})\]') {
|
|
1877
|
+
try {
|
|
1878
|
+
$lastTs = [datetime]::ParseExact($Matches[1], 'yyyy-MM-dd HH:mm', $null)
|
|
1879
|
+
if (((Get-Date) - $lastTs).TotalDays -le 30) { $recentEntry = $true }
|
|
1880
|
+
} catch {}
|
|
1881
|
+
}
|
|
1882
|
+
if (-not $recentEntry) {
|
|
1883
|
+
Add-Finding 'D36.review-queue-fresh' 'Contradictions' 'warning' "_review-queue.md has open items but most recent log entry is >30 days old" "Run 'lint-state' or 'build-state' to refresh. Open contradictions need periodic review." $reviewQueue 0
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
# D36.no-silent-overwrite — build-state preserves non-fenced regions
|
|
1890
|
+
# This check validates the invariant by looking for the fencing pattern.
|
|
1891
|
+
# If State pages exist with kushi:auto fences AND content outside fences,
|
|
1892
|
+
# verify the file has not been fully regenerated (check mtime consistency).
|
|
1893
|
+
# For the fixture-based test: run build-state twice, assert non-fenced bytes unchanged.
|
|
1894
|
+
# In self-check, we do a structural check: if a page has content outside fences,
|
|
1895
|
+
# verify it also has content INSIDE fences (indicating incremental mode is active).
|
|
1896
|
+
foreach ($mf in $mdFiles) {
|
|
1897
|
+
$content = Get-Content -Raw $mf.FullName -ErrorAction SilentlyContinue
|
|
1898
|
+
if (-not $content) { continue }
|
|
1899
|
+
if ($content -notmatch 'kushi_state_page:\s*true') { continue }
|
|
1900
|
+
if ($content -match '<!-- kushi:auto:start') {
|
|
1901
|
+
# Good — page uses fencing. Verify fence pairs are balanced.
|
|
1902
|
+
$starts = ([regex]::Matches($content, '<!-- kushi:auto:start')).Count
|
|
1903
|
+
$ends = ([regex]::Matches($content, '<!-- kushi:auto:end')).Count
|
|
1904
|
+
if ($starts -ne $ends) {
|
|
1905
|
+
Add-Finding 'D36.no-silent-overwrite' 'Contradictions' 'warning' "$($mf.Name) has mismatched kushi:auto fence pairs ($starts starts, $ends ends)" "Every <!-- kushi:auto:start --> must have a matching <!-- kushi:auto:end -->. Fix the fencing." $mf.FullName 0
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
# === D37.hooks — Hooks system integrity (v5.2.0+) ===
|
|
1912
|
+
|
|
1913
|
+
# D37.hooks-doctrine-exists
|
|
1914
|
+
$hooksDoctrine = Join-Path $instructionsDir 'hooks.instructions.md'
|
|
1915
|
+
if (-not (Test-Path $hooksDoctrine)) {
|
|
1916
|
+
Add-Finding 'D37.hooks-doctrine-exists' 'Hooks' 'warning' "hooks.instructions.md is missing" "Create plugin/instructions/hooks.instructions.md per v5.2.0 spec." $hooksDoctrine 0
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
# D37.hooks-helper-exists
|
|
1920
|
+
$hooksHelper = Join-Path $skillsDir '_shared\Invoke-Hooks.ps1'
|
|
1921
|
+
if (-not (Test-Path $hooksHelper)) {
|
|
1922
|
+
Add-Finding 'D37.hooks-helper-exists' 'Hooks' 'warning' "Invoke-Hooks.ps1 shared helper is missing" "Create plugin/skills/_shared/Invoke-Hooks.ps1 per hooks.instructions.md." $hooksHelper 0
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
# D37.hooks-templates-exist
|
|
1926
|
+
$templatesDir = Join-Path $skillsDir '_shared\hook-templates'
|
|
1927
|
+
if (-not (Test-Path $templatesDir)) {
|
|
1928
|
+
Add-Finding 'D37.hooks-templates-exist' 'Hooks' 'warning' "hook-templates/ directory is missing" "Create plugin/skills/_shared/hook-templates/ with at least teams-notify.ps1 and console-debug.ps1." $templatesDir 0
|
|
1929
|
+
} else {
|
|
1930
|
+
$templates = Get-ChildItem -Path $templatesDir -Filter '*.ps1' -ErrorAction SilentlyContinue
|
|
1931
|
+
if ($templates.Count -lt 2) {
|
|
1932
|
+
Add-Finding 'D37.hooks-templates-exist' 'Hooks' 'warning' "hook-templates/ has fewer than 2 templates (found $($templates.Count))" "Add teams-notify.ps1 and console-debug.ps1 templates." $templatesDir 0
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
# D37.hooks-log-format: if any fixture has a hooks-log.md, headings parse correctly
|
|
1937
|
+
$hooksLogFixtures = @()
|
|
1938
|
+
if (Test-Path $logFixtureDir) {
|
|
1939
|
+
$hooksLogFixtures += Get-ChildItem -Path $logFixtureDir -Recurse -Filter 'hooks-log.md' -ErrorAction SilentlyContinue
|
|
1940
|
+
}
|
|
1941
|
+
foreach ($engRoot in (Resolve-EngagementRoots -RepoRoot $Root)) {
|
|
1942
|
+
$hooksLogFixtures += Get-ChildItem -Path $engRoot -Recurse -Filter 'hooks-log.md' -ErrorAction SilentlyContinue
|
|
1943
|
+
}
|
|
1944
|
+
foreach ($hlf in $hooksLogFixtures) {
|
|
1945
|
+
$hlContent = Get-Content -Path $hlf.FullName -ErrorAction SilentlyContinue
|
|
1946
|
+
foreach ($line in $hlContent) {
|
|
1947
|
+
if ($line -match '^\#\#\s+\[') {
|
|
1948
|
+
if ($line -notmatch '^\#\#\s+\[\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\]\s+\S+\s+\|\s+hook:\s+.+$') {
|
|
1949
|
+
Add-Finding 'D37.hooks-log-format' 'Hooks' 'warning' "hooks-log.md heading does not match canonical format: $line" "Heading must be: ## [YYYY-MM-DD HH:MM] <event> | hook: <name>. See hooks.instructions.md." $hlf.FullName 0
|
|
1950
|
+
break
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
# === D38.parallel — Parallel execution integrity (v5.2.0+) ===
|
|
1957
|
+
|
|
1958
|
+
# D38.parallel-doctrine-exists
|
|
1959
|
+
$parallelDoctrine = Join-Path $instructionsDir 'parallel-execution.instructions.md'
|
|
1960
|
+
if (-not (Test-Path $parallelDoctrine)) {
|
|
1961
|
+
Add-Finding 'D38.parallel-doctrine-exists' 'Parallel' 'warning' "parallel-execution.instructions.md is missing" "Create plugin/instructions/parallel-execution.instructions.md per v5.2.0 spec." $parallelDoctrine 0
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
# D38.refresh-supports-parallel: grep for parallel/ThreadJob/workers in refresh-project
|
|
1965
|
+
$refreshSkill = Join-Path $skillsDir 'refresh-project\SKILL.md'
|
|
1966
|
+
if (Test-Path $refreshSkill) {
|
|
1967
|
+
$refreshContent = Get-Content -Raw $refreshSkill
|
|
1968
|
+
if ($refreshContent -notmatch 'parallel|ThreadJob|worker|concurrent' -and $refreshContent -notmatch 'parallel-execution\.instructions\.md') {
|
|
1969
|
+
Add-Finding 'D38.refresh-supports-parallel' 'Parallel' 'warning' "refresh-project/SKILL.md does not reference parallel dispatch" "Add parallel dispatch documentation per parallel-execution.instructions.md." $refreshSkill 0
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
# D38.deterministic-order: verify parallel doctrine documents deterministic ordering
|
|
1974
|
+
if (Test-Path $parallelDoctrine) {
|
|
1975
|
+
$pdContent = Get-Content -Raw $parallelDoctrine
|
|
1976
|
+
if ($pdContent -notmatch 'deterministic|canonical.*order') {
|
|
1977
|
+
Add-Finding 'D38.deterministic-order' 'Parallel' 'warning' "parallel-execution doctrine does not document deterministic output ordering" "Add a section on deterministic ordering (canonical source order regardless of completion order)." $parallelDoctrine 0
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
# === D39.otel — OpenTelemetry export integrity (v5.2.0+) ===
|
|
1982
|
+
|
|
1983
|
+
# D39.otel-doctrine-exists
|
|
1984
|
+
$otelDoctrine = Join-Path $instructionsDir 'otel.instructions.md'
|
|
1985
|
+
if (-not (Test-Path $otelDoctrine)) {
|
|
1986
|
+
Add-Finding 'D39.otel-doctrine-exists' 'OTel' 'warning' "otel.instructions.md is missing" "Create plugin/instructions/otel.instructions.md per v5.2.0 spec." $otelDoctrine 0
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
# D39.otel-helper-exists
|
|
1990
|
+
$otelHelper = Join-Path $skillsDir '_shared\Emit-OtelSpan.ps1'
|
|
1991
|
+
if (-not (Test-Path $otelHelper)) {
|
|
1992
|
+
Add-Finding 'D39.otel-helper-exists' 'OTel' 'warning' "Emit-OtelSpan.ps1 shared helper is missing" "Create plugin/skills/_shared/Emit-OtelSpan.ps1 per otel.instructions.md." $otelHelper 0
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
# D39.otel-noop-when-unset: helper exits cleanly when KUSHI_OTEL_ENDPOINT is empty
|
|
1996
|
+
if (Test-Path $otelHelper) {
|
|
1997
|
+
$otelContent = Get-Content -Raw $otelHelper
|
|
1998
|
+
if ($otelContent -notmatch 'KUSHI_OTEL_ENDPOINT.*return|return.*KUSHI_OTEL_ENDPOINT|\-not \$endpoint.*return') {
|
|
1999
|
+
Add-Finding 'D39.otel-noop-when-unset' 'OTel' 'warning' "Emit-OtelSpan.ps1 does not appear to short-circuit when KUSHI_OTEL_ENDPOINT is unset" "Add early return when endpoint is empty: if (-not \$env:KUSHI_OTEL_ENDPOINT) { return }" $otelHelper 0
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
1781
2003
|
# === Output ===
|
|
1782
2004
|
if ($Targeted) {
|
|
1783
2005
|
# Filter findings to those whose code, surface, file path, or message contain the substring.
|
|
@@ -88,7 +88,7 @@ function Get-TargetSkills {
|
|
|
88
88
|
return @(Get-Item -LiteralPath $d)
|
|
89
89
|
}
|
|
90
90
|
if ($All) {
|
|
91
|
-
return Get-ChildItem -Path $skillsRoot -Directory | Where-Object { $excludeFromAll -notcontains $_.Name }
|
|
91
|
+
return Get-ChildItem -Path $skillsRoot -Directory | Where-Object { $excludeFromAll -notcontains $_.Name -and $_.Name -notmatch '^_' }
|
|
92
92
|
}
|
|
93
93
|
throw "Specify -Skill <name> or -All."
|
|
94
94
|
}
|
|
File without changes
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "teach"
|
|
3
|
+
version: "1.0.0"
|
|
4
|
+
description: "USE WHEN the user asks 'explain how kushi does X', 'why does refresh do Y', 'what is the difference between A and B in kushi', 'kushi explain <topic>', or wants to understand a kushi concept. DO NOT USE for modifying state/evidence (use build-state/refresh) or for project Q&A (use ask-project). Capability: pure pedagogical output — loads relevant doctrine + genealogy, explains concepts with cross-references, never modifies any State or Evidence."
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Skill: teach
|
|
8
|
+
|
|
9
|
+
Pure pedagogical skill. Explains kushi concepts, architecture decisions, and operational patterns by loading relevant doctrine snippets, cross-references, and genealogy entries.
|
|
10
|
+
|
|
11
|
+
## Triggers
|
|
12
|
+
|
|
13
|
+
- "explain how kushi does X"
|
|
14
|
+
- "why does refresh do Y"
|
|
15
|
+
- "what's the difference between A and B in kushi"
|
|
16
|
+
- `kushi explain <topic>` (CLI verb)
|
|
17
|
+
- "how does kushi handle contradictions?"
|
|
18
|
+
- "teach me about the CSC format"
|
|
19
|
+
|
|
20
|
+
## Inputs
|
|
21
|
+
|
|
22
|
+
- `<topic>` — free-text topic (fuzzy-matched to doctrine clusters below).
|
|
23
|
+
|
|
24
|
+
## Topic → Doctrine mapping
|
|
25
|
+
|
|
26
|
+
| Topic keyword(s) | Primary doctrine | Supporting files |
|
|
27
|
+
|------------------|-----------------|-----------------|
|
|
28
|
+
| contradictions, conflicts | `living-wiki.instructions.md` | `wiki-lint.instructions.md`, `State/_review-queue.md` format |
|
|
29
|
+
| refresh, pull, sources | `refresh-project/SKILL.md` | `verbatim-by-default.instructions.md`, `parallel-execution.instructions.md` |
|
|
30
|
+
| state, build-state, wiki | `living-wiki.instructions.md` | `karpathy-state-layout.instructions.md`, `log-format.instructions.md` |
|
|
31
|
+
| hooks, events, webhooks | `hooks.instructions.md` | `Invoke-Hooks.ps1` |
|
|
32
|
+
| parallel, workers, speed | `parallel-execution.instructions.md` | `auth-and-retry.instructions.md` |
|
|
33
|
+
| otel, telemetry, tracing | `otel.instructions.md` | `Emit-OtelSpan.ps1` |
|
|
34
|
+
| csc, capture, weekly | `comprehensive-structured-capture.instructions.md` | `weekly-csc.instructions.md` |
|
|
35
|
+
| graph, entities, links | `entity-graph.instructions.md` | `link-entities/SKILL.md` |
|
|
36
|
+
| workiq, m365 | `workiq-only.instructions.md` | `workiq-input-sanitization.instructions.md` |
|
|
37
|
+
| schema, conventions, remember | `schema-evolve.instructions.md` | `karpathy-state-layout.instructions.md` |
|
|
38
|
+
| install, setup, hosts | `multi-host-install.instructions.md` | `host-portability.instructions.md` |
|
|
39
|
+
| evals, testing | `skill-evals.instructions.md` | `skill-authoring.instructions.md` |
|
|
40
|
+
|
|
41
|
+
## Procedure
|
|
42
|
+
|
|
43
|
+
1. **Match topic** — fuzzy-match user's topic to doctrine cluster. If no match, list available topics with one-line descriptions.
|
|
44
|
+
2. **Load doctrine** — read the primary doctrine file + first 20 lines of each supporting file.
|
|
45
|
+
3. **Load genealogy** — find the release that introduced the feature in `docs/genealogy.md`.
|
|
46
|
+
4. **Explain** — synthesize a clear explanation with:
|
|
47
|
+
- What it does (2-3 sentences)
|
|
48
|
+
- Why it exists (the problem it solved — from genealogy)
|
|
49
|
+
- How it works (key mechanics from doctrine)
|
|
50
|
+
- Where to look (file paths)
|
|
51
|
+
- Related concepts (cross-references)
|
|
52
|
+
5. **Never modify** — this skill is read-only. No writes to State/, Evidence/, or any file.
|
|
53
|
+
|
|
54
|
+
## Gotchas
|
|
55
|
+
|
|
56
|
+
1. **Topic not found**: List available topics with `kushi explain --list`. Don't guess.
|
|
57
|
+
2. **Too broad**: If topic matches multiple clusters, ask user to narrow down.
|
|
58
|
+
3. **Version-specific**: Always note which version introduced the feature.
|
|
59
|
+
4. **No project context needed**: teach operates on the kushi repo itself, not on engagement evidence.
|
|
60
|
+
5. **Doctrine drift**: Always read the file live — don't cache doctrine content.
|
|
61
|
+
|
|
62
|
+
## Validation loop
|
|
63
|
+
|
|
64
|
+
After generating explanation:
|
|
65
|
+
1. Verify all cited file paths exist (glob check).
|
|
66
|
+
2. Verify genealogy entry referenced is real.
|
|
67
|
+
3. If a path doesn't exist, remove the citation rather than citing a phantom file.
|
|
68
|
+
|
|
69
|
+
## References
|
|
70
|
+
|
|
71
|
+
- `release-genealogy.instructions.md` — genealogy format
|
|
72
|
+
- `skill-authoring.instructions.md` — skill structure conventions
|
|
73
|
+
- `docs/genealogy.md` — release lineage
|
|
74
|
+
|
|
75
|
+
## Issue Recovery
|
|
76
|
+
|
|
77
|
+
When this skill exposes a reusable defect (doctrine gap, missing cross-reference, stale genealogy), apply the [Issue Recovery Rule](../../instructions/issue-recovery.instructions.md): fix the smallest correct repo-owned artifact first, prefer durable fixes over per-run workarounds, then re-run the narrowest failed check. Do NOT use memory as a substitute for correcting the workflow surface.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill": "teach",
|
|
3
|
+
"cases": [
|
|
4
|
+
{
|
|
5
|
+
"id": "teach-contradictions",
|
|
6
|
+
"name": "Explain contradictions handling",
|
|
7
|
+
"input": "kushi explain contradictions",
|
|
8
|
+
"expected_assertions": [
|
|
9
|
+
{ "type": "contains", "value": "living-wiki" },
|
|
10
|
+
{ "type": "contains", "value": "callout" },
|
|
11
|
+
{ "type": "contains", "value": "_review-queue" }
|
|
12
|
+
],
|
|
13
|
+
"grader_type": "script"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "teach-parallel",
|
|
17
|
+
"name": "Explain parallel refresh",
|
|
18
|
+
"input": "explain how parallel pulls work in kushi",
|
|
19
|
+
"expected_assertions": [
|
|
20
|
+
{ "type": "contains", "value": "parallel" },
|
|
21
|
+
{ "type": "contains", "value": "worker" },
|
|
22
|
+
{ "type": "contains", "value": "max_workers" }
|
|
23
|
+
],
|
|
24
|
+
"grader_type": "script"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "teach-unknown-topic",
|
|
28
|
+
"name": "Unknown topic suggests alternatives",
|
|
29
|
+
"input": "kushi explain quantum-entanglement",
|
|
30
|
+
"expected_assertions": [
|
|
31
|
+
{ "type": "not_contains", "value": "quantum" },
|
|
32
|
+
{ "type": "contains", "value": "available" }
|
|
33
|
+
],
|
|
34
|
+
"grader_type": "script"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# State/answers/
|
|
2
|
+
|
|
3
|
+
This folder contains filed-back Q&A answers from `ask-project --file-back`.
|
|
4
|
+
|
|
5
|
+
Each file is named `YYYY-MM-DD_<slug>.md` and contains the question, answer, and source citations.
|
|
6
|
+
|
|
7
|
+
These are durable knowledge artifacts — they persist across refreshes and serve as a queryable FAQ for the project.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
kushi_state_hot: true
|
|
3
|
+
generated_at: "{{generated_at}}"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Hot — {{project}}
|
|
7
|
+
|
|
8
|
+
> _Auto-generated. Entities touched in the last 7 days, ranked by recency. Content between fences is regenerated on every build-state run._
|
|
9
|
+
|
|
10
|
+
<!-- kushi:auto:start section="hot-entities" -->
|
|
11
|
+
{{hot_entities}}
|
|
12
|
+
<!-- kushi:auto:end section="hot-entities" -->
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
kushi_state_review_queue: true
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Review Queue
|
|
6
|
+
|
|
7
|
+
> _Open contradictions requiring human review. Updated by build-state when contradictions are detected. Cleared when resolved (auto or manual)._
|
|
8
|
+
|
|
9
|
+
| Entity | Property | Old value | New value | Flagged | Sources |
|
|
10
|
+
|---|---|---|---|---|---|
|
package/src/eval-runner.test.mjs
CHANGED
|
@@ -35,7 +35,7 @@ test('eval-runner: evals.schema.json validates structurally', () => {
|
|
|
35
35
|
test('eval-runner: every plugin/skills/<name>/ (except eval/) has evals/evals.json that parses', () => {
|
|
36
36
|
const skillsDir = path.join(repoRoot, 'plugin/skills');
|
|
37
37
|
const skills = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
38
|
-
.filter((d) => d.isDirectory() && d.name !== 'eval')
|
|
38
|
+
.filter((d) => d.isDirectory() && d.name !== 'eval' && !d.name.startsWith('_'))
|
|
39
39
|
.map((d) => d.name);
|
|
40
40
|
const missing = [];
|
|
41
41
|
for (const skill of skills) {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// kushi v5.2.0 — hooks dispatcher tests.
|
|
2
|
+
// Validates Invoke-Hooks.ps1 behavior: ordering, failure isolation, payload format.
|
|
3
|
+
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const invokeHooks = path.join(repoRoot, 'plugin', 'skills', '_shared', 'Invoke-Hooks.ps1');
|
|
13
|
+
|
|
14
|
+
function setupFixture(label) {
|
|
15
|
+
const root = path.resolve(repoRoot, '.testtmp', `hooks-${label}-${Date.now()}`);
|
|
16
|
+
const hooksDir = path.join(root, '.kushi', 'hooks');
|
|
17
|
+
const stateDir = path.join(root, 'Evidence', 'tester', 'State');
|
|
18
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
19
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
20
|
+
return { root, hooksDir, stateDir };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function cleanup(root) {
|
|
24
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('hooks: scripts invoked in declaration order', () => {
|
|
28
|
+
const { root, hooksDir, stateDir } = setupFixture('order');
|
|
29
|
+
try {
|
|
30
|
+
// Create two hook scripts that write to a shared file
|
|
31
|
+
const orderFile = path.join(root, 'order.txt');
|
|
32
|
+
const hook1 = path.join(hooksDir, 'first.ps1');
|
|
33
|
+
const hook2 = path.join(hooksDir, 'second.ps1');
|
|
34
|
+
// Use simple scripts that write via Out-File
|
|
35
|
+
fs.writeFileSync(hook1, `$input | Out-Null\n'first' | Out-File -Append -FilePath '${orderFile.replace(/\\/g, '/')}' -Encoding utf8`);
|
|
36
|
+
fs.writeFileSync(hook2, `$input | Out-Null\n'second' | Out-File -Append -FilePath '${orderFile.replace(/\\/g, '/')}' -Encoding utf8`);
|
|
37
|
+
|
|
38
|
+
// Create hooks.yml referencing both in order
|
|
39
|
+
const hooksYml = path.join(root, '.kushi', 'hooks.yml');
|
|
40
|
+
fs.writeFileSync(hooksYml, [
|
|
41
|
+
'hooks:',
|
|
42
|
+
' post-pull:',
|
|
43
|
+
' - type: script',
|
|
44
|
+
' path: .kushi/hooks/first.ps1',
|
|
45
|
+
' - type: script',
|
|
46
|
+
' path: .kushi/hooks/second.ps1',
|
|
47
|
+
].join('\n'));
|
|
48
|
+
|
|
49
|
+
const script = `& '${invokeHooks.replace(/\\/g, '/')}' -ProjectRoot '${root.replace(/\\/g, '/')}' -Event 'post-pull' -Payload @{ project = 'test'; source = 'email'; success = $true } -StateDir '${stateDir.replace(/\\/g, '/')}'`;
|
|
50
|
+
const r = spawnSync('pwsh', ['-NoProfile', '-Command', script], { encoding: 'utf8', timeout: 15000 });
|
|
51
|
+
|
|
52
|
+
// The helper should not crash
|
|
53
|
+
assert.equal(r.status, 0, `invoke-hooks should exit 0: stderr=${r.stderr?.substring(0, 200)}`);
|
|
54
|
+
|
|
55
|
+
// Check hooks-log.md was written
|
|
56
|
+
const logFile = path.join(stateDir, 'hooks-log.md');
|
|
57
|
+
assert.ok(fs.existsSync(logFile), `hooks-log.md should be created. stdout: ${r.stdout?.substring(0,200)}`);
|
|
58
|
+
const logContent = fs.readFileSync(logFile, 'utf8');
|
|
59
|
+
assert.match(logContent, /post-pull/, 'log should mention the event');
|
|
60
|
+
} finally {
|
|
61
|
+
cleanup(root);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('hooks: failure of one hook does not block others', () => {
|
|
66
|
+
const { root, hooksDir, stateDir } = setupFixture('failiso');
|
|
67
|
+
try {
|
|
68
|
+
// First hook throws, second should still run
|
|
69
|
+
const successFile = path.join(root, 'success.txt');
|
|
70
|
+
const hook1 = path.join(hooksDir, 'post-pull.ps1');
|
|
71
|
+
fs.writeFileSync(hook1, `throw "deliberate failure"`);
|
|
72
|
+
|
|
73
|
+
const hook2Path = path.join(hooksDir, 'ok.ps1');
|
|
74
|
+
fs.writeFileSync(hook2Path, `$input | Out-Null\n'ran' | Out-File -FilePath '${successFile.replace(/\\/g, '/')}' -Encoding utf8`);
|
|
75
|
+
|
|
76
|
+
const hooksYml = path.join(root, '.kushi', 'hooks.yml');
|
|
77
|
+
fs.writeFileSync(hooksYml, [
|
|
78
|
+
'hooks:',
|
|
79
|
+
' post-pull:',
|
|
80
|
+
' - type: script',
|
|
81
|
+
' path: .kushi/hooks/post-pull.ps1',
|
|
82
|
+
' - type: script',
|
|
83
|
+
' path: .kushi/hooks/ok.ps1',
|
|
84
|
+
].join('\n'));
|
|
85
|
+
|
|
86
|
+
const script = `& '${invokeHooks.replace(/\\/g, '/')}' -ProjectRoot '${root.replace(/\\/g, '/')}' -Event 'post-pull' -Payload @{ project = 'test'; source = 'email'; success = $true } -StateDir '${stateDir.replace(/\\/g, '/')}'`;
|
|
87
|
+
const r = spawnSync('pwsh', ['-NoProfile', '-Command', script], { encoding: 'utf8', timeout: 15000 });
|
|
88
|
+
|
|
89
|
+
// The process should NOT have crashed (exit 0)
|
|
90
|
+
assert.equal(r.status, 0, `hook dispatcher should not crash: ${r.stderr?.substring(0,200)}`);
|
|
91
|
+
// Second hook should have run
|
|
92
|
+
assert.ok(fs.existsSync(successFile), `second hook should still run after first fails. stdout=${r.stdout?.substring(0,200)}`);
|
|
93
|
+
} finally {
|
|
94
|
+
cleanup(root);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('hooks: webhook payload has correct JSON structure', () => {
|
|
99
|
+
// We can't test real webhooks, but we can verify the payload format
|
|
100
|
+
// by checking the Invoke-Hooks.ps1 script constructs proper JSON
|
|
101
|
+
const content = fs.readFileSync(invokeHooks, 'utf8');
|
|
102
|
+
// Verify it converts payload to JSON
|
|
103
|
+
assert.match(content, /ConvertTo-Json/, 'must serialize payload to JSON');
|
|
104
|
+
// Verify it uses Invoke-RestMethod for webhooks
|
|
105
|
+
assert.match(content, /Invoke-RestMethod/, 'must use Invoke-RestMethod for webhooks');
|
|
106
|
+
// Verify Content-Type header
|
|
107
|
+
assert.match(content, /application\/json/, 'must set JSON content type');
|
|
108
|
+
// Verify timeout
|
|
109
|
+
assert.match(content, /TimeoutSec/, 'must set a timeout for webhooks');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('hooks: hooks-log.md format has correct heading structure', () => {
|
|
113
|
+
const { root, hooksDir, stateDir } = setupFixture('logfmt');
|
|
114
|
+
try {
|
|
115
|
+
const hook = path.join(hooksDir, 'post-pull.ps1');
|
|
116
|
+
fs.writeFileSync(hook, `$input | Out-Null; Write-Output 'ok'`);
|
|
117
|
+
|
|
118
|
+
const script = `
|
|
119
|
+
$payload = @{ project = 'Acme'; source = 'teams'; success = $true; duration_ms = 150 }
|
|
120
|
+
& '${invokeHooks.replace(/\\/g, '\\\\')}' -ProjectRoot '${root.replace(/\\/g, '\\\\')}' -Event 'post-pull' -Payload $payload -StateDir '${stateDir.replace(/\\/g, '\\\\')}'
|
|
121
|
+
`;
|
|
122
|
+
spawnSync('pwsh', ['-NoProfile', '-Command', script], { encoding: 'utf8' });
|
|
123
|
+
|
|
124
|
+
const logFile = path.join(stateDir, 'hooks-log.md');
|
|
125
|
+
if (fs.existsSync(logFile)) {
|
|
126
|
+
const content = fs.readFileSync(logFile, 'utf8');
|
|
127
|
+
// Check heading format matches: ## [YYYY-MM-DD HH:MM] <event> | hook: <name>
|
|
128
|
+
assert.match(content, /## \[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\] post-pull \| hook:/, 'heading format must be canonical');
|
|
129
|
+
assert.match(content, /- status:/, 'must include status field');
|
|
130
|
+
assert.match(content, /- duration_ms:/, 'must include duration field');
|
|
131
|
+
}
|
|
132
|
+
} finally {
|
|
133
|
+
cleanup(root);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// kushi v5.2.0 — OTel emit tests.
|
|
2
|
+
// Validates no-op behavior, OTLP payload format, and privacy contract.
|
|
3
|
+
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const emitOtel = path.join(repoRoot, 'plugin', 'skills', '_shared', 'Emit-OtelSpan.ps1');
|
|
13
|
+
|
|
14
|
+
test('otel: no-op when KUSHI_OTEL_ENDPOINT is unset', () => {
|
|
15
|
+
// Run the helper with no endpoint — should exit cleanly with no output
|
|
16
|
+
const script = `
|
|
17
|
+
$env:KUSHI_OTEL_ENDPOINT = ''
|
|
18
|
+
& '${emitOtel.replace(/\\/g, '\\\\')}' -SpanName 'kushi.pull' -Attributes @{ project='test'; success=$true }
|
|
19
|
+
Write-Output 'DONE'
|
|
20
|
+
`;
|
|
21
|
+
const r = spawnSync('pwsh', ['-NoProfile', '-Command', script], {
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
env: { ...process.env, KUSHI_OTEL_ENDPOINT: '' }
|
|
24
|
+
});
|
|
25
|
+
assert.equal(r.status, 0, `should exit cleanly: ${r.stderr}`);
|
|
26
|
+
assert.match(r.stdout, /DONE/, 'script should complete without error');
|
|
27
|
+
// Should NOT contain any HTTP-related output
|
|
28
|
+
assert.ok(!r.stdout.includes('Invoke-RestMethod'), 'should not attempt HTTP when unset');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('otel: helper script has correct OTLP payload structure', () => {
|
|
32
|
+
const content = fs.readFileSync(emitOtel, 'utf8');
|
|
33
|
+
// Verify OTLP JSON structure elements
|
|
34
|
+
assert.match(content, /resourceSpans/, 'must build resourceSpans');
|
|
35
|
+
assert.match(content, /scopeSpans/, 'must build scopeSpans');
|
|
36
|
+
assert.match(content, /traceId/, 'must include traceId');
|
|
37
|
+
assert.match(content, /spanId/, 'must include spanId');
|
|
38
|
+
assert.match(content, /startTimeUnixNano/, 'must include start time in nanos');
|
|
39
|
+
assert.match(content, /endTimeUnixNano/, 'must include end time in nanos');
|
|
40
|
+
assert.match(content, /service\.name.*kushi/, 'service.name must be kushi');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('otel: privacy — only allowed attribute keys pass through', () => {
|
|
44
|
+
const content = fs.readFileSync(emitOtel, 'utf8');
|
|
45
|
+
// Verify allowlist exists
|
|
46
|
+
assert.match(content, /allowedKeys/, 'must have an allowedKeys filter');
|
|
47
|
+
// Verify common safe keys are in the list
|
|
48
|
+
assert.match(content, /'project'/, 'project must be allowed');
|
|
49
|
+
assert.match(content, /'source'/, 'source must be allowed');
|
|
50
|
+
assert.match(content, /'success'/, 'success must be allowed');
|
|
51
|
+
// Verify filtering logic — keys not in allowed are skipped
|
|
52
|
+
assert.match(content, /\-notin \$allowedKeys/, 'must skip keys not in allowlist');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('otel: emits correct span kind (INTERNAL)', () => {
|
|
56
|
+
const content = fs.readFileSync(emitOtel, 'utf8');
|
|
57
|
+
// SPAN_KIND_INTERNAL = 1
|
|
58
|
+
assert.match(content, /kind\s*=\s*1/, 'span kind must be INTERNAL (1)');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('otel: handles missing endpoint gracefully without errors', () => {
|
|
62
|
+
// Run with explicitly empty endpoint
|
|
63
|
+
const script = `
|
|
64
|
+
Remove-Item Env:KUSHI_OTEL_ENDPOINT -ErrorAction SilentlyContinue
|
|
65
|
+
& '${emitOtel.replace(/\\/g, '\\\\')}' -SpanName 'kushi.lint' -Attributes @{ findings_count=5 }
|
|
66
|
+
exit $LASTEXITCODE
|
|
67
|
+
`;
|
|
68
|
+
const r = spawnSync('pwsh', ['-NoProfile', '-Command', script], {
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
env: { ...process.env, KUSHI_OTEL_ENDPOINT: undefined }
|
|
71
|
+
});
|
|
72
|
+
assert.equal(r.status, 0, 'must not fail when endpoint is missing');
|
|
73
|
+
});
|