@ag-eco/agentplate-cli 0.13.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/LICENSE +21 -0
- package/README.md +462 -0
- package/agents/ap-co-creation.md +90 -0
- package/agents/builder.md +144 -0
- package/agents/coordinator.md +377 -0
- package/agents/lead.md +435 -0
- package/agents/merger.md +164 -0
- package/agents/monitor.md +214 -0
- package/agents/orchestrator.md +239 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +125 -0
- package/agents/supervisor.md +427 -0
- package/package.json +66 -0
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/copilot-hooks-deployer.test.ts +162 -0
- package/src/agents/copilot-hooks-deployer.ts +93 -0
- package/src/agents/guard-rules.test.ts +372 -0
- package/src/agents/guard-rules.ts +97 -0
- package/src/agents/headless-mail-injector.test.ts +709 -0
- package/src/agents/headless-mail-injector.ts +377 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +3119 -0
- package/src/agents/hooks-deployer.ts +804 -0
- package/src/agents/identity.test.ts +604 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/mail-poll-detect.test.ts +153 -0
- package/src/agents/mail-poll-detect.ts +73 -0
- package/src/agents/manifest.test.ts +1026 -0
- package/src/agents/manifest.ts +376 -0
- package/src/agents/overlay.test.ts +1058 -0
- package/src/agents/overlay.ts +490 -0
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +2312 -0
- package/src/agents/turn-runner.ts +1383 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +230 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +328 -0
- package/src/commands/agents.ts +299 -0
- package/src/commands/clean.test.ts +797 -0
- package/src/commands/clean.ts +791 -0
- package/src/commands/completions.test.ts +348 -0
- package/src/commands/completions.ts +981 -0
- package/src/commands/coordinator.test.ts +2975 -0
- package/src/commands/coordinator.ts +1841 -0
- package/src/commands/costs.test.ts +1183 -0
- package/src/commands/costs.ts +599 -0
- package/src/commands/dashboard.test.ts +954 -0
- package/src/commands/dashboard.ts +1212 -0
- package/src/commands/discover.test.ts +288 -0
- package/src/commands/discover.ts +202 -0
- package/src/commands/doctor.test.ts +303 -0
- package/src/commands/doctor.ts +311 -0
- package/src/commands/ecosystem.test.ts +226 -0
- package/src/commands/ecosystem.ts +248 -0
- package/src/commands/errors.test.ts +654 -0
- package/src/commands/errors.ts +197 -0
- package/src/commands/feed.test.ts +709 -0
- package/src/commands/feed.ts +260 -0
- package/src/commands/group.test.ts +475 -0
- package/src/commands/group.ts +546 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +263 -0
- package/src/commands/init.test.ts +1011 -0
- package/src/commands/init.ts +967 -0
- package/src/commands/inspect.test.ts +1239 -0
- package/src/commands/inspect.ts +648 -0
- package/src/commands/log.test.ts +1913 -0
- package/src/commands/log.ts +958 -0
- package/src/commands/logs.test.ts +801 -0
- package/src/commands/logs.ts +483 -0
- package/src/commands/mail.test.ts +1501 -0
- package/src/commands/mail.ts +848 -0
- package/src/commands/merge.test.ts +864 -0
- package/src/commands/merge.ts +381 -0
- package/src/commands/metrics.test.ts +458 -0
- package/src/commands/metrics.ts +129 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +409 -0
- package/src/commands/nudge.test.ts +579 -0
- package/src/commands/nudge.ts +646 -0
- package/src/commands/orchestrator.ts +42 -0
- package/src/commands/prime.test.ts +612 -0
- package/src/commands/prime.ts +359 -0
- package/src/commands/replay.test.ts +757 -0
- package/src/commands/replay.ts +231 -0
- package/src/commands/run.test.ts +469 -0
- package/src/commands/run.ts +353 -0
- package/src/commands/serve/agent-actions.test.ts +210 -0
- package/src/commands/serve/agent-actions.ts +192 -0
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +410 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1680 -0
- package/src/commands/serve/rest.ts +1130 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +654 -0
- package/src/commands/sling.test.ts +1583 -0
- package/src/commands/sling.ts +1351 -0
- package/src/commands/spec.test.ts +179 -0
- package/src/commands/spec.ts +105 -0
- package/src/commands/status.test.ts +614 -0
- package/src/commands/status.ts +403 -0
- package/src/commands/stop.test.ts +964 -0
- package/src/commands/stop.ts +319 -0
- package/src/commands/supervisor.test.ts +185 -0
- package/src/commands/supervisor.ts +537 -0
- package/src/commands/trace.test.ts +762 -0
- package/src/commands/trace.ts +205 -0
- package/src/commands/update.test.ts +466 -0
- package/src/commands/update.ts +263 -0
- package/src/commands/upgrade.test.ts +48 -0
- package/src/commands/upgrade.ts +240 -0
- package/src/commands/watch.test.ts +257 -0
- package/src/commands/watch.ts +308 -0
- package/src/commands/worktree.test.ts +1297 -0
- package/src/commands/worktree.ts +451 -0
- package/src/config.test.ts +1535 -0
- package/src/config.ts +1064 -0
- package/src/doctor/agents.test.ts +523 -0
- package/src/doctor/agents.ts +399 -0
- package/src/doctor/config-check.test.ts +191 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +807 -0
- package/src/doctor/consistency.ts +347 -0
- package/src/doctor/databases.test.ts +350 -0
- package/src/doctor/databases.ts +243 -0
- package/src/doctor/dependencies.test.ts +296 -0
- package/src/doctor/dependencies.ts +272 -0
- package/src/doctor/ecosystem.test.ts +308 -0
- package/src/doctor/ecosystem.ts +156 -0
- package/src/doctor/logs.test.ts +253 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +315 -0
- package/src/doctor/merge-queue.ts +167 -0
- package/src/doctor/providers.test.ts +409 -0
- package/src/doctor/providers.ts +250 -0
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/structure.test.ts +423 -0
- package/src/doctor/structure.ts +285 -0
- package/src/doctor/types.ts +43 -0
- package/src/doctor/version.test.ts +241 -0
- package/src/doctor/version.ts +132 -0
- package/src/doctor/watchdog.test.ts +167 -0
- package/src/doctor/watchdog.ts +214 -0
- package/src/e2e/init-sling-lifecycle.test.ts +283 -0
- package/src/errors.test.ts +350 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tailer.test.ts +719 -0
- package/src/events/tailer.ts +332 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +533 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/json.test.ts +72 -0
- package/src/json.ts +53 -0
- package/src/loam/client.test.ts +752 -0
- package/src/loam/client.ts +664 -0
- package/src/logging/color.test.ts +252 -0
- package/src/logging/color.ts +105 -0
- package/src/logging/format.test.ts +110 -0
- package/src/logging/format.ts +255 -0
- package/src/logging/logger.test.ts +814 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +110 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/logging/theme.ts +140 -0
- package/src/mail/broadcast.test.ts +204 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +774 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +898 -0
- package/src/mail/store.ts +425 -0
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/merge/predict.test.ts +387 -0
- package/src/merge/predict.ts +249 -0
- package/src/merge/queue.test.ts +426 -0
- package/src/merge/queue.ts +246 -0
- package/src/merge/resolver.test.ts +1993 -0
- package/src/merge/resolver.ts +926 -0
- package/src/metrics/pricing.test.ts +258 -0
- package/src/metrics/pricing.ts +135 -0
- package/src/metrics/store.test.ts +978 -0
- package/src/metrics/store.ts +501 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +483 -0
- package/src/metrics/transcript.ts +114 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/aider.test.ts +124 -0
- package/src/runtimes/aider.ts +147 -0
- package/src/runtimes/amp.test.ts +164 -0
- package/src/runtimes/amp.ts +154 -0
- package/src/runtimes/claude.test.ts +1474 -0
- package/src/runtimes/claude.ts +579 -0
- package/src/runtimes/codex.test.ts +805 -0
- package/src/runtimes/codex.ts +273 -0
- package/src/runtimes/connections.test.ts +214 -0
- package/src/runtimes/connections.ts +103 -0
- package/src/runtimes/copilot.test.ts +707 -0
- package/src/runtimes/copilot.ts +316 -0
- package/src/runtimes/cursor.test.ts +497 -0
- package/src/runtimes/cursor.ts +205 -0
- package/src/runtimes/gemini.test.ts +537 -0
- package/src/runtimes/gemini.ts +243 -0
- package/src/runtimes/goose.test.ts +133 -0
- package/src/runtimes/goose.ts +157 -0
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/opencode.test.ts +325 -0
- package/src/runtimes/opencode.ts +188 -0
- package/src/runtimes/pi-guards.test.ts +486 -0
- package/src/runtimes/pi-guards.ts +367 -0
- package/src/runtimes/pi.test.ts +789 -0
- package/src/runtimes/pi.ts +305 -0
- package/src/runtimes/registry.test.ts +196 -0
- package/src/runtimes/registry.ts +99 -0
- package/src/runtimes/sapling.test.ts +1267 -0
- package/src/runtimes/sapling.ts +710 -0
- package/src/runtimes/types.ts +266 -0
- package/src/schema-consistency.test.ts +246 -0
- package/src/sessions/compat.test.ts +281 -0
- package/src/sessions/compat.ts +105 -0
- package/src/sessions/store.test.ts +1748 -0
- package/src/sessions/store.ts +858 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +145 -0
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/tools/loam/api.ts +368 -0
- package/src/tools/loam/cli.ts +278 -0
- package/src/tools/loam/commands/add.ts +52 -0
- package/src/tools/loam/commands/archive.ts +214 -0
- package/src/tools/loam/commands/audit.ts +276 -0
- package/src/tools/loam/commands/compact.ts +1062 -0
- package/src/tools/loam/commands/completions.ts +79 -0
- package/src/tools/loam/commands/config.ts +381 -0
- package/src/tools/loam/commands/delete-domain.ts +121 -0
- package/src/tools/loam/commands/delete.ts +316 -0
- package/src/tools/loam/commands/diff.ts +200 -0
- package/src/tools/loam/commands/doctor.ts +1113 -0
- package/src/tools/loam/commands/edit.ts +226 -0
- package/src/tools/loam/commands/init.ts +31 -0
- package/src/tools/loam/commands/learn.ts +179 -0
- package/src/tools/loam/commands/move.ts +323 -0
- package/src/tools/loam/commands/onboard.ts +374 -0
- package/src/tools/loam/commands/outcome.ts +185 -0
- package/src/tools/loam/commands/prime.ts +688 -0
- package/src/tools/loam/commands/prune.ts +614 -0
- package/src/tools/loam/commands/query.ts +218 -0
- package/src/tools/loam/commands/rank.ts +180 -0
- package/src/tools/loam/commands/ready.ts +189 -0
- package/src/tools/loam/commands/record.ts +1210 -0
- package/src/tools/loam/commands/restore.ts +166 -0
- package/src/tools/loam/commands/search.ts +327 -0
- package/src/tools/loam/commands/setup.ts +887 -0
- package/src/tools/loam/commands/status.ts +103 -0
- package/src/tools/loam/commands/sync.ts +298 -0
- package/src/tools/loam/commands/update.ts +19 -0
- package/src/tools/loam/commands/upgrade.ts +93 -0
- package/src/tools/loam/commands/validate.ts +190 -0
- package/src/tools/loam/index.ts +62 -0
- package/src/tools/loam/log.ts +127 -0
- package/src/tools/loam/registry/builtins.ts +409 -0
- package/src/tools/loam/registry/custom.ts +431 -0
- package/src/tools/loam/registry/init.ts +55 -0
- package/src/tools/loam/registry/template.ts +40 -0
- package/src/tools/loam/registry/type-registry.ts +113 -0
- package/src/tools/loam/schemas/config-schema.ts +489 -0
- package/src/tools/loam/schemas/config.ts +245 -0
- package/src/tools/loam/schemas/index.ts +18 -0
- package/src/tools/loam/schemas/record-schema.ts +191 -0
- package/src/tools/loam/schemas/record.ts +115 -0
- package/src/tools/loam/utils/active-work.ts +205 -0
- package/src/tools/loam/utils/anchor-validity.ts +80 -0
- package/src/tools/loam/utils/archive.ts +146 -0
- package/src/tools/loam/utils/audit.ts +667 -0
- package/src/tools/loam/utils/bm25.ts +238 -0
- package/src/tools/loam/utils/budget.ts +142 -0
- package/src/tools/loam/utils/config.ts +344 -0
- package/src/tools/loam/utils/dir-anchors.ts +62 -0
- package/src/tools/loam/utils/domain-rules.ts +114 -0
- package/src/tools/loam/utils/expertise.ts +393 -0
- package/src/tools/loam/utils/format-helpers.ts +96 -0
- package/src/tools/loam/utils/format.ts +1234 -0
- package/src/tools/loam/utils/git-context.ts +50 -0
- package/src/tools/loam/utils/git.ts +183 -0
- package/src/tools/loam/utils/hooks.ts +299 -0
- package/src/tools/loam/utils/index.ts +52 -0
- package/src/tools/loam/utils/json-output.ts +13 -0
- package/src/tools/loam/utils/lock.ts +76 -0
- package/src/tools/loam/utils/markers.ts +48 -0
- package/src/tools/loam/utils/numeric-flags.ts +20 -0
- package/src/tools/loam/utils/palette.ts +44 -0
- package/src/tools/loam/utils/prime-ranking.ts +135 -0
- package/src/tools/loam/utils/recipe-discovery.ts +195 -0
- package/src/tools/loam/utils/runtime-flags.ts +28 -0
- package/src/tools/loam/utils/scoring.ts +94 -0
- package/src/tools/loam/utils/version.ts +116 -0
- package/src/tools/sprout/commands/block.ts +64 -0
- package/src/tools/sprout/commands/blocked.ts +86 -0
- package/src/tools/sprout/commands/close.ts +129 -0
- package/src/tools/sprout/commands/completions.ts +198 -0
- package/src/tools/sprout/commands/config.ts +238 -0
- package/src/tools/sprout/commands/create.ts +164 -0
- package/src/tools/sprout/commands/dep.ts +148 -0
- package/src/tools/sprout/commands/doctor.ts +979 -0
- package/src/tools/sprout/commands/init.ts +83 -0
- package/src/tools/sprout/commands/label.ts +178 -0
- package/src/tools/sprout/commands/list.ts +210 -0
- package/src/tools/sprout/commands/migrate.ts +133 -0
- package/src/tools/sprout/commands/onboard.ts +207 -0
- package/src/tools/sprout/commands/plan-show.ts +278 -0
- package/src/tools/sprout/commands/plan.ts +2526 -0
- package/src/tools/sprout/commands/prime.ts +399 -0
- package/src/tools/sprout/commands/ready.ts +245 -0
- package/src/tools/sprout/commands/search.ts +221 -0
- package/src/tools/sprout/commands/show.ts +277 -0
- package/src/tools/sprout/commands/stats.ts +146 -0
- package/src/tools/sprout/commands/sync.ts +134 -0
- package/src/tools/sprout/commands/tpl.ts +364 -0
- package/src/tools/sprout/commands/unblock.ts +115 -0
- package/src/tools/sprout/commands/update.ts +257 -0
- package/src/tools/sprout/commands/upgrade.ts +91 -0
- package/src/tools/sprout/config-schema.ts +152 -0
- package/src/tools/sprout/config.ts +355 -0
- package/src/tools/sprout/filter.ts +107 -0
- package/src/tools/sprout/format.ts +43 -0
- package/src/tools/sprout/id.ts +22 -0
- package/src/tools/sprout/index.ts +204 -0
- package/src/tools/sprout/log.ts +76 -0
- package/src/tools/sprout/markers.ts +22 -0
- package/src/tools/sprout/output.ts +121 -0
- package/src/tools/sprout/plan-backref.ts +93 -0
- package/src/tools/sprout/plan-context.ts +81 -0
- package/src/tools/sprout/plan-domain.ts +139 -0
- package/src/tools/sprout/plan-lifecycle.ts +65 -0
- package/src/tools/sprout/plan-loam.ts +207 -0
- package/src/tools/sprout/plan-schema.ts +209 -0
- package/src/tools/sprout/sort.ts +31 -0
- package/src/tools/sprout/store.ts +172 -0
- package/src/tools/sprout/types.ts +118 -0
- package/src/tools/sprout/validation.ts +119 -0
- package/src/tools/sprout/version.ts +1 -0
- package/src/tools/sprout/yaml.ts +387 -0
- package/src/tools/trellis/commands/archive.ts +87 -0
- package/src/tools/trellis/commands/completions.ts +610 -0
- package/src/tools/trellis/commands/config.ts +382 -0
- package/src/tools/trellis/commands/create.ts +252 -0
- package/src/tools/trellis/commands/diff.ts +150 -0
- package/src/tools/trellis/commands/doctor.ts +771 -0
- package/src/tools/trellis/commands/emit.ts +365 -0
- package/src/tools/trellis/commands/history.ts +83 -0
- package/src/tools/trellis/commands/import.ts +198 -0
- package/src/tools/trellis/commands/init.ts +81 -0
- package/src/tools/trellis/commands/list.ts +103 -0
- package/src/tools/trellis/commands/onboard.ts +156 -0
- package/src/tools/trellis/commands/pin.ts +172 -0
- package/src/tools/trellis/commands/prime.ts +193 -0
- package/src/tools/trellis/commands/render.ts +122 -0
- package/src/tools/trellis/commands/schema.ts +353 -0
- package/src/tools/trellis/commands/show.ts +115 -0
- package/src/tools/trellis/commands/stats.ts +65 -0
- package/src/tools/trellis/commands/sync.ts +112 -0
- package/src/tools/trellis/commands/tree.ts +123 -0
- package/src/tools/trellis/commands/update.ts +330 -0
- package/src/tools/trellis/commands/upgrade.ts +95 -0
- package/src/tools/trellis/commands/validate.ts +166 -0
- package/src/tools/trellis/config-schema.ts +81 -0
- package/src/tools/trellis/config.ts +108 -0
- package/src/tools/trellis/frontmatter.ts +348 -0
- package/src/tools/trellis/id.ts +24 -0
- package/src/tools/trellis/index.ts +209 -0
- package/src/tools/trellis/markers.ts +28 -0
- package/src/tools/trellis/output.ts +84 -0
- package/src/tools/trellis/render.ts +212 -0
- package/src/tools/trellis/store.ts +144 -0
- package/src/tools/trellis/types.ts +82 -0
- package/src/tools/trellis/validate.ts +199 -0
- package/src/tools/trellis/yaml.ts +309 -0
- package/src/tracker/beads.test.ts +454 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +90 -0
- package/src/tracker/factory.ts +65 -0
- package/src/tracker/sprout.test.ts +461 -0
- package/src/tracker/sprout.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/trellis/client.test.ts +107 -0
- package/src/trellis/client.ts +179 -0
- package/src/types.ts +970 -0
- package/src/utils/bin.test.ts +10 -0
- package/src/utils/bin.ts +37 -0
- package/src/utils/browser.test.ts +49 -0
- package/src/utils/browser.ts +48 -0
- package/src/utils/fs.test.ts +119 -0
- package/src/utils/fs.ts +62 -0
- package/src/utils/pid.test.ts +152 -0
- package/src/utils/pid.ts +130 -0
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/utils/time.test.ts +43 -0
- package/src/utils/time.ts +37 -0
- package/src/utils/version.test.ts +33 -0
- package/src/utils/version.ts +70 -0
- package/src/version.ts +5 -0
- package/src/watchdog/daemon.test.ts +3721 -0
- package/src/watchdog/daemon.ts +1257 -0
- package/src/watchdog/health.test.ts +830 -0
- package/src/watchdog/health.ts +434 -0
- package/src/watchdog/triage.test.ts +205 -0
- package/src/watchdog/triage.ts +205 -0
- package/src/worktree/manager.test.ts +720 -0
- package/src/worktree/manager.ts +405 -0
- package/src/worktree/process.test.ts +172 -0
- package/src/worktree/process.ts +131 -0
- package/src/worktree/tmux.test.ts +1616 -0
- package/src/worktree/tmux.ts +721 -0
- package/templates/CLAUDE.md.tmpl +100 -0
- package/templates/copilot-hooks.json.tmpl +13 -0
- package/templates/hooks.json.tmpl +109 -0
- package/templates/overlay.md.tmpl +88 -0
- package/ui/dist/apple-touch-icon-bdy6teep.png +0 -0
- package/ui/dist/chunk-8s31f05k.css +1 -0
- package/ui/dist/chunk-vm5rz679.js +300 -0
- package/ui/dist/favicon-nzb39vza.svg +4 -0
- package/ui/dist/index.html +17 -0
|
@@ -0,0 +1,1210 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { type Command, Option } from "commander";
|
|
4
|
+
import { getRegistry, type TypeDefinition } from "../registry/type-registry.ts";
|
|
5
|
+
import type { Classification, Evidence, ExpertiseRecord, Outcome } from "../schemas/record.ts";
|
|
6
|
+
import { addDomain, getExpertisePath, readConfig } from "../utils/config.ts";
|
|
7
|
+
import {
|
|
8
|
+
assertWritableDirAnchor,
|
|
9
|
+
inferDirAnchors,
|
|
10
|
+
normalizeDirAnchor,
|
|
11
|
+
} from "../utils/dir-anchors.ts";
|
|
12
|
+
import {
|
|
13
|
+
findMissingDomainFields,
|
|
14
|
+
findRejectedRequiredFields,
|
|
15
|
+
getAllowedTypes,
|
|
16
|
+
getRequiredFields,
|
|
17
|
+
} from "../utils/domain-rules.ts";
|
|
18
|
+
import {
|
|
19
|
+
appendRecord,
|
|
20
|
+
findDuplicate,
|
|
21
|
+
readExpertiseFile,
|
|
22
|
+
writeExpertiseFile,
|
|
23
|
+
} from "../utils/expertise.ts";
|
|
24
|
+
import { getContextFiles, getCurrentCommit } from "../utils/git-context.ts";
|
|
25
|
+
import { runHooks } from "../utils/hooks.ts";
|
|
26
|
+
import { outputJson, outputJsonError } from "../utils/json-output.ts";
|
|
27
|
+
import { withFileLock } from "../utils/lock.ts";
|
|
28
|
+
import { parseStrictNonNegativeNumber } from "../utils/numeric-flags.ts";
|
|
29
|
+
import { brand, isQuiet } from "../utils/palette.ts";
|
|
30
|
+
import { isAllowDomainMismatch } from "../utils/runtime-flags.ts";
|
|
31
|
+
|
|
32
|
+
function buildTypeRequirements(): Record<string, string> {
|
|
33
|
+
const out: Record<string, string> = {};
|
|
34
|
+
for (const def of getRegistry().enabled()) {
|
|
35
|
+
out[def.name] = `${def.name} records require: ${def.required.join(", ")}`;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// snake_case field name → camelCase Commander option key (e.g. "test_results" → "testResults").
|
|
41
|
+
function fieldToOptionKey(field: string): string {
|
|
42
|
+
return field.replace(/_(.)/g, (_, c: string) => c.toUpperCase());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Returns the positional content fallback for built-in types that historically
|
|
46
|
+
// accept it. Returns null for custom types and built-ins without fallback.
|
|
47
|
+
function positionalFallbackField(def: TypeDefinition): string | null {
|
|
48
|
+
if (def.kind !== "builtin") return null;
|
|
49
|
+
if (def.name === "convention") return "content";
|
|
50
|
+
if (def.name === "pattern" || def.name === "reference" || def.name === "guide") {
|
|
51
|
+
return "description";
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface BaseRecordParts {
|
|
57
|
+
classification: Classification;
|
|
58
|
+
recorded_at: string;
|
|
59
|
+
evidence?: Evidence;
|
|
60
|
+
tags?: string[];
|
|
61
|
+
relates_to?: string[];
|
|
62
|
+
supersedes?: string[];
|
|
63
|
+
outcomes?: Outcome[];
|
|
64
|
+
dir_anchors?: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildRecordFromOptions(
|
|
68
|
+
def: TypeDefinition,
|
|
69
|
+
content: string | undefined,
|
|
70
|
+
options: Record<string, unknown>,
|
|
71
|
+
base: BaseRecordParts,
|
|
72
|
+
): { record: ExpertiseRecord | null; missing: Array<{ flag: string; placeholder: string }> } {
|
|
73
|
+
const fallbackField = positionalFallbackField(def);
|
|
74
|
+
const r: Record<string, unknown> = {
|
|
75
|
+
type: def.name,
|
|
76
|
+
classification: base.classification,
|
|
77
|
+
recorded_at: base.recorded_at,
|
|
78
|
+
};
|
|
79
|
+
if (base.evidence) r.evidence = base.evidence;
|
|
80
|
+
if (base.tags && base.tags.length > 0) r.tags = base.tags;
|
|
81
|
+
if (base.relates_to && base.relates_to.length > 0) r.relates_to = base.relates_to;
|
|
82
|
+
if (base.supersedes && base.supersedes.length > 0) r.supersedes = base.supersedes;
|
|
83
|
+
if (base.outcomes) r.outcomes = base.outcomes;
|
|
84
|
+
if (base.dir_anchors && base.dir_anchors.length > 0) r.dir_anchors = base.dir_anchors;
|
|
85
|
+
|
|
86
|
+
const missing: Array<{ flag: string; placeholder: string }> = [];
|
|
87
|
+
|
|
88
|
+
const collectField = (field: string, isRequired: boolean): void => {
|
|
89
|
+
const optKey = fieldToOptionKey(field);
|
|
90
|
+
let value: unknown = options[optKey];
|
|
91
|
+
if (value === undefined && fallbackField === field && content !== undefined) {
|
|
92
|
+
value = content;
|
|
93
|
+
}
|
|
94
|
+
if (value === undefined || value === "") {
|
|
95
|
+
if (isRequired)
|
|
96
|
+
missing.push({ flag: `--${field.replace(/_/g, "-")}`, placeholder: `<${field}>` });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// extractsFiles: split comma-separated string from --files flag
|
|
100
|
+
if (def.extractsFiles && field === def.filesField && typeof value === "string") {
|
|
101
|
+
r[field] = value
|
|
102
|
+
.split(",")
|
|
103
|
+
.map((s) => s.trim())
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
r[field] = value;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
for (const field of def.required) collectField(field, true);
|
|
111
|
+
for (const field of def.optional) collectField(field, false);
|
|
112
|
+
|
|
113
|
+
// Auto-populate files from git context for extractsFiles types when not provided.
|
|
114
|
+
if (def.extractsFiles && r[def.filesField] === undefined) {
|
|
115
|
+
const ctx = getContextFiles();
|
|
116
|
+
if (ctx.length > 0) r[def.filesField] = ctx;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (missing.length > 0) return { record: null, missing };
|
|
120
|
+
return { record: r as unknown as ExpertiseRecord, missing: [] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isNamedType(def: TypeDefinition): boolean {
|
|
124
|
+
// "Named" types upsert on duplicate; "anonymous" types skip on duplicate.
|
|
125
|
+
// Built-ins: convention/failure are anonymous; pattern/decision/reference/guide are named.
|
|
126
|
+
if (def.kind === "builtin") {
|
|
127
|
+
return def.name !== "convention" && def.name !== "failure";
|
|
128
|
+
}
|
|
129
|
+
// Custom types: treat as named iff dedup_key isn't content_hash (since
|
|
130
|
+
// content_hash dedup behaves like an anonymous exact-match check).
|
|
131
|
+
return def.dedupKey !== "content_hash";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildRetryCommand(
|
|
135
|
+
domain: string,
|
|
136
|
+
content: string | undefined,
|
|
137
|
+
options: Record<string, unknown>,
|
|
138
|
+
missingFlags: Array<{ flag: string; placeholder: string }>,
|
|
139
|
+
): string {
|
|
140
|
+
const parts = ["lm record", domain];
|
|
141
|
+
if (content) parts.push(JSON.stringify(content));
|
|
142
|
+
if (options.type) parts.push(`--type ${options.type as string}`);
|
|
143
|
+
if (options.classification && options.classification !== "tactical") {
|
|
144
|
+
parts.push(`--classification ${options.classification as string}`);
|
|
145
|
+
}
|
|
146
|
+
if (options.name) parts.push(`--name ${JSON.stringify(options.name as string)}`);
|
|
147
|
+
if (options.content) parts.push(`--content ${JSON.stringify(options.content as string)}`);
|
|
148
|
+
if (options.description)
|
|
149
|
+
parts.push(`--description ${JSON.stringify(options.description as string)}`);
|
|
150
|
+
if (options.resolution)
|
|
151
|
+
parts.push(`--resolution ${JSON.stringify(options.resolution as string)}`);
|
|
152
|
+
if (options.title) parts.push(`--title ${JSON.stringify(options.title as string)}`);
|
|
153
|
+
if (options.rationale) parts.push(`--rationale ${JSON.stringify(options.rationale as string)}`);
|
|
154
|
+
if (options.files) parts.push(`--files ${JSON.stringify(options.files as string)}`);
|
|
155
|
+
if (Array.isArray(options.dirAnchor)) {
|
|
156
|
+
for (const dir of options.dirAnchor as string[]) {
|
|
157
|
+
parts.push(`--dir-anchor ${JSON.stringify(dir)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (options.tags) parts.push(`--tags ${JSON.stringify(options.tags as string)}`);
|
|
161
|
+
if (options.evidenceCommit) parts.push(`--evidence-commit ${options.evidenceCommit as string}`);
|
|
162
|
+
if (options.evidenceIssue)
|
|
163
|
+
parts.push(`--evidence-issue ${JSON.stringify(options.evidenceIssue as string)}`);
|
|
164
|
+
if (options.evidenceFile)
|
|
165
|
+
parts.push(`--evidence-file ${JSON.stringify(options.evidenceFile as string)}`);
|
|
166
|
+
if (options.evidenceBead)
|
|
167
|
+
parts.push(`--evidence-bead ${JSON.stringify(options.evidenceBead as string)}`);
|
|
168
|
+
if (options.evidenceSprout)
|
|
169
|
+
parts.push(`--evidence-sprout ${JSON.stringify(options.evidenceSprout as string)}`);
|
|
170
|
+
if (options.evidenceGh)
|
|
171
|
+
parts.push(`--evidence-gh ${JSON.stringify(options.evidenceGh as string)}`);
|
|
172
|
+
if (options.evidenceLinear)
|
|
173
|
+
parts.push(`--evidence-linear ${JSON.stringify(options.evidenceLinear as string)}`);
|
|
174
|
+
for (const { flag, placeholder } of missingFlags) {
|
|
175
|
+
parts.push(`${flag} ${JSON.stringify(placeholder)}`);
|
|
176
|
+
}
|
|
177
|
+
return parts.join(" ");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Process records from stdin (JSON single object or array)
|
|
182
|
+
* Validates, dedups, and appends with file locking
|
|
183
|
+
*/
|
|
184
|
+
export async function processStdinRecords(
|
|
185
|
+
domain: string,
|
|
186
|
+
_jsonMode: boolean,
|
|
187
|
+
force: boolean,
|
|
188
|
+
dryRun: boolean,
|
|
189
|
+
stdinData?: string,
|
|
190
|
+
cwd?: string,
|
|
191
|
+
): Promise<{
|
|
192
|
+
created: number;
|
|
193
|
+
updated: number;
|
|
194
|
+
skipped: number;
|
|
195
|
+
errors: string[];
|
|
196
|
+
warnings: string[];
|
|
197
|
+
}> {
|
|
198
|
+
const config = await readConfig(cwd);
|
|
199
|
+
|
|
200
|
+
if (!(domain in config.domains)) {
|
|
201
|
+
await addDomain(domain, cwd);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Read stdin (or use provided data for testing)
|
|
205
|
+
const inputData = stdinData ?? readFileSync(0, "utf-8");
|
|
206
|
+
let inputRecords: unknown[];
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const parsed = JSON.parse(inputData);
|
|
210
|
+
inputRecords = Array.isArray(parsed) ? parsed : [parsed];
|
|
211
|
+
} catch (err) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Failed to parse JSON from stdin: ${err instanceof Error ? err.message : String(err)}`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Validate each record against schema (cached on registry)
|
|
218
|
+
const validate = getRegistry().validator;
|
|
219
|
+
const allowedTypes = getAllowedTypes(config, domain);
|
|
220
|
+
const requiredFields = getRequiredFields(config, domain);
|
|
221
|
+
|
|
222
|
+
const errors: string[] = [];
|
|
223
|
+
const validRecords: ExpertiseRecord[] = [];
|
|
224
|
+
|
|
225
|
+
for (let i = 0; i < inputRecords.length; i++) {
|
|
226
|
+
const record = inputRecords[i];
|
|
227
|
+
|
|
228
|
+
// Ensure recorded_at and classification are set
|
|
229
|
+
if (typeof record === "object" && record !== null) {
|
|
230
|
+
if (!("recorded_at" in record)) {
|
|
231
|
+
(record as Record<string, unknown>).recorded_at = new Date().toISOString();
|
|
232
|
+
}
|
|
233
|
+
if (!("classification" in record)) {
|
|
234
|
+
(record as Record<string, unknown>).classification = "tactical";
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!validate(record)) {
|
|
239
|
+
const validationErrors = (validate.errors ?? [])
|
|
240
|
+
.map((err) => `${err.instancePath} ${err.message}`)
|
|
241
|
+
.join("; ");
|
|
242
|
+
const recordType =
|
|
243
|
+
typeof record === "object" && record !== null
|
|
244
|
+
? (record as Record<string, unknown>).type
|
|
245
|
+
: undefined;
|
|
246
|
+
const requirements = buildTypeRequirements();
|
|
247
|
+
const typeHint =
|
|
248
|
+
typeof recordType === "string" && requirements[recordType]
|
|
249
|
+
? `. Hint: ${requirements[recordType]}`
|
|
250
|
+
: "";
|
|
251
|
+
const rejectedRequired = findRejectedRequiredFields(validate.errors, requiredFields);
|
|
252
|
+
const domainHint =
|
|
253
|
+
rejectedRequired.length > 0
|
|
254
|
+
? `. Domain "${domain}" requires field(s) ${rejectedRequired
|
|
255
|
+
.map((f) => `"${f}"`)
|
|
256
|
+
.join(
|
|
257
|
+
", ",
|
|
258
|
+
)}, but type "${typeof recordType === "string" ? recordType : "?"}" does not declare them. Declare a custom_type that holds these fields, or remove them from required_fields in loam.config.yaml.`
|
|
259
|
+
: "";
|
|
260
|
+
errors.push(`Record ${i}: ${validationErrors}${typeHint}${domainHint}`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const allowDomainMismatch = isAllowDomainMismatch();
|
|
265
|
+
if (allowedTypes && !allowDomainMismatch) {
|
|
266
|
+
const recordType = (record as Record<string, unknown>).type;
|
|
267
|
+
if (typeof recordType === "string" && !allowedTypes.includes(recordType)) {
|
|
268
|
+
errors.push(
|
|
269
|
+
`Record ${i}: type "${recordType}" is not allowed in domain "${domain}". Allowed types: ${allowedTypes.join(", ")}.`,
|
|
270
|
+
);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (requiredFields && !allowDomainMismatch) {
|
|
276
|
+
const missing = findMissingDomainFields(record as Record<string, unknown>, requiredFields);
|
|
277
|
+
if (missing.length > 0) {
|
|
278
|
+
errors.push(
|
|
279
|
+
`Record ${i}: domain "${domain}" requires field(s) ${missing.map((f) => `"${f}"`).join(", ")}.`,
|
|
280
|
+
);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
validRecords.push(record as ExpertiseRecord);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Emit one warning per disabled type seen across the batch, not per record.
|
|
289
|
+
const warnings: string[] = [];
|
|
290
|
+
const seenDisabledTypes = new Set<string>();
|
|
291
|
+
for (const r of validRecords) {
|
|
292
|
+
if (getRegistry().isDisabled(r.type) && !seenDisabledTypes.has(r.type)) {
|
|
293
|
+
seenDisabledTypes.add(r.type);
|
|
294
|
+
const msg = `type "${r.type}" is disabled (declared in disabled_types). Records will still be written; consider migrating off this type.`;
|
|
295
|
+
warnings.push(msg);
|
|
296
|
+
if (!isQuiet()) {
|
|
297
|
+
console.error(chalk.yellow(`Warning: ${msg}`));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (validRecords.length === 0) {
|
|
303
|
+
return { created: 0, updated: 0, skipped: 0, errors, warnings };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Fire pre-record per record before locking. A blocked hook drops just that
|
|
307
|
+
// record (added to errors); the rest of the batch still writes. Skipped in
|
|
308
|
+
// dry-run since hooks shouldn't fire when previewing.
|
|
309
|
+
const recordsToWrite: ExpertiseRecord[] = [];
|
|
310
|
+
if (dryRun) {
|
|
311
|
+
recordsToWrite.push(...validRecords);
|
|
312
|
+
} else {
|
|
313
|
+
for (let i = 0; i < validRecords.length; i++) {
|
|
314
|
+
const r = validRecords[i];
|
|
315
|
+
if (!r) continue;
|
|
316
|
+
const preRes = await runHooks<{ domain: string; record: ExpertiseRecord }>(
|
|
317
|
+
"pre-record",
|
|
318
|
+
{ domain, record: r },
|
|
319
|
+
{ cwd },
|
|
320
|
+
);
|
|
321
|
+
warnings.push(...preRes.warnings);
|
|
322
|
+
if (preRes.blocked) {
|
|
323
|
+
errors.push(`Record ${i}: ${preRes.blockReason ?? "pre-record hook blocked the write"}`);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
let final = r;
|
|
327
|
+
if (preRes.ranAny && preRes.payload?.record) {
|
|
328
|
+
const mutated = preRes.payload.record;
|
|
329
|
+
if (mutated !== r) {
|
|
330
|
+
if (!validate(mutated)) {
|
|
331
|
+
const errs = (validate.errors ?? [])
|
|
332
|
+
.map((e) => `${e.instancePath} ${e.message}`)
|
|
333
|
+
.join("; ");
|
|
334
|
+
errors.push(`Record ${i}: pre-record hook produced an invalid record: ${errs}`);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
final = mutated;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
recordsToWrite.push(final);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (recordsToWrite.length === 0) {
|
|
345
|
+
return { created: 0, updated: 0, skipped: 0, errors, warnings };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Process valid records with file locking (skip write in dry-run mode)
|
|
349
|
+
const filePath = getExpertisePath(domain, cwd);
|
|
350
|
+
let created = 0;
|
|
351
|
+
let updated = 0;
|
|
352
|
+
let skipped = 0;
|
|
353
|
+
|
|
354
|
+
const registry = getRegistry();
|
|
355
|
+
const isNamedRecord = (record: ExpertiseRecord): boolean => {
|
|
356
|
+
const def = registry.get(record.type);
|
|
357
|
+
return def ? isNamedType(def) : false;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// Track records that were actually written so post-record can fire on each
|
|
361
|
+
// after the lock releases.
|
|
362
|
+
const writtenForPostHook: Array<{
|
|
363
|
+
domain: string;
|
|
364
|
+
record: ExpertiseRecord;
|
|
365
|
+
action: "created" | "updated";
|
|
366
|
+
}> = [];
|
|
367
|
+
|
|
368
|
+
if (dryRun) {
|
|
369
|
+
// Dry-run: check for duplicates without writing
|
|
370
|
+
const existing = await readExpertiseFile(filePath);
|
|
371
|
+
const currentRecords = [...existing];
|
|
372
|
+
|
|
373
|
+
for (const record of recordsToWrite) {
|
|
374
|
+
const dup = findDuplicate(currentRecords, record);
|
|
375
|
+
|
|
376
|
+
if (dup && !force) {
|
|
377
|
+
if (isNamedRecord(record)) {
|
|
378
|
+
updated++;
|
|
379
|
+
} else {
|
|
380
|
+
skipped++;
|
|
381
|
+
}
|
|
382
|
+
} else {
|
|
383
|
+
created++;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
// Normal mode: write with file locking
|
|
388
|
+
await withFileLock(filePath, async () => {
|
|
389
|
+
const existing = await readExpertiseFile(filePath);
|
|
390
|
+
const currentRecords = [...existing];
|
|
391
|
+
|
|
392
|
+
for (const record of recordsToWrite) {
|
|
393
|
+
const dup = findDuplicate(currentRecords, record);
|
|
394
|
+
|
|
395
|
+
if (dup && !force) {
|
|
396
|
+
if (isNamedRecord(record)) {
|
|
397
|
+
// Upsert: replace in place, merging outcomes from existing
|
|
398
|
+
const existingRecord = currentRecords[dup.index];
|
|
399
|
+
if (!existingRecord) continue;
|
|
400
|
+
const mergedOutcomes = [...(existingRecord.outcomes ?? []), ...(record.outcomes ?? [])];
|
|
401
|
+
const upsertRecord =
|
|
402
|
+
mergedOutcomes.length > 0 ? { ...record, outcomes: mergedOutcomes } : record;
|
|
403
|
+
currentRecords[dup.index] = upsertRecord;
|
|
404
|
+
writtenForPostHook.push({ domain, record: upsertRecord, action: "updated" });
|
|
405
|
+
updated++;
|
|
406
|
+
} else {
|
|
407
|
+
// Exact match: skip
|
|
408
|
+
skipped++;
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
// New record: append
|
|
412
|
+
currentRecords.push(record);
|
|
413
|
+
writtenForPostHook.push({ domain, record, action: "created" });
|
|
414
|
+
created++;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Write all changes at once
|
|
419
|
+
if (created > 0 || updated > 0) {
|
|
420
|
+
await writeExpertiseFile(filePath, currentRecords);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Fire post-record outside the lock for each actual write.
|
|
426
|
+
for (const written of writtenForPostHook) {
|
|
427
|
+
const postRes = await runHooks("post-record", written, { cwd });
|
|
428
|
+
warnings.push(...postRes.warnings);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { created, updated, skipped, errors, warnings };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function registerRecordCommand(program: Command): void {
|
|
435
|
+
const registry = getRegistry();
|
|
436
|
+
const typeChoices = registry.names();
|
|
437
|
+
|
|
438
|
+
const cmd = program
|
|
439
|
+
.command("record")
|
|
440
|
+
.argument("<domain>", "expertise domain")
|
|
441
|
+
.argument("[content]", "record content")
|
|
442
|
+
.description("Record an expertise record")
|
|
443
|
+
.addOption(new Option("--type <type>", "record type").choices(typeChoices))
|
|
444
|
+
.addOption(
|
|
445
|
+
new Option("--classification <classification>", "classification level")
|
|
446
|
+
.choices(["foundational", "tactical", "observational"])
|
|
447
|
+
.default("tactical"),
|
|
448
|
+
)
|
|
449
|
+
.option("--name <name>", "name of the convention or pattern")
|
|
450
|
+
.option(
|
|
451
|
+
"--content <content>",
|
|
452
|
+
"content for convention records (explicit flag wins over positional [content])",
|
|
453
|
+
)
|
|
454
|
+
.option("--description <description>", "description of the record")
|
|
455
|
+
.option("--resolution <resolution>", "resolution for failure records")
|
|
456
|
+
.option("--title <title>", "title for decision records")
|
|
457
|
+
.option("--rationale <rationale>", "rationale for decision records")
|
|
458
|
+
.option("--files <files>", "related files (comma-separated)")
|
|
459
|
+
.option(
|
|
460
|
+
"--dir-anchor <path>",
|
|
461
|
+
"repo-relative directory the record applies to (repeatable; auto-populated from changed files when omitted)",
|
|
462
|
+
(value: string, prev: string[] = []) => [...prev, value],
|
|
463
|
+
[] as string[],
|
|
464
|
+
)
|
|
465
|
+
.option("--tags <tags>", "comma-separated tags")
|
|
466
|
+
.option(
|
|
467
|
+
"--evidence-commit <commit>",
|
|
468
|
+
"evidence: commit hash (auto-populated from git if omitted)",
|
|
469
|
+
)
|
|
470
|
+
.option("--evidence-issue <issue>", "evidence: issue reference")
|
|
471
|
+
.option("--evidence-file <file>", "evidence: file path")
|
|
472
|
+
.option("--evidence-bead <bead>", "evidence: bead ID")
|
|
473
|
+
.option("--evidence-sprout <id>", "evidence: sprout issue ID")
|
|
474
|
+
.option("--evidence-gh <ref>", "evidence: GitHub issue or PR reference")
|
|
475
|
+
.option("--evidence-linear <ticket>", "evidence: Linear ticket reference")
|
|
476
|
+
.option("--relates-to <ids>", "comma-separated record IDs this relates to")
|
|
477
|
+
.option("--supersedes <ids>", "comma-separated record IDs this supersedes")
|
|
478
|
+
.addOption(
|
|
479
|
+
new Option("--outcome-status <status>", "outcome status").choices([
|
|
480
|
+
"success",
|
|
481
|
+
"failure",
|
|
482
|
+
"partial",
|
|
483
|
+
]),
|
|
484
|
+
)
|
|
485
|
+
.option("--outcome-duration <ms>", "outcome duration in milliseconds")
|
|
486
|
+
.option("--outcome-test-results <text>", "outcome test results summary")
|
|
487
|
+
.option("--outcome-agent <agent>", "outcome agent name")
|
|
488
|
+
.option("--force", "force recording even if duplicate exists")
|
|
489
|
+
.option("--stdin", "read JSON record(s) from stdin (single object or array)")
|
|
490
|
+
.option("--batch <file>", "read JSON record(s) from file (single object or array)")
|
|
491
|
+
.option("--dry-run", "preview what would be recorded without writing")
|
|
492
|
+
.addHelpText(
|
|
493
|
+
"after",
|
|
494
|
+
`
|
|
495
|
+
Required fields per record type:
|
|
496
|
+
convention --content (or positional [content])
|
|
497
|
+
pattern --name, --description (or [content])
|
|
498
|
+
failure --description, --resolution
|
|
499
|
+
decision --title, --rationale
|
|
500
|
+
reference --name, --description (or [content])
|
|
501
|
+
guide --name, --description (or [content])
|
|
502
|
+
|
|
503
|
+
Batch recording examples:
|
|
504
|
+
lm record cli --batch records.json
|
|
505
|
+
lm record cli --batch records.json --dry-run
|
|
506
|
+
echo '[{"type":"convention","content":"test"}]' > batch.json && lm record cli --batch batch.json
|
|
507
|
+
`,
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
// Dynamically register --<field> flags for custom-type fields not already
|
|
511
|
+
// covered by the built-in flag set. This makes Phase 2 custom_types
|
|
512
|
+
// (declared in loam.config.yaml) feel first-class on the CLI.
|
|
513
|
+
const declaredOptionNames = new Set(
|
|
514
|
+
cmd.options.map((o) => o.name()).concat(["files"]), // --files declared above
|
|
515
|
+
);
|
|
516
|
+
for (const def of registry.enabled()) {
|
|
517
|
+
if (def.kind === "builtin") continue;
|
|
518
|
+
for (const field of [...def.required, ...def.optional]) {
|
|
519
|
+
const flagName = field.replace(/_/g, "-");
|
|
520
|
+
if (declaredOptionNames.has(flagName)) continue;
|
|
521
|
+
declaredOptionNames.add(flagName);
|
|
522
|
+
cmd.option(`--${flagName} <${field}>`, `${def.name} field: ${field}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
cmd.action(
|
|
527
|
+
async (domain: string, content: string | undefined, options: Record<string, unknown>) => {
|
|
528
|
+
const jsonMode = program.opts().json === true;
|
|
529
|
+
|
|
530
|
+
// Handle --batch mode
|
|
531
|
+
if (options.batch) {
|
|
532
|
+
const batchFile = options.batch as string;
|
|
533
|
+
const dryRun = options.dryRun === true;
|
|
534
|
+
|
|
535
|
+
if (!existsSync(batchFile)) {
|
|
536
|
+
if (jsonMode) {
|
|
537
|
+
outputJsonError("record", `Batch file not found: ${batchFile}`);
|
|
538
|
+
} else {
|
|
539
|
+
console.error(chalk.red(`Error: batch file not found: ${batchFile}`));
|
|
540
|
+
}
|
|
541
|
+
process.exitCode = 1;
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
const fileContent = readFileSync(batchFile, "utf-8");
|
|
547
|
+
const result = await processStdinRecords(
|
|
548
|
+
domain,
|
|
549
|
+
jsonMode,
|
|
550
|
+
options.force === true,
|
|
551
|
+
dryRun,
|
|
552
|
+
fileContent,
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
if (result.errors.length > 0) {
|
|
556
|
+
if (jsonMode) {
|
|
557
|
+
outputJsonError("record", `Validation errors: ${result.errors.join("; ")}`);
|
|
558
|
+
} else {
|
|
559
|
+
console.error(chalk.red("Validation errors:"));
|
|
560
|
+
for (const error of result.errors) {
|
|
561
|
+
console.error(chalk.red(` ${error}`));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (jsonMode) {
|
|
567
|
+
outputJson({
|
|
568
|
+
success: result.errors.length === 0 || result.created + result.updated > 0,
|
|
569
|
+
command: "record",
|
|
570
|
+
action: dryRun ? "dry-run" : "batch",
|
|
571
|
+
domain,
|
|
572
|
+
created: result.created,
|
|
573
|
+
updated: result.updated,
|
|
574
|
+
skipped: result.skipped,
|
|
575
|
+
errors: result.errors,
|
|
576
|
+
warnings: result.warnings,
|
|
577
|
+
});
|
|
578
|
+
} else {
|
|
579
|
+
if (dryRun) {
|
|
580
|
+
const total = result.created + result.updated;
|
|
581
|
+
if (total > 0 || result.skipped > 0) {
|
|
582
|
+
if (!isQuiet())
|
|
583
|
+
console.log(
|
|
584
|
+
`${brand("✓")} ${brand(`Dry-run complete. Would process ${total} record(s) in ${domain}:`)}`,
|
|
585
|
+
);
|
|
586
|
+
if (result.created > 0) {
|
|
587
|
+
if (!isQuiet()) console.log(chalk.dim(` Create: ${result.created}`));
|
|
588
|
+
}
|
|
589
|
+
if (result.updated > 0) {
|
|
590
|
+
if (!isQuiet()) console.log(chalk.dim(` Update: ${result.updated}`));
|
|
591
|
+
}
|
|
592
|
+
if (result.skipped > 0) {
|
|
593
|
+
if (!isQuiet()) console.log(chalk.dim(` Skip: ${result.skipped}`));
|
|
594
|
+
}
|
|
595
|
+
if (!isQuiet()) console.log(chalk.dim(" Run without --dry-run to apply changes."));
|
|
596
|
+
} else {
|
|
597
|
+
if (!isQuiet()) console.log(chalk.yellow("No records would be processed."));
|
|
598
|
+
}
|
|
599
|
+
} else {
|
|
600
|
+
if (result.created > 0) {
|
|
601
|
+
if (!isQuiet())
|
|
602
|
+
console.log(
|
|
603
|
+
`${brand("✓")} ${brand(`Created ${result.created} record(s) in ${domain}`)}`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
if (result.updated > 0) {
|
|
607
|
+
if (!isQuiet())
|
|
608
|
+
console.log(
|
|
609
|
+
`${brand("✓")} ${brand(`Updated ${result.updated} record(s) in ${domain}`)}`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
if (result.skipped > 0) {
|
|
613
|
+
if (!isQuiet())
|
|
614
|
+
console.log(chalk.yellow(`Skipped ${result.skipped} duplicate(s) in ${domain}`));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (result.errors.length > 0 && result.created + result.updated === 0) {
|
|
620
|
+
process.exitCode = 1;
|
|
621
|
+
}
|
|
622
|
+
} catch (err) {
|
|
623
|
+
if (jsonMode) {
|
|
624
|
+
outputJsonError("record", err instanceof Error ? err.message : String(err));
|
|
625
|
+
} else {
|
|
626
|
+
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
627
|
+
}
|
|
628
|
+
process.exitCode = 1;
|
|
629
|
+
}
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Handle --stdin mode
|
|
634
|
+
if (options.stdin === true) {
|
|
635
|
+
const dryRun = options.dryRun === true;
|
|
636
|
+
|
|
637
|
+
try {
|
|
638
|
+
const result = await processStdinRecords(
|
|
639
|
+
domain,
|
|
640
|
+
jsonMode,
|
|
641
|
+
options.force === true,
|
|
642
|
+
dryRun,
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
if (result.errors.length > 0) {
|
|
646
|
+
if (jsonMode) {
|
|
647
|
+
outputJsonError("record", `Validation errors: ${result.errors.join("; ")}`);
|
|
648
|
+
} else {
|
|
649
|
+
console.error(chalk.red("Validation errors:"));
|
|
650
|
+
for (const error of result.errors) {
|
|
651
|
+
console.error(chalk.red(` ${error}`));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (jsonMode) {
|
|
657
|
+
outputJson({
|
|
658
|
+
success: result.errors.length === 0 || result.created + result.updated > 0,
|
|
659
|
+
command: "record",
|
|
660
|
+
action: dryRun ? "dry-run" : "stdin",
|
|
661
|
+
domain,
|
|
662
|
+
created: result.created,
|
|
663
|
+
updated: result.updated,
|
|
664
|
+
skipped: result.skipped,
|
|
665
|
+
errors: result.errors,
|
|
666
|
+
warnings: result.warnings,
|
|
667
|
+
});
|
|
668
|
+
} else {
|
|
669
|
+
if (dryRun) {
|
|
670
|
+
const total = result.created + result.updated;
|
|
671
|
+
if (total > 0 || result.skipped > 0) {
|
|
672
|
+
if (!isQuiet())
|
|
673
|
+
console.log(
|
|
674
|
+
`${brand("✓")} ${brand(`Dry-run complete. Would process ${total} record(s) in ${domain}:`)}`,
|
|
675
|
+
);
|
|
676
|
+
if (result.created > 0) {
|
|
677
|
+
if (!isQuiet()) console.log(chalk.dim(` Create: ${result.created}`));
|
|
678
|
+
}
|
|
679
|
+
if (result.updated > 0) {
|
|
680
|
+
if (!isQuiet()) console.log(chalk.dim(` Update: ${result.updated}`));
|
|
681
|
+
}
|
|
682
|
+
if (result.skipped > 0) {
|
|
683
|
+
if (!isQuiet()) console.log(chalk.dim(` Skip: ${result.skipped}`));
|
|
684
|
+
}
|
|
685
|
+
if (!isQuiet()) console.log(chalk.dim(" Run without --dry-run to apply changes."));
|
|
686
|
+
} else {
|
|
687
|
+
if (!isQuiet()) console.log(chalk.yellow("No records would be processed."));
|
|
688
|
+
}
|
|
689
|
+
} else {
|
|
690
|
+
if (result.created > 0) {
|
|
691
|
+
if (!isQuiet())
|
|
692
|
+
console.log(
|
|
693
|
+
`${brand("✓")} ${brand(`Created ${result.created} record(s) in ${domain}`)}`,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
if (result.updated > 0) {
|
|
697
|
+
if (!isQuiet())
|
|
698
|
+
console.log(
|
|
699
|
+
`${brand("✓")} ${brand(`Updated ${result.updated} record(s) in ${domain}`)}`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
if (result.skipped > 0) {
|
|
703
|
+
if (!isQuiet())
|
|
704
|
+
console.log(chalk.yellow(`Skipped ${result.skipped} duplicate(s) in ${domain}`));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (result.errors.length > 0 && result.created + result.updated === 0) {
|
|
710
|
+
process.exitCode = 1;
|
|
711
|
+
}
|
|
712
|
+
} catch (err) {
|
|
713
|
+
if (jsonMode) {
|
|
714
|
+
outputJsonError("record", err instanceof Error ? err.message : String(err));
|
|
715
|
+
} else {
|
|
716
|
+
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
717
|
+
}
|
|
718
|
+
process.exitCode = 1;
|
|
719
|
+
}
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const config = await readConfig();
|
|
723
|
+
|
|
724
|
+
if (!(domain in config.domains)) {
|
|
725
|
+
await addDomain(domain);
|
|
726
|
+
if (!isQuiet()) {
|
|
727
|
+
console.log(`${brand("✓")} ${brand(`Auto-created domain "${domain}"`)}`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Validate --type is provided for non-stdin mode
|
|
732
|
+
if (!options.type) {
|
|
733
|
+
const choicesMsg = `--type is required (${typeChoices.join(", ")})`;
|
|
734
|
+
if (jsonMode) {
|
|
735
|
+
outputJsonError("record", choicesMsg);
|
|
736
|
+
} else {
|
|
737
|
+
console.error(chalk.red(`Error: ${choicesMsg}`));
|
|
738
|
+
}
|
|
739
|
+
process.exitCode = 1;
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const recordType = options.type as string;
|
|
744
|
+
const classification = (options.classification as Classification) ?? "tactical";
|
|
745
|
+
const recordedAt = new Date().toISOString();
|
|
746
|
+
|
|
747
|
+
// Build evidence if any evidence option is provided
|
|
748
|
+
let evidence: Evidence | undefined;
|
|
749
|
+
if (
|
|
750
|
+
options.evidenceCommit ||
|
|
751
|
+
options.evidenceIssue ||
|
|
752
|
+
options.evidenceFile ||
|
|
753
|
+
options.evidenceBead ||
|
|
754
|
+
options.evidenceSprout ||
|
|
755
|
+
options.evidenceGh ||
|
|
756
|
+
options.evidenceLinear
|
|
757
|
+
) {
|
|
758
|
+
evidence = {};
|
|
759
|
+
if (options.evidenceCommit) evidence.commit = options.evidenceCommit as string;
|
|
760
|
+
if (options.evidenceIssue) evidence.issue = options.evidenceIssue as string;
|
|
761
|
+
if (options.evidenceFile) evidence.file = options.evidenceFile as string;
|
|
762
|
+
if (options.evidenceBead) evidence.bead = options.evidenceBead as string;
|
|
763
|
+
if (options.evidenceSprout) evidence.sprout = options.evidenceSprout as string;
|
|
764
|
+
if (options.evidenceGh) evidence.gh = options.evidenceGh as string;
|
|
765
|
+
if (options.evidenceLinear) evidence.linear = options.evidenceLinear as string;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Auto-populate evidence.commit from git HEAD if not explicitly provided
|
|
769
|
+
if (!options.evidenceCommit) {
|
|
770
|
+
const autoCommit = getCurrentCommit();
|
|
771
|
+
if (autoCommit) {
|
|
772
|
+
evidence = evidence ?? {};
|
|
773
|
+
evidence.commit = autoCommit;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const tags =
|
|
778
|
+
typeof options.tags === "string"
|
|
779
|
+
? options.tags
|
|
780
|
+
.split(",")
|
|
781
|
+
.map((t) => (t as string).trim())
|
|
782
|
+
.filter(Boolean)
|
|
783
|
+
: undefined;
|
|
784
|
+
|
|
785
|
+
const relatesTo =
|
|
786
|
+
typeof options.relatesTo === "string"
|
|
787
|
+
? options.relatesTo
|
|
788
|
+
.split(",")
|
|
789
|
+
.map((id: string) => id.trim())
|
|
790
|
+
.filter(Boolean)
|
|
791
|
+
: undefined;
|
|
792
|
+
|
|
793
|
+
const supersedes =
|
|
794
|
+
typeof options.supersedes === "string"
|
|
795
|
+
? options.supersedes
|
|
796
|
+
.split(",")
|
|
797
|
+
.map((id: string) => id.trim())
|
|
798
|
+
.filter(Boolean)
|
|
799
|
+
: undefined;
|
|
800
|
+
|
|
801
|
+
let outcomes: Outcome[] | undefined;
|
|
802
|
+
if (options.outcomeStatus) {
|
|
803
|
+
const o: Outcome = {
|
|
804
|
+
status: options.outcomeStatus as "success" | "failure" | "partial",
|
|
805
|
+
};
|
|
806
|
+
if (options.outcomeDuration !== undefined) {
|
|
807
|
+
const parsed = parseStrictNonNegativeNumber(options.outcomeDuration as string);
|
|
808
|
+
if (parsed === null) {
|
|
809
|
+
const msg = `--outcome-duration must be a non-negative number (got "${options.outcomeDuration as string}").`;
|
|
810
|
+
if (jsonMode) {
|
|
811
|
+
outputJsonError("record", msg);
|
|
812
|
+
} else {
|
|
813
|
+
console.error(chalk.red(`Error: ${msg}`));
|
|
814
|
+
}
|
|
815
|
+
process.exitCode = 1;
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
o.duration = parsed;
|
|
819
|
+
}
|
|
820
|
+
if (options.outcomeTestResults) {
|
|
821
|
+
o.test_results = options.outcomeTestResults as string;
|
|
822
|
+
}
|
|
823
|
+
if (options.outcomeAgent) {
|
|
824
|
+
o.agent = options.outcomeAgent as string;
|
|
825
|
+
}
|
|
826
|
+
outcomes = [o];
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// dir_anchors: explicit --dir-anchor wins; otherwise infer from changed
|
|
830
|
+
// files (3+ files sharing a parent directory). Normalized to drop
|
|
831
|
+
// trailing slashes and a leading "./"; "." / repo root collapses to
|
|
832
|
+
// nothing stored. Absolute paths and ".." traversal are rejected with
|
|
833
|
+
// a formatted error before validation builds the record.
|
|
834
|
+
let explicitDirAnchors: string[] = [];
|
|
835
|
+
if (Array.isArray(options.dirAnchor)) {
|
|
836
|
+
try {
|
|
837
|
+
for (const raw of options.dirAnchor as string[]) {
|
|
838
|
+
assertWritableDirAnchor(raw);
|
|
839
|
+
}
|
|
840
|
+
} catch (err) {
|
|
841
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
842
|
+
if (jsonMode) {
|
|
843
|
+
outputJsonError("record", msg);
|
|
844
|
+
} else {
|
|
845
|
+
console.error(chalk.red(`Error: ${msg}`));
|
|
846
|
+
}
|
|
847
|
+
process.exitCode = 1;
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
explicitDirAnchors = (options.dirAnchor as string[])
|
|
851
|
+
.map((p) => normalizeDirAnchor(p))
|
|
852
|
+
.filter((p) => p.length > 0);
|
|
853
|
+
}
|
|
854
|
+
let dirAnchors: string[] | undefined;
|
|
855
|
+
if (explicitDirAnchors.length > 0) {
|
|
856
|
+
dirAnchors = [...new Set(explicitDirAnchors)].sort();
|
|
857
|
+
} else if (options.files === undefined) {
|
|
858
|
+
const inferred = inferDirAnchors(getContextFiles());
|
|
859
|
+
if (inferred.length > 0) dirAnchors = inferred;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const def = getRegistry().get(recordType);
|
|
863
|
+
if (!def) {
|
|
864
|
+
const msg = `Unknown record type "${recordType}". Available: ${typeChoices.join(", ")}.`;
|
|
865
|
+
if (jsonMode) {
|
|
866
|
+
outputJsonError("record", msg);
|
|
867
|
+
} else {
|
|
868
|
+
console.error(chalk.red(`Error: ${msg}`));
|
|
869
|
+
}
|
|
870
|
+
process.exitCode = 1;
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Per-domain allowed_types gate. Empty/missing means all registered
|
|
875
|
+
// types allowed (back-compat). disabled_types is independent — a type
|
|
876
|
+
// in allowed_types still writes (with deprecation warning) even when
|
|
877
|
+
// also disabled; cross-project peers in shared domains shouldn't
|
|
878
|
+
// hard-fail on an upstream config change. --allow-domain-mismatch
|
|
879
|
+
// skips this gate (worktree/CI lag escape hatch).
|
|
880
|
+
const allowDomainMismatch = isAllowDomainMismatch();
|
|
881
|
+
const allowedTypes = getAllowedTypes(config, domain);
|
|
882
|
+
if (allowedTypes && !allowDomainMismatch && !allowedTypes.includes(recordType)) {
|
|
883
|
+
const allowedList = allowedTypes.join(", ");
|
|
884
|
+
const msg = `type "${recordType}" is not allowed in domain "${domain}". Allowed types: ${allowedList}.`;
|
|
885
|
+
if (jsonMode) {
|
|
886
|
+
outputJsonError("record", msg);
|
|
887
|
+
} else {
|
|
888
|
+
console.error(chalk.red(`Error: ${msg}`));
|
|
889
|
+
const suggestedType = allowedTypes[0] ?? recordType;
|
|
890
|
+
const retryCmd = buildRetryCommand(
|
|
891
|
+
domain,
|
|
892
|
+
content,
|
|
893
|
+
{ ...options, type: suggestedType },
|
|
894
|
+
[],
|
|
895
|
+
);
|
|
896
|
+
console.error(chalk.dim(` Retry: ${retryCmd}`));
|
|
897
|
+
}
|
|
898
|
+
process.exitCode = 1;
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Phase 3: disabled types still write but emit a deprecation warning.
|
|
903
|
+
const disabledWarning = getRegistry().isDisabled(recordType)
|
|
904
|
+
? `type "${recordType}" is disabled (declared in disabled_types). Records will still be written; consider migrating off this type.`
|
|
905
|
+
: null;
|
|
906
|
+
if (disabledWarning && !isQuiet() && !jsonMode) {
|
|
907
|
+
console.error(chalk.yellow(`Warning: ${disabledWarning}`));
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const built = buildRecordFromOptions(def, content, options, {
|
|
911
|
+
classification,
|
|
912
|
+
recorded_at: recordedAt,
|
|
913
|
+
evidence,
|
|
914
|
+
tags,
|
|
915
|
+
relates_to: relatesTo,
|
|
916
|
+
supersedes,
|
|
917
|
+
outcomes,
|
|
918
|
+
dir_anchors: dirAnchors,
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
if (!built.record) {
|
|
922
|
+
const missingFlags = built.missing.map((m) => m.flag);
|
|
923
|
+
const flagList =
|
|
924
|
+
missingFlags.length > 0 ? missingFlags.join(", ") : def.required.join(", ");
|
|
925
|
+
const retryCmd = buildRetryCommand(domain, content, options, built.missing);
|
|
926
|
+
const msg = `${def.name} records are missing required flag(s): ${flagList}. Example: ${retryCmd}`;
|
|
927
|
+
if (jsonMode) {
|
|
928
|
+
outputJsonError("record", msg);
|
|
929
|
+
} else {
|
|
930
|
+
console.error(
|
|
931
|
+
chalk.red(`Error: ${def.name} records are missing required flag(s): ${flagList}.`),
|
|
932
|
+
);
|
|
933
|
+
console.error(chalk.dim(` Retry: ${retryCmd}`));
|
|
934
|
+
}
|
|
935
|
+
process.exitCode = 1;
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
let record: ExpertiseRecord = built.record;
|
|
940
|
+
|
|
941
|
+
// Validate against JSON schema (cached on registry)
|
|
942
|
+
const validate = getRegistry().validator;
|
|
943
|
+
// Pre-fetch required_fields so we can rewrite the AJV error when a
|
|
944
|
+
// rejected additionalProperty is one the domain demands — otherwise
|
|
945
|
+
// users see a confusing oneOf/additionalProperties soup with no hint
|
|
946
|
+
// that the real cause is the closed schema vs. domain rule mismatch.
|
|
947
|
+
const requiredFields = getRequiredFields(config, domain);
|
|
948
|
+
if (!validate(record)) {
|
|
949
|
+
const errors = (validate.errors ?? []).map((err) => `${err.instancePath} ${err.message}`);
|
|
950
|
+
const requirements = buildTypeRequirements();
|
|
951
|
+
const typeHint = requirements[recordType] ? `. Hint: ${requirements[recordType]}` : "";
|
|
952
|
+
const rejectedRequired = findRejectedRequiredFields(validate.errors, requiredFields);
|
|
953
|
+
const domainHint =
|
|
954
|
+
rejectedRequired.length > 0
|
|
955
|
+
? `Domain "${domain}" requires field(s) ${rejectedRequired
|
|
956
|
+
.map((f) => `"${f}"`)
|
|
957
|
+
.join(
|
|
958
|
+
", ",
|
|
959
|
+
)}, but type "${recordType}" does not declare them. Declare a custom_type that holds these fields, or remove them from required_fields in loam.config.yaml.`
|
|
960
|
+
: "";
|
|
961
|
+
if (jsonMode) {
|
|
962
|
+
const suffix = domainHint ? `. ${domainHint}` : "";
|
|
963
|
+
outputJsonError(
|
|
964
|
+
"record",
|
|
965
|
+
`Schema validation failed: ${errors.join("; ")}${typeHint}${suffix}`,
|
|
966
|
+
);
|
|
967
|
+
} else {
|
|
968
|
+
console.error(chalk.red("Error: record failed schema validation:"));
|
|
969
|
+
for (const err of validate.errors ?? []) {
|
|
970
|
+
console.error(chalk.red(` ${err.instancePath} ${err.message}`));
|
|
971
|
+
}
|
|
972
|
+
if (requirements[recordType]) {
|
|
973
|
+
console.error(chalk.yellow(`Hint: ${requirements[recordType]}`));
|
|
974
|
+
}
|
|
975
|
+
if (domainHint) {
|
|
976
|
+
console.error(chalk.yellow(`Hint: ${domainHint}`));
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
process.exitCode = 1;
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Per-domain required_fields gate. Stacks on top of per-type required
|
|
984
|
+
// fields enforced by the schema above. Lists all missing fields in a
|
|
985
|
+
// single error so users fix the config in one pass.
|
|
986
|
+
// --allow-domain-mismatch skips this gate (worktree/CI lag escape hatch).
|
|
987
|
+
if (requiredFields && !allowDomainMismatch) {
|
|
988
|
+
const missingFields = findMissingDomainFields(
|
|
989
|
+
record as unknown as Record<string, unknown>,
|
|
990
|
+
requiredFields,
|
|
991
|
+
);
|
|
992
|
+
if (missingFields.length > 0) {
|
|
993
|
+
const fieldList = missingFields.map((f) => `"${f}"`).join(", ");
|
|
994
|
+
const msg = `domain "${domain}" requires field(s) ${fieldList}.`;
|
|
995
|
+
if (jsonMode) {
|
|
996
|
+
outputJsonError("record", msg);
|
|
997
|
+
} else {
|
|
998
|
+
console.error(chalk.red(`Error: ${msg}`));
|
|
999
|
+
const retryCmd = buildRetryCommand(
|
|
1000
|
+
domain,
|
|
1001
|
+
content,
|
|
1002
|
+
options,
|
|
1003
|
+
missingFields.map((f) => ({
|
|
1004
|
+
flag: `--${f.replace(/_/g, "-")}`,
|
|
1005
|
+
placeholder: `<${f}>`,
|
|
1006
|
+
})),
|
|
1007
|
+
);
|
|
1008
|
+
console.error(chalk.dim(` Retry: ${retryCmd}`));
|
|
1009
|
+
}
|
|
1010
|
+
process.exitCode = 1;
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const filePath = getExpertisePath(domain);
|
|
1016
|
+
const dryRun = options.dryRun === true;
|
|
1017
|
+
|
|
1018
|
+
// Fire pre-record hook outside the file lock. Skipped in dry-run since
|
|
1019
|
+
// dry-run shouldn't trigger external side effects (slack, scanners, etc).
|
|
1020
|
+
let preRecordWarnings: string[] = [];
|
|
1021
|
+
if (!dryRun) {
|
|
1022
|
+
const preResult = await runHooks<{ domain: string; record: ExpertiseRecord }>(
|
|
1023
|
+
"pre-record",
|
|
1024
|
+
{ domain, record },
|
|
1025
|
+
);
|
|
1026
|
+
if (preResult.blocked) {
|
|
1027
|
+
const reason = preResult.blockReason ?? "pre-record hook blocked the write";
|
|
1028
|
+
if (jsonMode) {
|
|
1029
|
+
outputJsonError("record", reason);
|
|
1030
|
+
} else {
|
|
1031
|
+
console.error(chalk.red(`Error: ${reason}`));
|
|
1032
|
+
}
|
|
1033
|
+
process.exitCode = 1;
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
preRecordWarnings = preResult.warnings;
|
|
1037
|
+
if (preResult.ranAny && preResult.payload?.record) {
|
|
1038
|
+
const mutated = preResult.payload.record;
|
|
1039
|
+
if (mutated !== record) {
|
|
1040
|
+
if (!validate(mutated)) {
|
|
1041
|
+
const errs = (validate.errors ?? [])
|
|
1042
|
+
.map((e) => `${e.instancePath} ${e.message}`)
|
|
1043
|
+
.join("; ");
|
|
1044
|
+
const msg = `pre-record hook produced an invalid record: ${errs}`;
|
|
1045
|
+
if (jsonMode) {
|
|
1046
|
+
outputJsonError("record", msg);
|
|
1047
|
+
} else {
|
|
1048
|
+
console.error(chalk.red(`Error: ${msg}`));
|
|
1049
|
+
}
|
|
1050
|
+
process.exitCode = 1;
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
record = mutated;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (dryRun) {
|
|
1059
|
+
// Dry-run: check for duplicates without writing
|
|
1060
|
+
const existing = await readExpertiseFile(filePath);
|
|
1061
|
+
const dup = findDuplicate(existing, record);
|
|
1062
|
+
|
|
1063
|
+
let action = "created";
|
|
1064
|
+
if (dup && !options.force) {
|
|
1065
|
+
action = isNamedType(def) ? "updated" : "skipped";
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (jsonMode) {
|
|
1069
|
+
outputJson({
|
|
1070
|
+
success: true,
|
|
1071
|
+
command: "record",
|
|
1072
|
+
action: "dry-run",
|
|
1073
|
+
wouldDo: action,
|
|
1074
|
+
domain,
|
|
1075
|
+
type: recordType,
|
|
1076
|
+
record,
|
|
1077
|
+
...(disabledWarning ? { warnings: [disabledWarning] } : {}),
|
|
1078
|
+
});
|
|
1079
|
+
} else {
|
|
1080
|
+
if (action === "created") {
|
|
1081
|
+
if (!isQuiet())
|
|
1082
|
+
console.log(
|
|
1083
|
+
`${brand("✓")} ${brand(`Dry-run: Would create ${recordType} in ${domain}`)}`,
|
|
1084
|
+
);
|
|
1085
|
+
} else if (action === "updated") {
|
|
1086
|
+
if (!isQuiet())
|
|
1087
|
+
console.log(
|
|
1088
|
+
`${brand("✓")} ${brand(`Dry-run: Would update existing ${recordType} in ${domain}`)}`,
|
|
1089
|
+
);
|
|
1090
|
+
} else {
|
|
1091
|
+
if (!isQuiet())
|
|
1092
|
+
console.log(
|
|
1093
|
+
chalk.yellow(
|
|
1094
|
+
`Dry-run: Duplicate ${recordType} already exists in ${domain}. Would skip.`,
|
|
1095
|
+
),
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
if (!isQuiet()) console.log(chalk.dim(" Run without --dry-run to apply changes."));
|
|
1099
|
+
}
|
|
1100
|
+
} else {
|
|
1101
|
+
// Normal mode: write with file locking, then fire post-record outside
|
|
1102
|
+
// the lock so observation hooks (slack notifications, etc.) can't
|
|
1103
|
+
// stall holders or risk re-entrant deadlock.
|
|
1104
|
+
type WriteOutcome =
|
|
1105
|
+
| { action: "created"; record: ExpertiseRecord }
|
|
1106
|
+
| { action: "updated"; record: ExpertiseRecord; index: number }
|
|
1107
|
+
| { action: "skipped"; index: number };
|
|
1108
|
+
|
|
1109
|
+
const outcome = await withFileLock<WriteOutcome | null>(filePath, async () => {
|
|
1110
|
+
const existing = await readExpertiseFile(filePath);
|
|
1111
|
+
const dup = findDuplicate(existing, record);
|
|
1112
|
+
|
|
1113
|
+
if (dup && !options.force) {
|
|
1114
|
+
if (isNamedType(def)) {
|
|
1115
|
+
const existingRecord = existing[dup.index];
|
|
1116
|
+
if (!existingRecord) return null;
|
|
1117
|
+
const mergedOutcomes = [
|
|
1118
|
+
...(existingRecord.outcomes ?? []),
|
|
1119
|
+
...(record.outcomes ?? []),
|
|
1120
|
+
];
|
|
1121
|
+
const upsertRecord =
|
|
1122
|
+
mergedOutcomes.length > 0 ? { ...record, outcomes: mergedOutcomes } : record;
|
|
1123
|
+
existing[dup.index] = upsertRecord;
|
|
1124
|
+
await writeExpertiseFile(filePath, existing);
|
|
1125
|
+
return { action: "updated", record: upsertRecord, index: dup.index };
|
|
1126
|
+
}
|
|
1127
|
+
return { action: "skipped", index: dup.index };
|
|
1128
|
+
}
|
|
1129
|
+
await appendRecord(filePath, record);
|
|
1130
|
+
return { action: "created", record };
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
if (!outcome) return;
|
|
1134
|
+
|
|
1135
|
+
// Post-record only fires for actual writes (created/updated). Skipped
|
|
1136
|
+
// duplicates are no-ops by design.
|
|
1137
|
+
const postWarnings: string[] = [];
|
|
1138
|
+
if (outcome.action === "created" || outcome.action === "updated") {
|
|
1139
|
+
const postResult = await runHooks("post-record", {
|
|
1140
|
+
domain,
|
|
1141
|
+
record: outcome.record,
|
|
1142
|
+
action: outcome.action,
|
|
1143
|
+
});
|
|
1144
|
+
postWarnings.push(...postResult.warnings);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const collectedWarnings = [
|
|
1148
|
+
...(disabledWarning ? [disabledWarning] : []),
|
|
1149
|
+
...preRecordWarnings,
|
|
1150
|
+
...postWarnings,
|
|
1151
|
+
];
|
|
1152
|
+
|
|
1153
|
+
if (jsonMode) {
|
|
1154
|
+
if (outcome.action === "updated") {
|
|
1155
|
+
outputJson({
|
|
1156
|
+
success: true,
|
|
1157
|
+
command: "record",
|
|
1158
|
+
action: "updated",
|
|
1159
|
+
domain,
|
|
1160
|
+
type: recordType,
|
|
1161
|
+
index: outcome.index + 1,
|
|
1162
|
+
record: outcome.record,
|
|
1163
|
+
...(collectedWarnings.length > 0 ? { warnings: collectedWarnings } : {}),
|
|
1164
|
+
});
|
|
1165
|
+
} else if (outcome.action === "skipped") {
|
|
1166
|
+
outputJson({
|
|
1167
|
+
success: true,
|
|
1168
|
+
command: "record",
|
|
1169
|
+
action: "skipped",
|
|
1170
|
+
domain,
|
|
1171
|
+
type: recordType,
|
|
1172
|
+
index: outcome.index + 1,
|
|
1173
|
+
...(collectedWarnings.length > 0 ? { warnings: collectedWarnings } : {}),
|
|
1174
|
+
});
|
|
1175
|
+
} else {
|
|
1176
|
+
outputJson({
|
|
1177
|
+
success: true,
|
|
1178
|
+
command: "record",
|
|
1179
|
+
action: "created",
|
|
1180
|
+
domain,
|
|
1181
|
+
type: recordType,
|
|
1182
|
+
record: outcome.record,
|
|
1183
|
+
...(collectedWarnings.length > 0 ? { warnings: collectedWarnings } : {}),
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
} else {
|
|
1187
|
+
if (outcome.action === "updated") {
|
|
1188
|
+
if (!isQuiet())
|
|
1189
|
+
console.log(
|
|
1190
|
+
`${brand("✓")} ${brand(`Updated existing ${recordType} in ${domain} (record #${outcome.index + 1})`)}`,
|
|
1191
|
+
);
|
|
1192
|
+
} else if (outcome.action === "skipped") {
|
|
1193
|
+
if (!isQuiet())
|
|
1194
|
+
console.log(
|
|
1195
|
+
chalk.yellow(
|
|
1196
|
+
`Duplicate ${recordType} already exists in ${domain} (record #${outcome.index + 1}). Use --force to add anyway.`,
|
|
1197
|
+
),
|
|
1198
|
+
);
|
|
1199
|
+
} else {
|
|
1200
|
+
if (!isQuiet())
|
|
1201
|
+
console.log(`${brand("✓")} ${brand(`Recorded ${recordType} in ${domain}`)}`);
|
|
1202
|
+
}
|
|
1203
|
+
for (const w of [...preRecordWarnings, ...postWarnings]) {
|
|
1204
|
+
console.error(chalk.yellow(`Warning: ${w}`));
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
},
|
|
1209
|
+
);
|
|
1210
|
+
}
|