@cairnkeep/cli 2.15.4 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/CONTRIBUTING.md +12 -0
  3. package/README.md +85 -590
  4. package/docs/README.md +87 -0
  5. package/docs/agents.md +193 -0
  6. package/docs/context-intelligence.md +143 -0
  7. package/docs/context-packs.md +33 -0
  8. package/docs/domain-knowledge.md +29 -0
  9. package/docs/ecosystem.md +23 -0
  10. package/docs/learning/CURRICULUM-MAP.md +3 -1
  11. package/docs/learning/FEATURE-GUIDE.md +23 -4
  12. package/docs/learning/README.md +4 -0
  13. package/docs/learning/lessons/L00-why-cairnkeep.md +3 -1
  14. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  15. package/docs/learning/lessons/L02-installation.md +2 -2
  16. package/docs/learning/lessons/L03-first-project.md +9 -10
  17. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  18. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  19. package/docs/learning/lessons/L21-context-packs.md +1 -1
  20. package/docs/learning/lessons/L22-native-windows.md +2 -2
  21. package/docs/learning/lessons/L23-guided-setup.md +18 -5
  22. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  23. package/docs/learning/lessons/L25-playbooks.md +1 -1
  24. package/docs/learning/lessons/L26-context-intelligence.md +180 -0
  25. package/docs/learning/tracks/evidence-and-evaluation.md +1 -0
  26. package/docs/learning/tracks/operator.md +1 -0
  27. package/docs/learning/tracks/practitioner.md +1 -0
  28. package/docs/learning/video-scripts/V26-context-intelligence.md +144 -0
  29. package/docs/native-windows.md +5 -0
  30. package/docs/operating.md +65 -3
  31. package/docs/privacy-and-data-flow.md +22 -0
  32. package/docs/quickstart.md +12 -4
  33. package/docs/releasing.md +27 -19
  34. package/docs/storage.md +21 -0
  35. package/docs/work-evidence.md +14 -0
  36. package/examples/eval/bundled-fake.json +1 -1
  37. package/mcp-memory-server/dist/context-pack-cli.js +3 -1
  38. package/mcp-memory-server/dist/context-pack-retrieval.js +413 -0
  39. package/mcp-memory-server/dist/context-pack.js +171 -3
  40. package/mcp-memory-server/dist/domain-retrieval-provider.js +257 -0
  41. package/mcp-memory-server/dist/index.js +73 -387
  42. package/mcp-memory-server/dist/mcp-tool-catalog.js +2 -0
  43. package/mcp-memory-server/dist/memory-extraction.js +87 -0
  44. package/mcp-memory-server/dist/memory-proposal-cli.js +110 -0
  45. package/mcp-memory-server/dist/memory-proposal-schema.js +78 -0
  46. package/mcp-memory-server/dist/memory-proposal-store.js +273 -0
  47. package/mcp-memory-server/dist/platform-security.js +32 -26
  48. package/mcp-memory-server/dist/retrieval-benchmark.js +199 -0
  49. package/mcp-memory-server/dist/reviewed-memory-store.js +281 -0
  50. package/mcp-memory-server/dist/work-evidence-schema.js +17 -0
  51. package/mcp-memory-server/dist/work-evidence-store.js +57 -1
  52. package/mcp-memory-server/package.json +8 -1
  53. package/package.json +1 -1
  54. package/schemas/context-usage-receipt.schema.json +41 -0
  55. package/schemas/memory-proposal.schema.json +65 -0
  56. package/schemas/retrieval-benchmark.schema.json +64 -0
  57. package/scripts/cairn-cli.mjs +3 -0
  58. package/scripts/completion.sh +17 -6
  59. package/scripts/doctor.sh +4 -2
  60. package/scripts/setup.mjs +92 -13
  61. package/scripts/terminal-prompts.mjs +172 -0
  62. package/scripts/test-completion.sh +8 -1
  63. package/scripts/test-docs-navigation.sh +63 -0
  64. package/scripts/test-doctor.sh +21 -0
  65. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  66. package/scripts/test-release-sbom.sh +44 -16
  67. package/scripts/test-setup-preflight.mjs +130 -0
  68. package/scripts/test-windows-native.mjs +30 -0
  69. package/scripts/verify-docs-parity.sh +28 -28
  70. package/scripts/windows-platform.mjs +8 -5
