778 lines
24 KiB
Plaintext
Raw Normal View History

2026-03-13 00:21:04 +00:00
# .coder/ management — author workspaces, inbox triage, insight graduation
#
# Structure:
# .coder/<author>/author.ncl — identity (validated)
# .coder/<author>/inbox/ — dump zone, zero ceremony
# .coder/<author>/<category>/ — classified content
# .coder/<author>/<category>/context.ncl — category metadata (validated)
# .coder/general/<category>/ — published from all authors
#
# File naming: {description}.{kind}.md where kind ∈ {done,plan,info,review,audit,commit}
# Legacy files with _ separator are accepted and normalized during triage.
use store.nu [daemon-export-safe]
const VALID_KINDS = ["done", "plan", "info", "review", "audit", "commit"]
def coder-root []: nothing -> string {
let root = if ($env.ONTOREF_PROJECT_ROOT? | is-not-empty) {
$env.ONTOREF_PROJECT_ROOT
} else {
$env.PWD
}
$"($root)/.coder"
}
# Extract kind from filename, handling both . and _ separators
def extract-kind [filename: string]: nothing -> string {
let base = ($filename | str replace '.md' '')
for kind in $VALID_KINDS {
if ($base | str ends-with $".($kind)") or ($base | str ends-with $"_($kind)") {
return $kind
}
}
""
}
# Map file kind to triage category
def kind-to-category [kind: string]: nothing -> string {
match $kind {
"done" => "features"
"plan" => "features"
"info" => "investigations"
"review" => "reviews"
"audit" => "reviews"
"commit" => "features"
_ => ""
}
}
# Classify by content keywords (fallback when kind is empty)
def classify-by-content [filepath: string]: nothing -> string {
let first_lines = (open $filepath | lines | first 5 | str join " " | str downcase)
if ($first_lines =~ "insight|til |learned|pattern|trap|gotcha") {
"insights"
} else if ($first_lines =~ "fix|bug|error|broken|crash|hotfix") {
"bugfixes"
} else if ($first_lines =~ "decision|chose|adr|rejected|alternative") {
"decisions"
} else if ($first_lines =~ "plan|implement|design|proposal") {
"features"
} else {
""
}
}
# Extract title from markdown first heading or filename
def extract-title [filepath: string]: nothing -> string {
let lines = (open $filepath | lines)
let heading = ($lines | where { $in =~ '^#+ ' } | first | default "")
if ($heading | is-not-empty) {
$heading | str replace -r '^#+\s*' '' | str trim
} else {
# Fall back to filename without extension and kind suffix
mut base = ($filepath | path basename | str replace '.md' '')
for kind in $VALID_KINDS {
$base = ($base | str replace $".($kind)" '' | str replace $"_($kind)" '')
}
$base | str replace -a '_' ' ' | str replace -a '-' ' '
}
}
# Extract date from filename (YYYY-MM-DD or YYYYMMDD prefix).
# Nushell str substring ranges are inclusive on both ends.
def extract-date [filename: string]: nothing -> string {
if ($filename =~ '^\d{4}-\d{2}-\d{2}') {
$filename | str substring 0..9
} else if ($filename =~ '^\d{8}') {
let raw = ($filename | str substring 0..7)
let y = ($raw | str substring 0..3)
let m = ($raw | str substring 4..5)
let d = ($raw | str substring 6..7)
$"($y)-($m)-($d)"
} else {
""
}
}
# Extract keyword tags from content
def extract-tags [filepath: string]: nothing -> list<string> {
let content = (open $filepath | str downcase)
let tag_map = [
["nickel", "nickel"], ["nushell", "nushell"], ["rust", "rust"],
["cargo", "cargo"], ["bacon", "bacon"], ["ci", "ci"],
["encryption", "encryption"], ["ontology", "ontology"],
["surrealdb", "surrealdb"], ["nats", "nats"],
["agent", "agent"], ["llm", "llm"], ["docker", "docker"],
]
mut tags = []
for pair in $tag_map {
let keyword = ($pair | get 0)
let tag = ($pair | get 1)
if ($content =~ $keyword) {
$tags = ($tags | append $tag)
}
}
$tags
}
# Map category string to NCL enum value
def category-to-ncl [cat: string]: nothing -> string {
match $cat {
"insights" => "'Insight"
"features" => "'Feature"
"bugfixes" => "'Bugfix"
"investigations" => "'Investigation"
"decisions" => "'Decision"
"reviews" => "'Review"
"resources" => "'Resource"
_ => "'Inbox"
}
}
# Generate companion NCL for a classified file
def generate-companion-ncl [
filepath: string
author: string
kind: string
category: string
]: nothing -> string {
let title = (extract-title $filepath)
let date = (extract-date ($filepath | path basename))
let tags = (extract-tags $filepath)
let kind_ncl = if ($kind | is-empty) { "'unknown" } else { $"'($kind)" }
let cat_ncl = (category-to-ncl $category)
let tags_ncl = ($tags | each {|t| $"\"($t)\""} | str join ", ")
let safe_title = ($title | str replace -a '"' '\\"')
let title_line = $' title = "($safe_title)",'
let author_line = $' author = "($author)",'
let kind_line = $' kind = ($kind_ncl),'
let cat_line = $' category = ($cat_ncl),'
let date_line = if ($date | is-not-empty) { $' date = "($date)",' } else { "" }
let tags_line = if ($tags | is-not-empty) { $' tags = [($tags_ncl)],' } else { "" }
([
'let d = import "coder-defaults.ncl" in'
'let c = import "coder-constraints.ncl" in'
''
'(d.make_entry {'
$title_line
$date_line
$author_line
$kind_line
$cat_line
$tags_line
'}) | c.NonEmptyTitle | c.NonEmptyAuthor'
] | where {|line| ($line | str length) > 0 } | str join "\n")
}
# List all authors in .coder/
export def "coder authors" []: nothing -> table {
let root = (coder-root)
if not ($root | path exists) { return [] }
ls $root
| where type == "dir"
| where { ($in.name | path basename) != "general" and ($in.name | path basename) != "insights" }
| each {|d|
let author_ncl = $"($d.name)/author.ncl"
let name = ($d.name | path basename)
let has_meta = ($author_ncl | path exists)
{ name: $name, has_metadata: $has_meta, path: $d.name }
}
}
# Initialize an author workspace
export def "coder init" [
author: string
--actor: string = "Human"
--model: string = ""
]: nothing -> string {
let root = (coder-root)
let author_dir = $"($root)/($author)"
let inbox_dir = $"($author_dir)/inbox"
if ($author_dir | path exists) {
return $"author '($author)' already exists at ($author_dir)"
}
mkdir $inbox_dir
let actor_value = match $actor {
"Human" => "'Human"
"AgentClaude" => "'AgentClaude"
"AgentCustom" => "'AgentCustom"
"CI" => "'CI"
_ => "'Human"
}
let model_line = if ($model | is-empty) { "" } else { $" model = \"($model)\"," }
let ncl_content = ([
'let s = import "coder.ncl" in'
''
's.Author & {'
$" name = \"($author)\","
$" actor = ($actor_value),"
$model_line
'}'
] | where {|line| ($line | str length) > 0 } | str join "\n")
$ncl_content | save --force $"($author_dir)/author.ncl"
$"initialized ($author_dir) with inbox/"
}
# Triage: classify files in an author's inbox/ into categories
export def "coder triage" [
author: string
--dry-run (-n)
--interactive (-i)
]: nothing -> table {
let root = (coder-root)
let inbox_dir = $"($root)/($author)/inbox"
if not ($inbox_dir | path exists) {
print $"no inbox/ at ($inbox_dir)"
return []
}
let files = (ls $inbox_dir | where type == "file")
if ($files | is-empty) {
print "inbox/ is empty"
return []
}
mut results = []
for file in $files {
let filename = ($file.name | path basename)
let kind = (extract-kind $filename)
let by_kind = (kind-to-category $kind)
let by_content = (classify-by-content $file.name)
let category = if ($by_kind | is-not-empty) {
$by_kind
} else if ($by_content | is-not-empty) {
$by_content
} else {
"unclassified"
}
let target_dir = $"($root)/($author)/($category)"
if $category == "unclassified" {
$results = ($results | append { file: $filename, kind: $kind, category: $category, action: "skip", ncl: "" })
} else if $dry_run {
$results = ($results | append { file: $filename, kind: $kind, category: $category, action: "would move", ncl: "" })
} else if $interactive {
let answer = (input $"($filename) → ($category)? [y/n/c] (c=change category) ")
if ($answer | str starts-with "y") {
let ncl = (generate-companion-ncl $file.name $author $kind $category)
let ncl_name = ($filename | str replace '.md' '.ncl')
mkdir $target_dir
mv $file.name $"($target_dir)/($filename)"
$ncl | save --force $"($target_dir)/($ncl_name)"
$results = ($results | append { file: $filename, kind: $kind, category: $category, action: "moved", ncl: $ncl_name })
} else if ($answer | str starts-with "c") {
let new_cat = (input "category: ")
let ncl = (generate-companion-ncl $file.name $author $kind $new_cat)
let ncl_name = ($filename | str replace '.md' '.ncl')
let new_dir = $"($root)/($author)/($new_cat)"
mkdir $new_dir
mv $file.name $"($new_dir)/($filename)"
$ncl | save --force $"($new_dir)/($ncl_name)"
$results = ($results | append { file: $filename, kind: $kind, category: $new_cat, action: "moved", ncl: $ncl_name })
} else {
$results = ($results | append { file: $filename, kind: $kind, category: $category, action: "skipped", ncl: "" })
}
} else {
let ncl = (generate-companion-ncl $file.name $author $kind $category)
let ncl_name = ($filename | str replace '.md' '.ncl')
mkdir $target_dir
mv $file.name $"($target_dir)/($filename)"
$ncl | save --force $"($target_dir)/($ncl_name)"
$results = ($results | append { file: $filename, kind: $kind, category: $category, action: "moved", ncl: $ncl_name })
}
}
$results
}
# Publish: promote files from author workspace to .coder/general/
# Renames files to include author: {author}-{original-name}
export def "coder publish" [
author: string
category: string
--dry-run (-n)
--all (-a) # publish all files in category (default: interactive pick)
]: nothing -> table {
let root = (coder-root)
let source_dir = $"($root)/($author)/($category)"
if not ($source_dir | path exists) {
print $"no ($category)/ for author ($author)"
return []
}
let general_dir = $"($root)/general/($category)"
let files = (ls $source_dir
| where type == "file"
| where { ($in.name | path basename) != "context.ncl" })
if ($files | is-empty) {
print $"no files in ($category)/"
return []
}
mut results = []
for file in $files {
let fname = ($file.name | path basename)
let target_name = $"($author)-($fname)"
if (not $all) and (not $dry_run) {
let answer = (input $"publish ($fname) as ($target_name)? [y/n] ")
if not ($answer | str starts-with "y") {
$results = ($results | append { file: $fname, target: $target_name, action: "skipped" })
continue
}
}
if $dry_run {
$results = ($results | append { file: $fname, target: $target_name, action: "would publish" })
} else {
mkdir $general_dir
cp $file.name $"($general_dir)/($target_name)"
$results = ($results | append { file: $fname, target: $target_name, action: "published" })
}
}
$results
}
# List contents of an author workspace or general, grouped by category
export def "coder ls" [
author: string = ""
--category (-c): string = ""
]: nothing -> table {
let root = (coder-root)
if not ($root | path exists) { return [] }
let search_dir = if ($author | is-empty) {
$"($root)/general"
} else {
$"($root)/($author)"
}
if not ($search_dir | path exists) {
print $"directory not found: ($search_dir)"
return []
}
let dirs = (ls $search_dir | where type == "dir")
mut items = []
for dir in $dirs {
let cat = ($dir.name | path basename)
if ($category | is-not-empty) and ($cat != $category) { continue }
let files = (ls $dir.name
| where type == "file"
| where { let b = ($in.name | path basename); $b != "context.ncl" and $b != "author.ncl" })
for file in $files {
let fname = ($file.name | path basename)
let kind = (extract-kind $fname)
let date = if ($fname =~ '^\d{4}-\d{2}-\d{2}') or ($fname =~ '^\d{8}') {
$fname | str substring 0..9
} else {
""
}
$items = ($items | append {
category: $cat,
file: $fname,
kind: $kind,
date: $date,
modified: $file.modified,
})
}
}
$items | sort-by modified --reverse
}
# Search across all authors by content pattern
export def "coder search" [
pattern: string
--author (-a): string = ""
]: nothing -> table {
let root = (coder-root)
if not ($root | path exists) { return [] }
let search_path = if ($author | is-not-empty) {
$"($root)/($author)"
} else {
$root
}
let md_files = (glob $"($search_path)/**/*.md")
mut matches = []
for file in $md_files {
let content = (open $file | default "")
if ($content =~ $pattern) {
let rel = ($file | str replace $"($root)/" "")
let parts = ($rel | split row "/")
let file_author = if ($parts | length) > 1 { $parts | first } else { "root" }
let fname = ($parts | last)
$matches = ($matches | append { author: $file_author, file: $fname, path: $rel })
}
}
$matches
}
# ── JSON record commands ──────────────────────────────────────────────────────
# Structured entries stored as JSONL (one JSON object per line).
# Machine-readable alternative to markdown — no companion NCL needed.
# Storage: .coder/<author>/<category>/entries.jsonl
const VALID_CATEGORIES = ["insights", "features", "bugfixes", "investigations", "decisions", "reviews", "resources"]
const VALID_DOMAINS = ["Language", "Runtime", "Architecture", "Tooling", "Pattern", "Debugging", "Security", "Performance"]
# Record a structured JSON entry into a category's JSONL log
export def "coder record" [
author: string
--kind (-k): string = "info"
--category (-c): string = ""
--title (-t): string
--tags: list<string> = []
--relates_to: list<string> = []
--trigger: string = ""
--files_touched: list<string> = []
--domain (-d): string = ""
--reusable (-r)
content: string
]: nothing -> record {
let root = (coder-root)
let author_dir = $"($root)/($author)"
if not ($author_dir | path exists) {
error make { msg: $"author '($author)' not initialized — run: coder init ($author)" }
}
if ($title | str trim | is-empty) {
error make { msg: "title must not be empty" }
}
if ($kind | is-not-empty) and (not ($kind in $VALID_KINDS)) {
error make { msg: $"invalid kind '($kind)' — valid: ($VALID_KINDS | str join ', ')" }
}
let resolved_category = if ($category | is-not-empty) {
$category
} else {
let by_kind = (kind-to-category $kind)
if ($by_kind | is-not-empty) { $by_kind } else { "inbox" }
}
if ($resolved_category != "inbox") and (not ($resolved_category in $VALID_CATEGORIES)) {
error make { msg: $"invalid category '($resolved_category)' — valid: ($VALID_CATEGORIES | str join ', ')" }
}
let target_dir = $"($author_dir)/($resolved_category)"
mkdir $target_dir
let now = (date now | format date "%Y-%m-%d")
mut entry = {
title: $title,
kind: $kind,
category: $resolved_category,
author: $author,
date: $now,
tags: $tags,
relates_to: $relates_to,
content: $content,
}
if ($trigger | is-not-empty) or ($files_touched | is-not-empty) {
$entry = ($entry | merge {
context: {
trigger: $trigger,
files_touched: $files_touched,
}
})
}
if ($domain | is-not-empty) {
if not ($domain in $VALID_DOMAINS) {
error make { msg: $"invalid domain '($domain)' — valid: ($VALID_DOMAINS | str join ', ')" }
}
$entry = ($entry | merge { domain: $domain, reusable: $reusable })
}
let jsonl_path = $"($target_dir)/entries.jsonl"
let json_line = ($entry | to json --raw)
$"($json_line)\n" | save --raw --append $jsonl_path
$entry
}
# Query JSONL entries across categories/authors with optional filters
export def "coder log" [
--author (-a): string = ""
--category (-c): string = ""
--tag (-t): string = ""
--kind (-k): string = ""
--domain (-d): string = ""
--limit (-l): int = 0
--json (-j)
]: nothing -> table {
let root = (coder-root)
if not ($root | path exists) { return [] }
let pattern = if ($author | is-not-empty) and ($category | is-not-empty) {
$"($root)/($author)/($category)/entries.jsonl"
} else if ($author | is-not-empty) {
$"($root)/($author)/**/entries.jsonl"
} else if ($category | is-not-empty) {
$"($root)/**/($category)/entries.jsonl"
} else {
$"($root)/**/entries.jsonl"
}
let files = (glob $pattern)
if ($files | is-empty) { return [] }
mut entries = []
for file in $files {
let lines = (open $file | lines | where { $in | str trim | is-not-empty })
for line in $lines {
# Guard: only attempt JSON parse on lines that look like objects.
# from json is an internal command — errors cannot be captured with | complete.
let trimmed = ($line | str trim)
if ($trimmed | str starts-with "{") and ($trimmed | str ends-with "}") {
let parsed = ($trimmed | from json)
if ($parsed | describe | str starts-with "record") {
$entries = ($entries | append $parsed)
}
}
}
}
if ($tag | is-not-empty) {
$entries = ($entries | where { ($in.tags? | default []) | any {|t| $t == $tag } })
}
if ($kind | is-not-empty) {
$entries = ($entries | where { ($in.kind? | default "") == $kind })
}
if ($domain | is-not-empty) {
$entries = ($entries | where { ($in.domain? | default "") == $domain })
}
let sorted = ($entries | sort-by date --reverse)
if $limit > 0 {
$sorted | first $limit
} else {
$sorted
}
}
# Export all JSONL entries as a single JSON array (for external tools/databases)
export def "coder export" [
--author (-a): string = ""
--category (-c): string = ""
--format (-f): string = "json"
]: nothing -> string {
let entries = (coder log --author $author --category $category)
match $format {
"json" => { $entries | to json }
"jsonl" => { $entries | each { to json --raw } | str join "\n" }
"csv" => {
$entries
| select title kind category author date tags
| update tags { str join ";" }
| to csv
}
_ => { error make { msg: $"unsupported format '($format)' — use json, jsonl, or csv" } }
}
}
# Graduate: copy items to committed project path (e.g. reflection/knowledge/)
export def "coder graduate" [
source_category: string # "general/insights" or "jesus/insights"
--target (-t): string = "reflection/knowledge"
--dry-run (-n)
]: nothing -> table {
let root = (coder-root)
let source_dir = $"($root)/($source_category)"
if not ($source_dir | path exists) {
print $"not found: ($source_dir)"
return []
}
# Check if category is graduable
let context_path = $"($source_dir)/context.ncl"
if ($context_path | path exists) {
let ctx = (daemon-export-safe $context_path)
if ($ctx != null) {
if not ($ctx.graduable? | default false) {
print $"'($source_category)' is not marked graduable in context.ncl"
return []
}
}
}
let project_root = if ($env.ONTOREF_PROJECT_ROOT? | is-not-empty) {
$env.ONTOREF_PROJECT_ROOT
} else {
$env.PWD
}
let target_dir = $"($project_root)/($target)"
let files = (ls $source_dir
| where type == "file"
| where { ($in.name | path basename) != "context.ncl" })
mut results = []
for file in $files {
let fname = ($file.name | path basename)
if $dry_run {
$results = ($results | append { file: $fname, action: "would graduate", target: $target_dir })
} else {
mkdir $target_dir
cp $file.name $"($target_dir)/($fname)"
$results = ($results | append { file: $fname, action: "graduated", target: $target_dir })
}
}
$results
}
feat: mode guards, convergence, manifest coverage, doc authoring pattern ## Mode guards and convergence loops (ADR-011) - `Guard` and `Converge` types added to `reflection/schema.ncl` and `reflection/defaults.ncl`. Guards run pre-flight checks (Block/Warn); converge loops iterate until a condition is met (RetryFailed/RetryAll). - `sync-ontology.ncl`: 3 guards + converge (zero-drift condition, max 2 iter). - `coder-workflow.ncl`: guard (coder-dir-exists) + `novelty-check` step. - Rust types in `ontoref-reflection/src/mode.rs`; executor in `executor.rs` evaluates guards before steps and convergence loop after. - `adrs/adr-011-mode-guards-and-convergence.ncl` added. ## Manifest capability completeness - `.ontology/manifest.ncl`: 3 → 19 declared capabilities covering the full action surface (daemon API, modes, Task Composer, QA, bookmarks, etc.). - `sync.nu`: `audit-manifest-coverage` + `sync manifest-check` command. - `validate-project.ncl`: 6th category `manifest-cov`. - Pre-commit hook `manifest-coverage` added. - Migrations `0010-manifest-capability-completeness`, `0011-manifest-coverage-hooks`. ## Rust doc authoring pattern — canonical `///` convention - `#[onto_api]`: `description = "..."` optional when `///` doc comment exists above handler — first line used as fallback. `#[derive(OntologyNode)]` same. - `ontoref-daemon/src/api.rs`: 42 handlers migrated to `///` doc comments; `description = "..."` removed from all `#[onto_api]` blocks. - `sync diff --docs --fail-on-drift`: exits 1 on crate `//!` drift; used by new `docs-drift` pre-commit hook. `docs-links` hook checks rustdoc broken links. - `generator.nu`: mdBook `crates/` chapter — per-crate page from `//!` doc, coverage badge, feature flags, implementing practice nodes. - `.claude/CLAUDE.md`: `### Documentation Authoring (Rust)` section added. - Migration `0012-rust-doc-authoring-pattern`. ## OntologyNode derive fixes - `#[derive(OntologyNode)]`: `name` and `paths` attributes supported; `///` doc fallback for `description`; `artifact_paths` correctly populated. - `Core::from_value` calls `merge_contributors()` behind `#[cfg(feature = "derive")]`. ## Bug fixes - `sync.nu` drift check: exact crate path match (not `str starts-with`); first-path-only rule; split on `. ` not `.` to avoid `.ontology/` truncation. - `find-unclaimed-artifacts`: fixed absolute vs relative path comparison. - Rustdoc broken intra-doc links fixed across all three crates. - `ci-docs` recipe now sets `RUSTDOCFLAGS` and actually fails on errors. mode guards/converge, manifest coverage validation, 19 capabilities (ADR-011) Extend the mode schema with Guard (pre-flight Block/Warn checks) and Converge (RetryFailed/RetryAll post-execution loops) — protocol pushes back on invalid state and iterates until convergence. ADR-011 records the decision to extend modes rather than create a separate action subsystem. Manifest expanded from 3 to 19 capabilities covering the full action surface (compose, plans, backlog graduation, notifications, coder pipeline, forms, templates, drift, quick actions, migrations, config, onboarding). New audit-manifest-coverage validator + pre-commit hook + SessionStart hook ensure agents always see complete project self-description. Bug fix: find-unclaimed-artifacts absolute vs relative path comparison — 19 phantom MISSING items resolved. Health 43% → 100%. Anti-slop: coder novelty-check step (Jaccard overlap against published+QA) inserted between triage and publish in coder-workflow. Justfile restructured into 5 modules (build/test/dev/ci/assets). Migrations 0010-0011 propagate requirements to consumer projects.
2026-03-30 19:08:25 +01:00
# Check pending entries for novelty against published entries and QA store.
# Flags entries whose content overlaps significantly (Jaccard > threshold)
# with existing knowledge — potential slop that adds no new information.
export def "coder novelty-check" [
author: string
--threshold: float = 0.60 # Jaccard overlap above this = flagged as redundant
--category (-c): string = "" # Check specific category, default all
]: nothing -> table {
let root = (coder-root)
let author_dir = $"($root)/($author)"
if not ($author_dir | path exists) {
error make { msg: $"author '($author)' not initialized" }
}
# Collect pending entries from author workspace
let pending_pattern = if ($category | is-not-empty) {
$"($author_dir)/($category)/entries.jsonl"
} else {
$"($author_dir)/**/entries.jsonl"
}
let pending_files = (glob $pending_pattern)
mut pending_entries = []
for file in $pending_files {
let lines = (open $file | lines | where { $in | str trim | is-not-empty })
for line in $lines {
let trimmed = ($line | str trim)
if ($trimmed | str starts-with "{") and ($trimmed | str ends-with "}") {
let parsed = ($trimmed | from json)
if ($parsed | describe | str starts-with "record") {
$pending_entries = ($pending_entries | append $parsed)
}
}
}
}
if ($pending_entries | is-empty) { return [] }
# Collect existing published entries
let published_pattern = $"($root)/general/**/entries.jsonl"
let pub_files = (glob $published_pattern)
mut published = []
for file in $pub_files {
let lines = (open $file | lines | where { $in | str trim | is-not-empty })
for line in $lines {
let trimmed = ($line | str trim)
if ($trimmed | str starts-with "{") and ($trimmed | str ends-with "}") {
let parsed = ($trimmed | from json)
if ($parsed | describe | str starts-with "record") {
$published = ($published | append $parsed)
}
}
}
}
# Collect QA entries
let project_root = ($env.ONTOREF_PROJECT_ROOT? | default ($env.ONTOREF_ROOT? | default "."))
let qa_file = $"($project_root)/reflection/qa.ncl"
mut qa_entries = []
if ($qa_file | path exists) {
let qa_data = (do { nickel export --format json $qa_file } | complete)
if $qa_data.exit_code == 0 {
let parsed = ($qa_data.stdout | from json)
$qa_entries = ($parsed.entries? | default [] | each { |e|
{ title: ($e.question? | default ""), content: ($e.answer? | default "") }
})
}
}
let existing = ($published | append $qa_entries)
if ($existing | is-empty) { return [] }
# Build existing content corpus
let existing_texts = ($existing | each { |e|
$"($e.title? | default '') ($e.content? | default '')"
})
let stop_words = ["that", "this", "with", "from", "have", "will", "been", "when", "they", "each", "does", "also", "into", "than"]
mut results = []
for entry in $pending_entries {
let entry_text = $"($entry.title? | default '') ($entry.content? | default '')"
let entry_words = ($entry_text | str downcase | split row --regex '\W+'
| where { |w| ($w | str length) > 3 and not ($w in $stop_words) }
| sort | uniq)
if ($entry_words | is-empty) { continue }
mut max_overlap = 0.0
mut most_similar = ""
for existing_text in $existing_texts {
let ex_words = ($existing_text | str downcase | split row --regex '\W+'
| where { |w| ($w | str length) > 3 and not ($w in $stop_words) }
| sort | uniq)
if ($ex_words | is-empty) { continue }
let intersection = ($entry_words | where { |w| $w in $ex_words } | length)
let union_size = ($entry_words | append $ex_words | sort | uniq | length)
let jaccard = (($intersection | into float) / ($union_size | into float))
if $jaccard > $max_overlap {
$max_overlap = $jaccard
$most_similar = ($existing_text | str substring 0..80)
}
}
let status = if $max_overlap > $threshold { "REDUNDANT" } else if $max_overlap > ($threshold * 0.7) { "SIMILAR" } else { "NOVEL" }
$results = ($results | append {
title: ($entry.title? | default "?"),
status: $status,
overlap: ($max_overlap * 100.0 | math round),
similar_to: $most_similar,
})
}
$results
}