@@ -0,0 +1,41 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/cairnkeep/cairnkeep/main/schemas/context-usage-receipt.schema.json",
4
+ "title": "Cairnkeep context usage receipt v1",
5
+ "description": "A privacy-minimal link from existing work evidence to a retrieved result. Receipts contain identifiers, digests, outcome labels and a timestamp; never retrieved content, queries, prompts or model output.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "schema_version",
10
+ "link_id",
11
+ "evidence_id",
12
+ "kind",
13
+ "created_at",
14
+ "receipt_id",
15
+ "task_digest",
16
+ "result_digest",
17
+ "outcome"
18
+ ],
19
+ "properties": {
20
+ "schema_version": { "const": 1 },
21
+ "link_id": {
22
+ "description": "Equal to receipt_id for context usage links.",
23
+ "type": "string",
24
+ "pattern": "^[a-f0-9]{64}$"
25
+ },
26
+ "evidence_id": {
27
+ "type": "string",
28
+ "pattern": "^wev_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
29
+ },
30
+ "kind": { "const": "context_usage" },
31
+ "created_at": { "type": "string", "format": "date-time" },
32
+ "receipt_id": {
33
+ "description": "SHA-256 of canonical evidence_id, task_digest and result_digest fields. The outcome is deliberately excluded so a changed outcome is a conflict, not a second receipt.",
34
+ "type": "string",
35
+ "pattern": "^[a-f0-9]{64}$"
36
+ },
37
+ "task_digest": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
38
+ "result_digest": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
39
+ "outcome": { "enum": ["used", "unused", "unknown"] }
40
+ }
41
+ }
@@ -0,0 +1,65 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/cairnkeep/cairnkeep/main/schemas/memory-proposal.schema.json",
4
+ "title": "Cairnkeep review-gated memory proposal",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "created_at", "project_root", "scope", "source", "extraction", "candidates", "digest"],
8
+ "properties": {
9
+ "schema_version": { "const": 1 },
10
+ "created_at": { "type": "string", "format": "date-time" },
11
+ "project_root": { "type": "string", "minLength": 1 },
12
+ "scope": { "type": "string", "pattern": "^(project|[a-z0-9][a-z0-9-]*)$" },
13
+ "source": {
14
+ "type": "object",
15
+ "additionalProperties": false,
16
+ "required": ["kind", "session_id", "digest"],
17
+ "properties": {
18
+ "kind": { "const": "trajectory" },
19
+ "session_id": { "type": "string", "minLength": 1, "maxLength": 256 },
20
+ "digest": { "$ref": "#/$defs/digest" }
21
+ }
22
+ },
23
+ "extraction": {
24
+ "type": "object",
25
+ "additionalProperties": false,
26
+ "required": ["model"],
27
+ "properties": { "model": { "type": "string", "minLength": 1, "maxLength": 256 } }
28
+ },
29
+ "candidates": {
30
+ "type": "array",
31
+ "minItems": 1,
32
+ "maxItems": 128,
33
+ "items": { "$ref": "#/$defs/candidate" }
34
+ },
35
+ "digest": { "$ref": "#/$defs/digest" }
36
+ },
37
+ "$defs": {
38
+ "digest": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
39
+ "candidate": {
40
+ "type": "object",
41
+ "additionalProperties": false,
42
+ "required": ["key", "value", "operation", "base_hash", "value_hash"],
43
+ "$comment": "Runtime verification also requires value_hash to equal SHA-256(value), noop base_hash to equal value_hash, and update base_hash to differ from value_hash.",
44
+ "properties": {
45
+ "key": { "type": "string", "minLength": 1, "maxLength": 512 },
46
+ "value": { "type": "string", "maxLength": 65536 },
47
+ "category": { "type": "string", "maxLength": 64 },
48
+ "importance": { "type": "number", "minimum": 0, "maximum": 1 },
49
+ "operation": { "enum": ["create", "update", "noop"] },
50
+ "base_hash": { "oneOf": [{ "$ref": "#/$defs/digest" }, { "type": "null" }] },
51
+ "value_hash": { "$ref": "#/$defs/digest" }
52
+ },
53
+ "allOf": [
54
+ {
55
+ "if": { "properties": { "operation": { "const": "create" } }, "required": ["operation"] },
56
+ "then": { "properties": { "base_hash": { "type": "null" } } }
57
+ },
58
+ {
59
+ "if": { "properties": { "operation": { "enum": ["update", "noop"] } }, "required": ["operation"] },
60
+ "then": { "properties": { "base_hash": { "$ref": "#/$defs/digest" } } }
61
+ }
62
+ ]
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,64 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://cairnkeep.dev/schemas/retrieval-benchmark.schema.json",
4
+ "title": "Cairnkeep retrieval benchmark suite",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "id", "description", "packs", "cases"],
8
+ "properties": {
9
+ "schema_version": { "const": 1 },
10
+ "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
11
+ "description": { "type": "string", "minLength": 1 },
12
+ "packs": {
13
+ "type": "array",
14
+ "minItems": 1,
15
+ "maxItems": 32,
16
+ "items": {
17
+ "type": "object",
18
+ "additionalProperties": false,
19
+ "required": ["id", "version", "title", "enabled", "files"],
20
+ "properties": {
21
+ "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
22
+ "version": { "type": "string", "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$" },
23
+ "title": { "type": "string", "minLength": 1 },
24
+ "enabled": { "type": "boolean" },
25
+ "files": {
26
+ "type": "array",
27
+ "minItems": 1,
28
+ "maxItems": 1024,
29
+ "items": {
30
+ "type": "object",
31
+ "additionalProperties": false,
32
+ "required": ["path", "kind", "title", "description", "keywords", "content"],
33
+ "properties": {
34
+ "path": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" },
35
+ "kind": { "enum": ["document", "skill"] },
36
+ "title": { "type": "string", "minLength": 1 },
37
+ "description": { "type": "string" },
38
+ "keywords": { "type": "array", "items": { "type": "string" } },
39
+ "content": { "type": "string" }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ },
46
+ "cases": {
47
+ "type": "array",
48
+ "minItems": 1,
49
+ "maxItems": 1024,
50
+ "items": {
51
+ "type": "object",
52
+ "additionalProperties": false,
53
+ "required": ["id", "query", "relevant", "forbidden", "limit"],
54
+ "properties": {
55
+ "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
56
+ "query": { "type": "string", "minLength": 1 },
57
+ "relevant": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } },
58
+ "forbidden": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } },
59
+ "limit": { "type": "integer", "minimum": 1, "maximum": 100 }
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }
@@ -9,6 +9,7 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
9
9
  const NODE_COMMANDS = new Map([
10
10
  ["memory-server", "index.js"],
11
11
  ["trajectory", "trajectory-cli.js"],
12
+ ["proposals", "memory-proposal-cli.js"],
12
13
  ["artifact", "artifact-cli.js"],
13
14
  ["capabilities", "capability-cli.js"],
14
15
  ["mcp-tools", "mcp-tool-cli.js"],
@@ -37,6 +38,7 @@ export const USAGE = `cairn — Cairnkeep CLI
37
38
 
38
39
  Usage:
39
40
  cairn bootstrap [--untracked] [path]
41
+ cairn setup [PATH] # interactive Git, harness, memory, and confirmation selectors
40
42
  cairn setup PATH --git init|existing|none --harness LIST --memory local|none [--policy PATH] --yes [--json]
41
43
  cairn memory-server
42
44
  cairn sync [--check|--apply] [--live-root DIR]
@@ -44,6 +46,7 @@ Usage:
44
46
  cairn sync-kimi [--check|--apply] [--live-root DIR]
45
47
  cairn doctor [--repair]
46
48
  cairn trajectory <list|show|prune>
49
+ cairn proposals <create|list|show|apply|doctor>
47
50
  cairn artifact <list|show|delete|prune>
48
51
  cairn capabilities <list|status|enable|disable|reset|logging>
49
52
  cairn mcp-tools <list|status|set|reset>
@@ -13,7 +13,7 @@ _cairn_complete() {
13
13
  COMPREPLY=()
14
14
  current=${COMP_WORDS[COMP_CWORD]}
15
15
  previous=${COMP_WORDS[COMP_CWORD-1]:-}
16
- commands="bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact evidence playbook capabilities mcp-tools pack notes eval skill graph memory audit-timer uninstall completion version help"
16
+ commands="bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help"
17
17
  if (( COMP_CWORD == 1 )); then
18
18
  COMPREPLY=( $(compgen -W "$commands" -- "$current") )
19
19
  return
@@ -57,7 +57,8 @@ _cairn_complete() {
57
57
  esac
58
58
  ;;
59
59
  mcp-tools) COMPREPLY=( $(compgen -W "list status set reset full read-only custom --tool --project --json" -- "$current") ) ;;
60
- pack) COMPREPLY=( $(compgen -W "init lock validate install import-okf validate-okf export-okf list show remove enable disable update skills approve-skill revoke-skill --id --version --title --description --license --ref --project --project-id --output --file --note --check --apply --confirm --json" -- "$current") ) ;;
60
+ pack) COMPREPLY=( $(compgen -W "init lock validate install import-okf validate-okf export-okf list show remove enable disable update skills approve-skill revoke-skill doctor --id --version --title --description --license --ref --project --project-id --output --file --note --check --apply --confirm --repair --json" -- "$current") ) ;;
61
+ proposals) COMPREPLY=( $(compgen -W "create list show apply doctor --session --scope --model --category --project --json" -- "$current") ) ;;
61
62
  notes) COMPREPLY=( $(compgen -W "distill search-error promote doctor --project --session --all-projects --para-root --text --component --with --confirm --repair --json" -- "$current") ) ;;
62
63
  eval)
63
64
  case "${COMP_WORDS[2]:-}" in
@@ -101,6 +102,7 @@ _cairn() {
101
102
  'capabilities:inspect and manage project capability state'
102
103
  'mcp-tools:inspect and restrict MCP tool exposure'
103
104
  'pack:manage immutable context packs and skill approvals'
105
+ 'proposals:review and apply session memory proposals'
104
106
  'notes:distill and search local hindsight notes'
105
107
  'eval:run and inspect default-off local evaluations'
106
108
  'skill:review and evaluate evidence-backed skill improvements'
@@ -136,7 +138,8 @@ _cairn() {
136
138
  evidence) _values 'work evidence command' list show delete prune doctor '--status[filter records]:status:(pending complete)' '--dry-run[report without mutation]' '--repair[remove safe temporary remnants]' '--json[emit JSON]' ;;
137
139
  playbook) _values 'playbook command' list status init set enable disable reset check record receipts instructions doctor minimal balanced strict must should may off start finish install remove context.recall context.explore work.plan verify.tests review.repository review.security docs.update learning.capture '--project[project root]:directory:_files -/' '--json[emit JSON]' '--enforce[fail when must evidence is missing]' '--changed[changed project path]:file:_files' '--change-type[change type]:type:(code tests docs config dependencies security)' '--complexity[task complexity]:level:(trivial standard complex)' '--familiarity[context familiarity]:level:(known mixed unfamiliar)' '--risk[task risk]:level:(low normal high security)' '--public-change[public behavior changed]' '--completed[completed action]:action:' '--skipped[skipped action and reason]:evidence:' '--failed[failed action and reason]:evidence:' '--actor[local actor label]:actor:' '--actor-kind[local actor kind]:kind:(user agent service)' '--session[session label]:session:' '--policy[policy digest]:digest:' '--decision[decision digest]:digest:' '--event[lifecycle event]:event:(start check finish)' '--action[canonical action]:action:(context.recall context.explore work.plan verify.tests review.repository review.security docs.update learning.capture)' '--outcome[outcome]:outcome:(completed skipped failed)' '--reason[bounded reason]:reason:' ;;
138
140
  mcp-tools) _values 'MCP tool profile command' list status set reset full read-only custom '--tool[allow an exact tool]:tool:' '--project[project root]:directory:_files -/' '--json[emit JSON]' ;;
139
- pack) _values 'context pack command' init lock validate install import-okf validate-okf export-okf list show remove enable disable update skills approve-skill revoke-skill '--ref[pinned Git ref]:ref:' '--project[project root]:directory:_files -/' '--project-id[remote project ID]:project ID:' '--output[export output directory]:directory:_files -/' '--file[explicit project Markdown file]:file:_files' '--note[promoted shared note ID]:note ID:' '--check[inspect update or export]' '--apply[apply update or export]' '--confirm[confirm digest]:digest:' '--json[emit JSON]' ;;
141
+ pack) _values 'context pack command' init lock validate install import-okf validate-okf export-okf list show remove enable disable update skills approve-skill revoke-skill doctor '--ref[pinned Git ref]:ref:' '--project[project root]:directory:_files -/' '--project-id[remote project ID]:project ID:' '--output[export output directory]:directory:_files -/' '--file[explicit project Markdown file]:file:_files' '--note[promoted shared note ID]:note ID:' '--check[inspect update or export]' '--apply[apply update or export]' '--confirm[confirm digest]:digest:' '--repair[remove only invalid derived context cache state]' '--json[emit JSON]' ;;
142
+ proposals) _values 'memory proposal command' create list show apply doctor '--session[trajectory session ID]:session:' '--scope[memory scope]:scope:' '--model[extraction model]:model:' '--category[extraction category]:category:' '--project[project root]:directory:_files -/' '--json[emit JSON]' ;;
140
143
  capabilities)
141
144
  case $words[3] in
142
145
  list|status) _arguments '--json[emit JSON]' ;;
@@ -185,8 +188,8 @@ EOF
185
188
  fish)
186
189
  sed "s/__CAIRN_HARNESSES__/$harnesses/g" <<'EOF'
187
190
  complete -c cairn -f
188
- for command in bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact evidence playbook capabilities mcp-tools pack notes eval skill graph memory audit-timer uninstall completion version help
189
- complete -c cairn -n "not __fish_seen_subcommand_from bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact evidence playbook capabilities mcp-tools pack notes eval skill graph memory audit-timer uninstall completion version help" -a $command
191
+ for command in bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help
192
+ complete -c cairn -n "not __fish_seen_subcommand_from bootstrap setup memory-server sync sync-pi sync-kimi doctor trajectory artifact evidence playbook capabilities mcp-tools pack proposals notes eval skill graph memory audit-timer uninstall completion version help" -a $command
190
193
  end
191
194
  complete -c cairn -n "__fish_seen_subcommand_from sync" -l apply
192
195
  complete -c cairn -n "__fish_seen_subcommand_from sync" -l live-root -r
@@ -243,12 +246,20 @@ complete -c cairn -n "__fish_seen_subcommand_from mcp-tools; and __fish_seen_sub
243
246
  complete -c cairn -n "__fish_seen_subcommand_from mcp-tools" -l project -r
244
247
  complete -c cairn -n "__fish_seen_subcommand_from mcp-tools" -l tool -r
245
248
  complete -c cairn -n "__fish_seen_subcommand_from mcp-tools" -l json
246
- complete -c cairn -n "__fish_seen_subcommand_from pack" -a "init lock validate install import-okf validate-okf export-okf list show remove enable disable update skills approve-skill revoke-skill"
249
+ complete -c cairn -n "__fish_seen_subcommand_from pack" -a "init lock validate install import-okf validate-okf export-okf list show remove enable disable update skills approve-skill revoke-skill doctor"
247
250
  complete -c cairn -n "__fish_seen_subcommand_from pack" -l project -r
248
251
  complete -c cairn -n "__fish_seen_subcommand_from pack" -l project-id -r
249
252
  complete -c cairn -n "__fish_seen_subcommand_from pack" -l ref -r
250
253
  complete -c cairn -n "__fish_seen_subcommand_from pack" -l confirm -r
251
254
  complete -c cairn -n "__fish_seen_subcommand_from pack" -l json
255
+ complete -c cairn -n "__fish_seen_subcommand_from pack; and __fish_seen_subcommand_from doctor" -l repair
256
+ complete -c cairn -n "__fish_seen_subcommand_from proposals" -a "create list show apply doctor"
257
+ complete -c cairn -n "__fish_seen_subcommand_from proposals" -l session -r
258
+ complete -c cairn -n "__fish_seen_subcommand_from proposals" -l scope -r
259
+ complete -c cairn -n "__fish_seen_subcommand_from proposals" -l model -r
260
+ complete -c cairn -n "__fish_seen_subcommand_from proposals" -l category -r
261
+ complete -c cairn -n "__fish_seen_subcommand_from proposals" -l project -r
262
+ complete -c cairn -n "__fish_seen_subcommand_from proposals" -l json
252
263
  complete -c cairn -n "__fish_seen_subcommand_from notes" -a "distill search-error promote doctor"
253
264
  complete -c cairn -n "__fish_seen_subcommand_from eval; and not __fish_seen_subcommand_from validate run ablate report prune delete" -a "validate run ablate report prune delete"
254
265
  complete -c cairn -n "__fish_seen_subcommand_from eval; and __fish_seen_subcommand_from validate run ablate" -l task-set -r
package/scripts/doctor.sh CHANGED
@@ -13,7 +13,7 @@ REPAIR_STORES=0
13
13
  while [[ $# -gt 0 ]]; do
14
14
  case "$1" in
15
15
  --repair) REPAIR_STORES=1; shift ;;
16
- -h|--help) echo "Usage: cairn doctor [--repair] # repair derived trajectory/artifact/typed metadata, indexes, and interrupted note transactions"; exit 0 ;;
16
+ -h|--help) echo "Usage: cairn doctor [--repair] # repair safe derived pack caches, typed metadata/indexes, and interrupted note transactions"; exit 0 ;;
17
17
  *) echo "Unknown doctor option: $1" >&2; echo "Usage: cairn doctor [--repair]" >&2; exit 2 ;;
18
18
  esac
19
19
  done
@@ -470,11 +470,13 @@ fi
470
470
  pack_cli="$CAIRN_ROOT/mcp-memory-server/dist/context-pack-cli.js"
471
471
  pack_base="${CAIRN_PACK_BASE_DIR:-$HOME/.cairnkeep/packs}"
472
472
  pack_base="${pack_base/#\~/$HOME}"
473
+ pack_args=(doctor --json)
474
+ if [[ "$REPAIR_STORES" == 1 ]]; then pack_args+=(--repair); fi
473
475
  if [[ ! -e "$pack_base" ]]; then
474
476
  skip "context packs (not installed)"
475
477
  elif [[ ! -f "$pack_cli" ]]; then
476
478
  fail "context-pack checker is missing from the installed package"
477
- elif pack_json=$(node "$pack_cli" doctor --json 2>/dev/null) && node -e 'const v=JSON.parse(process.argv[1]); if(v.ok!==true)process.exit(1)' "$pack_json"; then
479
+ elif pack_json=$(node "$pack_cli" "${pack_args[@]}" 2>/dev/null) && node -e 'const v=JSON.parse(process.argv[1]); if(v.ok!==true)process.exit(1)' "$pack_json"; then
478
480
  pack_counts=$(node -e 'const v=JSON.parse(process.argv[1]); process.stdout.write(`${v.objects} object(s), ${v.projects} project pointer(s)`)' "$pack_json")
479
481
  pass "context packs healthy ($pack_counts)"
480
482
  else
package/scripts/setup.mjs CHANGED
@@ -21,8 +21,9 @@ import {
21
21
  resolveSetupChoices,
22
22
  } from "./setup-core.mjs";
23
23
  import { reconcileSetupPlan } from "./setup-reconcile.mjs";
24
- import { HARNESS_IDS, machineSyncCommand, requiredHarnessAssetPaths } from "./harness-registry.mjs";
24
+ import { HARNESS_IDS, HARNESS_REGISTRY, machineSyncCommand, requiredHarnessAssetPaths } from "./harness-registry.mjs";
25
25
  import { reconcilePlaybookInstructions } from "./playbook-instructions.mjs";
26
+ import { selectManyPrompt, selectOnePrompt, supportsTerminalPrompts } from "./terminal-prompts.mjs";
26
27
 
27
28
  const HARNESSES = HARNESS_IDS;
28
29
  const GIT_MODES = Object.freeze(["init", "existing", "none"]);
@@ -70,11 +71,36 @@ function parseInteractiveHarnesses(value) {
70
71
  }
71
72
 
72
73
  export async function promptSetupChoices(parsed, streams) {
73
- const terminal = typeof streams.question === "function"
74
- ? { question: streams.question, close: streams.closePrompt ?? (() => {}) }
75
- : createInterface({ input: streams.input, output: streams.output });
74
+ let linePrompt = null;
75
+ let externallyClosed = false;
76
+ const question = typeof streams.question === "function"
77
+ ? streams.question
78
+ : async (message) => {
79
+ linePrompt ??= createInterface({ input: streams.input, output: streams.output });
80
+ return linePrompt.question(message);
81
+ };
82
+ const closeLinePrompt = () => {
83
+ if (linePrompt) linePrompt.close();
84
+ linePrompt = null;
85
+ if (!externallyClosed) {
86
+ streams.closePrompt?.();
87
+ externallyClosed = true;
88
+ }
89
+ };
90
+ const structured = Boolean(streams.selectOne && streams.selectMany)
91
+ || supportsTerminalPrompts(streams.input, streams.output);
92
+ const selectOne = streams.selectOne ?? ((options) => selectOnePrompt({
93
+ ...options,
94
+ input: streams.input,
95
+ output: streams.output,
96
+ }));
97
+ const selectMany = streams.selectMany ?? ((options) => selectManyPrompt({
98
+ ...options,
99
+ input: streams.input,
100
+ output: streams.output,
101
+ }));
76
102
  try {
77
- const target = parsed.target ?? (await terminal.question("Target path: ")).trim();
103
+ const target = parsed.target ?? (await question("Target path: ")).trim();
78
104
  if (!target) throw operational("interactive-input", "A setup target path is required.");
79
105
  const preflight = classifySetupTarget(target);
80
106
  const gitQuestion = preflight.targetState === "missing" || preflight.targetState === "empty"
@@ -82,12 +108,63 @@ export async function promptSetupChoices(parsed, streams) {
82
108
  : preflight.repository === "work-tree"
83
109
  ? "Git mode (existing recommended; choices: init, existing, none): "
84
110
  : "Git mode (init requires explicit choice for existing non-Git target; choices: init, existing, none): ";
85
- const git = parsed.git ?? (await terminal.question(gitQuestion)).trim();
86
- const harnesses = parsed.harnesses ?? parseInteractiveHarnesses(await terminal.question(`Harnesses (${HARNESSES.join(", ")}): `));
87
- const memory = parsed.memory ?? (await terminal.question("Memory mode (local, none): ")).trim();
88
- return { target, git, harnesses, memory, preflight, terminal };
111
+ if (structured) closeLinePrompt();
112
+ const gitChoices = preflight.gitExecutable === "missing"
113
+ ? [{ value: "none", label: "Continue without Git", hint: "Git executable not found; repository features will be limited" }]
114
+ : preflight.repository === "work-tree"
115
+ ? [
116
+ { value: "existing", label: "Use the existing Git repository", hint: "recommended" },
117
+ { value: "none", label: "Continue without Git integration", hint: "limited" },
118
+ ]
119
+ : [
120
+ { value: "init", label: "Initialize a Git repository", hint: preflight.targetState === "non-empty" ? "review the existing directory first" : "recommended" },
121
+ { value: "none", label: "Continue without Git integration", hint: "limited" },
122
+ ];
123
+ const git = parsed.git ?? (structured
124
+ ? await selectOne({ message: "Git integration", choices: gitChoices, initialValue: gitChoices[0].value })
125
+ : (await question(gitQuestion)).trim());
126
+ const harnesses = parsed.harnesses ?? (structured
127
+ ? await selectMany({
128
+ message: "Coding harnesses",
129
+ choices: HARNESS_REGISTRY.map(({ id, title }) => ({ value: id, label: `${title} (${id})` })),
130
+ })
131
+ : parseInteractiveHarnesses(await question(`Harnesses (${HARNESSES.join(", ")}): `)));
132
+ const memory = parsed.memory ?? (structured
133
+ ? await selectOne({
134
+ message: "Memory mode",
135
+ choices: [
136
+ { value: "local", label: "Local Cairnkeep memory", hint: "recommended" },
137
+ { value: "none", label: "No memory server" },
138
+ ],
139
+ initialValue: "local",
140
+ })
141
+ : (await question("Memory mode (local, none): ")).trim());
142
+ return {
143
+ target,
144
+ git,
145
+ harnesses,
146
+ memory,
147
+ preflight,
148
+ terminal: {
149
+ question,
150
+ close: closeLinePrompt,
151
+ confirm: structured
152
+ ? async () => (await selectOne({
153
+ message: "Apply this setup plan?",
154
+ choices: [
155
+ { value: "no", label: "No, cancel" },
156
+ { value: "yes", label: "Yes, apply the plan" },
157
+ ],
158
+ initialValue: "no",
159
+ })) === "yes"
160
+ : async () => {
161
+ const answer = (await question("Apply this setup plan? [y/N] ")).trim().toLowerCase();
162
+ return answer === "y" || answer === "yes";
163
+ },
164
+ },
165
+ };
89
166
  } catch (error) {
90
- terminal.close();
167
+ closeLinePrompt();
91
168
  throw error;
92
169
  }
93
170
  }
@@ -167,7 +244,7 @@ function initializeGit(target) {
167
244
  }
168
245
 
169
246
  async function executeSetup(args, options) {
170
- const isTTY = options.isTTY ?? Boolean(options.input.isTTY);
247
+ const isTTY = options.isTTY ?? Boolean(options.input.isTTY && options.output.isTTY);
171
248
  const parsed = parseSetupArgs(args, { isTTY });
172
249
  const policy = parsed.policyPath ? readSetupPolicy(parsed.policyPath) : null;
173
250
 
@@ -196,9 +273,9 @@ async function executeSetup(args, options) {
196
273
 
197
274
  if (interactive && !parsed.confirmed) {
198
275
  renderPlan(plan, options.output);
199
- const answer = (await interactive.terminal.question("Apply this setup plan? [y/N] ")).trim().toLowerCase();
276
+ const accepted = await interactive.terminal.confirm();
200
277
  interactive.terminal.close();
201
- if (answer !== "y" && answer !== "yes") {
278
+ if (!accepted) {
202
279
  writeLine(options.output, "Setup cancelled; no files were changed.");
203
280
  return null;
204
281
  }
@@ -221,6 +298,8 @@ export async function runSetup(args, options = {}) {
221
298
  augmentPlan: options.augmentPlan,
222
299
  question: options.question,
223
300
  closePrompt: options.closePrompt,
301
+ selectOne: options.selectOne,
302
+ selectMany: options.selectMany,
224
303
  };
225
304
  try {
226
305
  const result = await executeSetup(args, streams);
@@ -0,0 +1,172 @@
1
+ import { emitKeypressEvents } from "node:readline";
2
+
3
+ function promptError(message, status = 1) {
4
+ const error = new Error(message);
5
+ error.name = "TerminalPromptError";
6
+ error.status = status;
7
+ return error;
8
+ }
9
+
10
+ function normalizedChoices(choices) {
11
+ if (!Array.isArray(choices) || choices.length === 0) throw promptError("A terminal prompt requires at least one choice.");
12
+ const normalized = choices.map((choice) => Object.freeze({
13
+ value: String(choice.value),
14
+ label: String(choice.label),
15
+ hint: choice.hint ? String(choice.hint) : "",
16
+ }));
17
+ if (new Set(normalized.map(({ value }) => value)).size !== normalized.length) {
18
+ throw promptError("Terminal prompt choices must have unique values.");
19
+ }
20
+ return Object.freeze(normalized);
21
+ }
22
+
23
+ export function supportsTerminalPrompts(input, output, env = process.env) {
24
+ return Boolean(
25
+ input?.isTTY
26
+ && output?.isTTY
27
+ && typeof input.setRawMode === "function"
28
+ && env.TERM !== "dumb",
29
+ );
30
+ }
31
+
32
+ async function runKeypressPrompt({ input, output, draw, accept }) {
33
+ emitKeypressEvents(input);
34
+ const wasRaw = Boolean(input.isRaw);
35
+ const wasPaused = typeof input.isPaused === "function" ? input.isPaused() : false;
36
+ let renderedLines = 0;
37
+ let settled = false;
38
+ let keypressHandler = null;
39
+ let rawChanged = false;
40
+ let resumed = false;
41
+ let cursorHidden = false;
42
+
43
+ const render = () => {
44
+ const lines = draw();
45
+ if (renderedLines > 0) output.write(`\x1b[${renderedLines}A\r\x1b[0J`);
46
+ output.write(`${lines.join("\n")}\n`);
47
+ renderedLines = lines.length;
48
+ };
49
+
50
+ const clear = () => {
51
+ if (renderedLines > 0) output.write(`\x1b[${renderedLines}A\r\x1b[0J`);
52
+ renderedLines = 0;
53
+ };
54
+
55
+ try {
56
+ if (!wasRaw) {
57
+ input.setRawMode(true);
58
+ rawChanged = true;
59
+ }
60
+ if (wasPaused && typeof input.resume === "function") {
61
+ input.resume();
62
+ resumed = true;
63
+ }
64
+ output.write("\x1b[?25l");
65
+ cursorHidden = true;
66
+ render();
67
+ return await new Promise((resolve, reject) => {
68
+ const finish = (callback, value) => {
69
+ if (settled) return;
70
+ settled = true;
71
+ input.removeListener("keypress", onKeypress);
72
+ callback(value);
73
+ };
74
+ const onKeypress = (text, key = {}) => {
75
+ try {
76
+ if ((key.ctrl && key.name === "c") || key.name === "escape") {
77
+ finish(reject, promptError("Setup cancelled.", 130));
78
+ return;
79
+ }
80
+ const result = accept(text, key);
81
+ if (result?.done) finish(resolve, result.value);
82
+ else render();
83
+ } catch (error) {
84
+ finish(reject, error);
85
+ }
86
+ };
87
+ keypressHandler = onKeypress;
88
+ input.on("keypress", onKeypress);
89
+ });
90
+ } finally {
91
+ if (keypressHandler) input.removeListener("keypress", keypressHandler);
92
+ clear();
93
+ if (cursorHidden) output.write("\x1b[?25h");
94
+ if (rawChanged) input.setRawMode(false);
95
+ if (resumed && typeof input.pause === "function") input.pause();
96
+ }
97
+ }
98
+
99
+ function movement(key, cursor, length) {
100
+ if (key.name === "up" || key.name === "k") return (cursor - 1 + length) % length;
101
+ if (key.name === "down" || key.name === "j") return (cursor + 1) % length;
102
+ if (key.name === "home") return 0;
103
+ if (key.name === "end") return length - 1;
104
+ return cursor;
105
+ }
106
+
107
+ export async function selectOnePrompt({ message, choices, initialValue, input, output }) {
108
+ const options = normalizedChoices(choices);
109
+ let cursor = Math.max(0, options.findIndex(({ value }) => value === initialValue));
110
+ const selected = await runKeypressPrompt({
111
+ input,
112
+ output,
113
+ draw: () => [
114
+ `? ${message} (Up/Down to move, Enter to select)`,
115
+ ...options.map((choice, index) => `${index === cursor ? ">" : " "} ${choice.label}${choice.hint ? ` - ${choice.hint}` : ""}`),
116
+ ],
117
+ accept: (text, key) => {
118
+ cursor = movement(key, cursor, options.length);
119
+ if (key.name === "return" || key.name === "enter") return { done: true, value: options[cursor].value };
120
+ const number = Number.parseInt(text, 10);
121
+ if (Number.isInteger(number) && number >= 1 && number <= options.length) {
122
+ cursor = number - 1;
123
+ return { done: true, value: options[cursor].value };
124
+ }
125
+ return null;
126
+ },
127
+ });
128
+ const label = options.find(({ value }) => value === selected)?.label ?? selected;
129
+ output.write(`> ${message}: ${label}\n`);
130
+ return selected;
131
+ }
132
+
133
+ export async function selectManyPrompt({ message, choices, selectedValues = [], input, output, minimum = 1 }) {
134
+ const options = normalizedChoices(choices);
135
+ const allowed = new Set(options.map(({ value }) => value));
136
+ const selected = new Set(selectedValues.filter((value) => allowed.has(value)));
137
+ let cursor = 0;
138
+ let validation = "";
139
+ const values = await runKeypressPrompt({
140
+ input,
141
+ output,
142
+ draw: () => [
143
+ `? ${message} (Up/Down to move, Space to toggle, A to toggle all, Enter to continue)`,
144
+ ...options.map((choice, index) => `${index === cursor ? ">" : " "} [${selected.has(choice.value) ? "x" : " "}] ${choice.label}${choice.hint ? ` - ${choice.hint}` : ""}`),
145
+ validation,
146
+ ],
147
+ accept: (text, key) => {
148
+ cursor = movement(key, cursor, options.length);
149
+ if (key.name === "space" || text === " ") {
150
+ const value = options[cursor].value;
151
+ if (selected.has(value)) selected.delete(value);
152
+ else selected.add(value);
153
+ validation = "";
154
+ } else if (text?.toLowerCase() === "a") {
155
+ if (selected.size === options.length) selected.clear();
156
+ else for (const { value } of options) selected.add(value);
157
+ validation = "";
158
+ } else if (key.name === "return" || key.name === "enter") {
159
+ if (selected.size < minimum) {
160
+ validation = ` Select at least ${minimum} option${minimum === 1 ? "" : "s"}.`;
161
+ output.write("\x07");
162
+ } else {
163
+ return { done: true, value: options.filter(({ value }) => selected.has(value)).map(({ value }) => value) };
164
+ }
165
+ }
166
+ return null;
167
+ },
168
+ });
169
+ const labels = options.filter(({ value }) => values.includes(value)).map(({ label }) => label);
170
+ output.write(`> ${message}: ${labels.join(", ")}\n`);
171
+ return values;
172
+ }
@@ -46,7 +46,13 @@ for shell in bash zsh fish; do
46
46
  grep -q 'mcp-tools' "$tmp/$shell"
47
47
  grep -q 'full.*read-only.*custom\|full read-only custom' "$tmp/$shell"
48
48
  grep -q 'pack' "$tmp/$shell"
49
- grep -q 'init.*lock.*validate.*install.*list.*show.*remove.*enable.*disable.*update.*skills.*approve-skill.*revoke-skill\|init lock validate install list show remove enable disable update skills approve-skill revoke-skill' "$tmp/$shell"
49
+ grep -q 'init.*lock.*validate.*install.*list.*show.*remove.*enable.*disable.*update.*skills.*approve-skill.*revoke-skill.*doctor\|init lock validate install list show remove enable disable update skills approve-skill revoke-skill doctor' "$tmp/$shell"
50
+ grep -q -- '--repair\|-l repair' "$tmp/$shell"
51
+ grep -q 'proposals' "$tmp/$shell"
52
+ grep -q 'create.*list.*show.*apply.*doctor\|create list show apply doctor' "$tmp/$shell"
53
+ for proposal_flag in --session --scope --model --category --project --json; do
54
+ grep -q -- "$proposal_flag\|-l ${proposal_flag#--}" "$tmp/$shell"
55
+ done
50
56
  grep -q -- '--purge-packs\|-l purge-packs' "$tmp/$shell"
51
57
  grep -q 'evidence' "$tmp/$shell"
52
58
  grep -q 'list.*show.*delete.*prune.*doctor\|list show delete prune doctor' "$tmp/$shell"
@@ -77,6 +83,7 @@ fi
77
83
  grep -qF 'cairn skill <harvest|list|show|review|propose|evaluate|apply|rollback>' "$tmp/root-help"
78
84
  grep -qF 'cairn evidence <list|show|delete|prune|doctor>' "$tmp/root-help"
79
85
  grep -qF 'cairn playbook <list|status|init|set|enable|disable|reset|check|record|receipts|instructions|doctor>' "$tmp/root-help"
86
+ grep -qF 'cairn proposals <create|list|show|apply|doctor>' "$tmp/root-help"
80
87
  node "$ROOT/mcp-memory-server/dist/skill-cli.js" --help >"$tmp/skill-help"
81
88
  for command in $skill_commands; do
82
89
  grep -q "cairn skill $command" "$tmp/skill-help"