@cairnkeep/cli 2.3.1 → 2.4.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 (125) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +221 -6
  3. package/bin/cairn +43 -2
  4. package/claude/capability-contract/hooks/capability-command-finish.sh +81 -0
  5. package/claude/capability-contract/hooks/capability-command-start.sh +76 -0
  6. package/claude/hooks/compaction-capture.sh +34 -0
  7. package/claude/hooks/memory-capture.sh +47 -5
  8. package/claude/hooks/memory-wakeup.sh +55 -2
  9. package/docs/building-an-overlay.md +1 -1
  10. package/docs/design/meta-agent.md +439 -0
  11. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  12. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  13. package/docs/learning/lessons/L02-installation.md +2 -2
  14. package/docs/learning/lessons/L03-first-project.md +1 -1
  15. package/docs/operating.md +531 -4
  16. package/docs/privacy-and-data-flow.md +417 -0
  17. package/docs/storage.md +252 -0
  18. package/examples/eval/README.md +42 -0
  19. package/examples/eval/adapter.json +12 -0
  20. package/examples/eval/bundled-fake.json +1 -0
  21. package/examples/eval/task-set.json +1 -0
  22. package/mcp-memory-server/dist/artifact-cli.js +184 -0
  23. package/mcp-memory-server/dist/artifact-schema.js +218 -0
  24. package/mcp-memory-server/dist/artifact-store.js +812 -0
  25. package/mcp-memory-server/dist/capability-adapter.js +286 -0
  26. package/mcp-memory-server/dist/capability-cli.js +361 -0
  27. package/mcp-memory-server/dist/capability-config.js +310 -0
  28. package/mcp-memory-server/dist/capability-harness.js +496 -0
  29. package/mcp-memory-server/dist/capability-registry.js +123 -0
  30. package/mcp-memory-server/dist/capability-schema.js +65 -0
  31. package/mcp-memory-server/dist/capability-store.js +431 -0
  32. package/mcp-memory-server/dist/compaction-normalize.js +267 -0
  33. package/mcp-memory-server/dist/eval-cli.js +523 -0
  34. package/mcp-memory-server/dist/eval-plan.js +346 -0
  35. package/mcp-memory-server/dist/eval-process.js +273 -0
  36. package/mcp-memory-server/dist/eval-report.js +609 -0
  37. package/mcp-memory-server/dist/eval-runner.js +827 -0
  38. package/mcp-memory-server/dist/eval-schema.js +368 -0
  39. package/mcp-memory-server/dist/eval-statistics.js +161 -0
  40. package/mcp-memory-server/dist/eval-workspace.js +313 -0
  41. package/mcp-memory-server/dist/failure-signature.js +141 -0
  42. package/mcp-memory-server/dist/index.js +600 -55
  43. package/mcp-memory-server/dist/node-cli.js +84 -0
  44. package/mcp-memory-server/dist/node-schema.js +170 -0
  45. package/mcp-memory-server/dist/node-store.js +455 -0
  46. package/mcp-memory-server/dist/note-cli.js +235 -0
  47. package/mcp-memory-server/dist/note-distiller.js +177 -0
  48. package/mcp-memory-server/dist/note-enrichment.js +93 -0
  49. package/mcp-memory-server/dist/note-schema.js +4 -0
  50. package/mcp-memory-server/dist/note-store.js +985 -0
  51. package/mcp-memory-server/dist/trajectory-cli.js +141 -0
  52. package/mcp-memory-server/dist/trajectory-normalize.js +448 -0
  53. package/mcp-memory-server/dist/trajectory-redaction.js +155 -0
  54. package/mcp-memory-server/dist/trajectory-schema.js +73 -0
  55. package/mcp-memory-server/dist/trajectory-store.js +339 -0
  56. package/mcp-memory-server/package.json +24 -2
  57. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.219.json +9 -0
  58. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.220.json +9 -0
  59. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-event.json +6 -0
  60. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-messages.json +119 -0
  61. package/mcp-memory-server/scripts/fixtures/compaction-unknown-version.json +17 -0
  62. package/opencode/capability-contract/plugins/capability-command.ts +319 -0
  63. package/opencode/command/graphify.md +25 -14
  64. package/opencode/plugins/memory-capture.ts +76 -10
  65. package/opencode/plugins/memory-wakeup.ts +58 -6
  66. package/package.json +8 -4
  67. package/pi/extensions/cairnkeep-trajectory.ts +55 -0
  68. package/schemas/artifact.schema.json +133 -0
  69. package/schemas/capability-callback.schema.json +68 -0
  70. package/schemas/capability-contract.schema.json +126 -0
  71. package/schemas/eval-adapter.schema.json +43 -0
  72. package/schemas/eval-protocol.schema.json +114 -0
  73. package/schemas/eval-report.schema.json +423 -0
  74. package/schemas/eval-task-set.schema.json +114 -0
  75. package/schemas/memory-node.schema.json +90 -0
  76. package/schemas/note.schema.json +112 -0
  77. package/schemas/trajectory-redaction.schema.json +25 -0
  78. package/schemas/trajectory.schema.json +82 -0
  79. package/scripts/bootstrap.sh +4 -0
  80. package/scripts/completion.sh +119 -5
  81. package/scripts/doctor.sh +284 -0
  82. package/scripts/fake-eval-adapter.mjs +120 -0
  83. package/scripts/fixtures/capability-harness-contracts.json +408 -0
  84. package/scripts/install-audit-timer.sh +3 -1
  85. package/scripts/lib/capability-opencode-plugin-harness.mjs +291 -0
  86. package/scripts/lib/trajectory-opencode-plugin-harness.mjs +25 -0
  87. package/scripts/lib/trajectory-pi-extension-harness.mjs +42 -0
  88. package/scripts/memory-wiki-audit.sh +49 -0
  89. package/scripts/smoke-bash32.sh +9 -0
  90. package/scripts/sync-claude-assets.sh +99 -6
  91. package/scripts/sync-opencode-graphify-assets.sh +24 -3
  92. package/scripts/sync-opencode-plugin-assets.sh +31 -5
  93. package/scripts/sync-opencode-security-assets.sh +24 -3
  94. package/scripts/sync-opencode-wiki-assets.sh +24 -3
  95. package/scripts/sync-pi-assets.sh +72 -0
  96. package/scripts/test-audit-timer.sh +98 -0
  97. package/scripts/test-bootstrap-untracked.sh +48 -0
  98. package/scripts/test-cli-dispatch.sh +41 -2
  99. package/scripts/test-compaction-hooks.sh +495 -0
  100. package/scripts/test-completion.sh +77 -0
  101. package/scripts/test-doctor.sh +348 -1
  102. package/scripts/test-eval-cli.sh +175 -0
  103. package/scripts/test-launcher-hooks.sh +602 -3
  104. package/scripts/test-notes-cli.sh +114 -0
  105. package/scripts/test-package-install.sh +251 -0
  106. package/scripts/test-phase16-node-lifecycle.sh +50 -0
  107. package/scripts/test-phase17-artifact-lifecycle.sh +370 -0
  108. package/scripts/test-phase18-capability-lifecycle.sh +504 -0
  109. package/scripts/test-phase18-harness-boundary.sh +1060 -0
  110. package/scripts/test-phase19-eval-lifecycle.sh +1015 -0
  111. package/scripts/test-trajectory-cli.sh +87 -0
  112. package/scripts/test-trajectory-hooks.sh +142 -0
  113. package/scripts/test-uninstall.sh +231 -5
  114. package/scripts/uninstall.sh +37 -5
  115. package/scripts/verify-dependency-security.mjs +121 -0
  116. package/scripts/verify-docs-parity.sh +527 -1
  117. package/scripts/verify-phase18-runtime-evidence.mjs +895 -0
  118. package/scripts/verify-phase19-runtime-evidence.mjs +947 -0
  119. package/templates/capabilities.json.template +7 -0
  120. package/templates/env.example.template +51 -0
  121. package/templates/memory-wiki-audit.service.template +1 -1
  122. package/templates/start-claude.sh.template +36 -0
  123. package/templates/start-opencode.sh.template +47 -5
  124. package/templates/start-pi.sh.template +30 -0
  125. package/templates/trajectory-redaction.json.template +4 -0
@@ -0,0 +1,119 @@
1
+ {
2
+ "session": {
3
+ "id": "opencode-compaction-session-001",
4
+ "title": "Pinned compaction fixture",
5
+ "version": "1.17.20",
6
+ "projectID": "synthetic-project",
7
+ "directory": "/synthetic/project",
8
+ "time": {
9
+ "created": 1785052800000,
10
+ "updated": 1785052810000
11
+ }
12
+ },
13
+ "messages": [
14
+ {
15
+ "info": {
16
+ "id": "oc-ordinary-user",
17
+ "sessionID": "opencode-compaction-session-001",
18
+ "role": "user",
19
+ "time": { "created": 1785052801000 }
20
+ },
21
+ "parts": [
22
+ { "id": "oc-ordinary-text", "sessionID": "opencode-compaction-session-001", "messageID": "oc-ordinary-user", "type": "text", "text": "Continue normal work." }
23
+ ]
24
+ },
25
+ {
26
+ "info": {
27
+ "id": "oc-compact-user-old",
28
+ "sessionID": "opencode-compaction-session-001",
29
+ "role": "user",
30
+ "time": { "created": 1785052802000 }
31
+ },
32
+ "parts": [
33
+ { "id": "oc-compaction-part-old", "sessionID": "opencode-compaction-session-001", "messageID": "oc-compact-user-old", "type": "compaction", "auto": true }
34
+ ]
35
+ },
36
+ {
37
+ "info": {
38
+ "id": "oc-summary-incomplete",
39
+ "sessionID": "opencode-compaction-session-001",
40
+ "role": "assistant",
41
+ "parentID": "oc-compact-user-old",
42
+ "summary": true,
43
+ "time": { "created": 1785052803000 }
44
+ },
45
+ "parts": [
46
+ { "id": "oc-incomplete-text", "sessionID": "opencode-compaction-session-001", "messageID": "oc-summary-incomplete", "type": "text", "text": "# Objective\nThis incomplete decoy must not win." }
47
+ ]
48
+ },
49
+ {
50
+ "info": {
51
+ "id": "oc-summary-error",
52
+ "sessionID": "opencode-compaction-session-001",
53
+ "role": "assistant",
54
+ "parentID": "oc-compact-user-old",
55
+ "summary": true,
56
+ "finish": "stop",
57
+ "error": { "name": "SyntheticError", "message": "failed summary decoy" },
58
+ "time": { "created": 1785052803500, "completed": 1785052803600 }
59
+ },
60
+ "parts": [
61
+ { "id": "oc-error-text", "sessionID": "opencode-compaction-session-001", "messageID": "oc-summary-error", "type": "text", "text": "# Objective\nThis errored decoy must not win." }
62
+ ]
63
+ },
64
+ {
65
+ "info": {
66
+ "id": "oc-summary-valid-old",
67
+ "sessionID": "opencode-compaction-session-001",
68
+ "role": "assistant",
69
+ "parentID": "oc-compact-user-old",
70
+ "summary": true,
71
+ "finish": "stop",
72
+ "time": { "created": 1785052804000, "completed": 1785052805000 }
73
+ },
74
+ "parts": [
75
+ { "id": "oc-old-summary-text", "sessionID": "opencode-compaction-session-001", "messageID": "oc-summary-valid-old", "type": "text", "text": "# Objective\nPreserve the old goal.\n\n# Important Details\n- Decision: Keep this valid summary as a selection fallback.\n\n# Work State\n## Completed\n- Fixture parsing is pinned.\n## Active\n- Older work remains.\n## Blocked\n- Error: OldError at old.ts:1.\n\n# Next Move\n- Replace this summary with a newer valid revision.\n\n# Relevant Files\n- src/old.ts" }
76
+ ]
77
+ },
78
+ {
79
+ "info": {
80
+ "id": "oc-compact-user-new",
81
+ "sessionID": "opencode-compaction-session-001",
82
+ "role": "user",
83
+ "time": { "created": 1785052806000 }
84
+ },
85
+ "parts": [
86
+ { "id": "oc-compaction-part-new", "sessionID": "opencode-compaction-session-001", "messageID": "oc-compact-user-new", "type": "compaction", "auto": false }
87
+ ]
88
+ },
89
+ {
90
+ "info": {
91
+ "id": "oc-summary-wrong-parent",
92
+ "sessionID": "opencode-compaction-session-001",
93
+ "role": "assistant",
94
+ "parentID": "oc-ordinary-user",
95
+ "summary": true,
96
+ "finish": "stop",
97
+ "time": { "created": 1785052808000, "completed": 1785052809000 }
98
+ },
99
+ "parts": [
100
+ { "id": "oc-wrong-parent-text", "sessionID": "opencode-compaction-session-001", "messageID": "oc-summary-wrong-parent", "type": "text", "text": "# Objective\nThis newest wrong-parent decoy must not win." }
101
+ ]
102
+ },
103
+ {
104
+ "info": {
105
+ "id": "oc-summary-valid-new",
106
+ "sessionID": "opencode-compaction-session-001",
107
+ "role": "assistant",
108
+ "parentID": "oc-compact-user-new",
109
+ "summary": true,
110
+ "finish": "stop",
111
+ "time": { "created": 1785052807000, "completed": 1785052808000 }
112
+ },
113
+ "parts": [
114
+ { "id": "oc-new-summary-text", "sessionID": "opencode-compaction-session-001", "messageID": "oc-summary-valid-new", "type": "text", "text": "# Objective\nPreserve supported compaction state across sessions.\n\n# Important Details\n- Decision: Pin adapter behavior to the documented session.compacted family.\n- Nested credentials: {\"authorization\":\"Bearer opencode-compaction-secret-001\",\"nested\":{\"api_key\":\"sk-opencode-compaction-secret-002\"}}\n\n# Work State\n## Completed\n- The exact fixture family is recorded.\n## Active\n- Adapter implementation remains.\n## Blocked\n- Error: TypeError: recovery pointer invalid at selectRecovery (compaction.ts:42).\n\n# Next Move\n- Implement the local immutable compaction adapter.\n\n# Relevant Files\n- mcp-memory-server/src/compaction-normalize.ts" },
115
+ { "id": "oc-new-summary-ignored", "sessionID": "opencode-compaction-session-001", "messageID": "oc-summary-valid-new", "type": "text", "text": "IGNORED_SUMMARY_SECRET", "ignored": true }
116
+ ]
117
+ }
118
+ ]
119
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "harness": "opencode",
3
+ "harness_version": "99.0.0",
4
+ "event": {
5
+ "type": "session.compacted",
6
+ "properties": {
7
+ "sessionID": "unknown-version-session"
8
+ }
9
+ },
10
+ "payload": {
11
+ "authorization": "Bearer unknown-version-secret-001",
12
+ "nested": {
13
+ "api_key": "sk-unknown-version-secret-002",
14
+ "summary": "UNKNOWN_VERSION_PAYLOAD_MUST_NOT_SURVIVE"
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,319 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { spawn, spawnSync } from "node:child_process"
3
+ import fs from "node:fs"
4
+ import path from "node:path"
5
+
6
+ const COORDINATOR = "@@INFRA_ROOT@@/mcp-memory-server/dist/capability-cli.js"
7
+ const FIXED_BLOCK = "Cairn capability disabled."
8
+ const MAX_COORDINATOR_OUTPUT = 8 * 1024
9
+ const COORDINATOR_TIMEOUT_MS = 3000
10
+ const MAX_TRACKED_SESSIONS = 10_000
11
+ const DISPOSAL_BATCH_SIZE = 32
12
+ const SESSION_ID = /^(?:cairn:)?[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/
13
+ const COMMANDS = new Set([
14
+ "wiki-ingest",
15
+ "wiki-query",
16
+ "wiki-lint",
17
+ "graphify",
18
+ "security-audit",
19
+ ])
20
+
21
+ const OPENCODE_CAPABILITY_CONTRACT = {
22
+ version: "1.17.20",
23
+ sourceCommit: "4473fc3c9055046183990a965d68df3db7ea6f62",
24
+ admissionHook: "command.execute.before",
25
+ terminalEvents: {
26
+ success_terminals: ["session.idle", "session.status:idle"],
27
+ error_terminal: "session.error",
28
+ abandonment_only: "session.deleted",
29
+ event_callback_is_awaited: false,
30
+ },
31
+ } as const
32
+
33
+ type JsonObject = Record<string, unknown>
34
+ type SessionState = {
35
+ unfinished: boolean
36
+ terminalEpoch: number
37
+ operations: number
38
+ tail: Promise<void>
39
+ }
40
+
41
+ function isObject(value: unknown): value is JsonObject {
42
+ return value !== null && typeof value === "object" && !Array.isArray(value)
43
+ }
44
+
45
+ function hasExactKeys(value: JsonObject, required: readonly string[], optional: readonly string[] = []): boolean {
46
+ const keys = Object.keys(value)
47
+ const allowed = new Set([...required, ...optional])
48
+ return required.every((key) => Object.hasOwn(value, key))
49
+ && keys.every((key) => allowed.has(key))
50
+ }
51
+
52
+ function validSessionID(value: unknown): value is string {
53
+ return typeof value === "string" && value !== "unknown" && SESSION_ID.test(value)
54
+ }
55
+
56
+ function contractEnabled(): boolean {
57
+ return /^(1|true|yes|on)$/i.test(process.env.CAIRN_CAPABILITY_CONTRACT ?? "")
58
+ }
59
+
60
+ function validatedProjectRoot(values: unknown[]): string | undefined {
61
+ try {
62
+ if (!values.every((value) => typeof value === "string" && path.isAbsolute(value))) return undefined
63
+ const roots = values.map((value) => fs.realpathSync(value as string))
64
+ if (!roots.every((value) => value === roots[0])) return undefined
65
+ if (!fs.statSync(roots[0]).isDirectory()) return undefined
66
+ return roots[0]
67
+ } catch {
68
+ return undefined
69
+ }
70
+ }
71
+
72
+ function coordinator(operation: string, payload: JsonObject): Promise<JsonObject | undefined> {
73
+ return new Promise((resolvePromise) => {
74
+ let stdout = ""
75
+ let settled = false
76
+ let timer: ReturnType<typeof setTimeout> | undefined
77
+ const finish = (value?: JsonObject) => {
78
+ if (settled) return
79
+ settled = true
80
+ if (timer) clearTimeout(timer)
81
+ resolvePromise(value)
82
+ }
83
+ const child = spawn("node", [COORDINATOR, operation], {
84
+ stdio: ["pipe", "pipe", "ignore"],
85
+ })
86
+ timer = setTimeout(() => {
87
+ try {
88
+ child.kill("SIGKILL")
89
+ } catch {
90
+ // best-effort termination only
91
+ }
92
+ finish()
93
+ }, COORDINATOR_TIMEOUT_MS)
94
+ child.stdout.on("data", (chunk: Buffer) => {
95
+ stdout += chunk.toString("utf8")
96
+ if (Buffer.byteLength(stdout, "utf8") > MAX_COORDINATOR_OUTPUT) {
97
+ stdout = ""
98
+ try {
99
+ child.kill("SIGKILL")
100
+ } catch {
101
+ // best-effort termination only
102
+ }
103
+ }
104
+ })
105
+ child.on("error", () => finish())
106
+ child.on("close", (code) => {
107
+ if (code !== 0 || !stdout) {
108
+ finish()
109
+ return
110
+ }
111
+ try {
112
+ const value = JSON.parse(stdout) as unknown
113
+ finish(isObject(value) ? value : undefined)
114
+ } catch {
115
+ finish()
116
+ }
117
+ })
118
+ child.stdin.on("error", () => finish())
119
+ child.stdin.end(JSON.stringify(payload))
120
+ })
121
+ }
122
+
123
+ function coordinatorSync(operation: string, payload: JsonObject): JsonObject | undefined {
124
+ const child = spawnSync("node", [COORDINATOR, operation], {
125
+ input: JSON.stringify(payload),
126
+ encoding: "utf8",
127
+ timeout: COORDINATOR_TIMEOUT_MS,
128
+ maxBuffer: MAX_COORDINATOR_OUTPUT,
129
+ stdio: ["pipe", "pipe", "ignore"],
130
+ })
131
+ if (child.error || child.status !== 0 || !child.stdout) return undefined
132
+ try {
133
+ const value = JSON.parse(child.stdout) as unknown
134
+ return isObject(value) ? value : undefined
135
+ } catch {
136
+ return undefined
137
+ }
138
+ }
139
+
140
+ function terminalRequiresSync(): boolean {
141
+ return (process.versions as NodeJS.ProcessVersions & { bun?: string }).bun !== undefined
142
+ || process.env.CAIRN_TEST_OPENCODE_NON_AWAITED === "1"
143
+ }
144
+
145
+ function admission(input: unknown, output: unknown): {
146
+ command: string
147
+ sessionID: string
148
+ } | undefined {
149
+ if (!isObject(input) || !isObject(output)) return undefined
150
+ if (!hasExactKeys(input, ["command", "sessionID", "arguments"])) return undefined
151
+ if (!hasExactKeys(output, ["parts"]) || !Array.isArray(output.parts)) return undefined
152
+ if (typeof input.command !== "string" || !COMMANDS.has(input.command)) return undefined
153
+ if (!validSessionID(input.sessionID) || typeof input.arguments !== "string") return undefined
154
+ return { command: input.command, sessionID: input.sessionID }
155
+ }
156
+
157
+ function eventTerminal(event: unknown): { sessionID: string; outcome: "success" | "error" | "abandoned" } | undefined {
158
+ if (!isObject(event) || !hasExactKeys(event, ["type", "properties"], ["id"]) || !isObject(event.properties)) {
159
+ return undefined
160
+ }
161
+ if (Object.hasOwn(event, "id")
162
+ && (typeof event.id !== "string" || event.id.length === 0 || event.id.length > 256)) return undefined
163
+ const properties = event.properties
164
+ if (event.type === "session.idle") {
165
+ if (!hasExactKeys(properties, ["sessionID"]) || !validSessionID(properties.sessionID)) return undefined
166
+ return { sessionID: properties.sessionID, outcome: "success" }
167
+ }
168
+ if (event.type === "session.error") {
169
+ if (!hasExactKeys(properties, [], ["sessionID", "error"]) || !validSessionID(properties.sessionID)) return undefined
170
+ return { sessionID: properties.sessionID, outcome: "error" }
171
+ }
172
+ if (event.type === "session.status") {
173
+ if (!hasExactKeys(properties, ["sessionID", "status"]) || !validSessionID(properties.sessionID)) return undefined
174
+ if (!isObject(properties.status) || properties.status.type !== "idle" || !hasExactKeys(properties.status, ["type"])) {
175
+ return undefined
176
+ }
177
+ return { sessionID: properties.sessionID, outcome: "success" }
178
+ }
179
+ if (event.type === "session.deleted") {
180
+ if (!hasExactKeys(properties, ["info"]) || !isObject(properties.info)) return undefined
181
+ const info = properties.info
182
+ if (!hasExactKeys(
183
+ info,
184
+ ["id", "projectID", "directory", "title", "version", "time"],
185
+ ["parentID", "summary", "share", "revert"],
186
+ ) || !validSessionID(info.id) || !isObject(info.time)) return undefined
187
+ if (!hasExactKeys(info.time, ["created", "updated"], ["compacting"])) return undefined
188
+ if (typeof info.projectID !== "string" || typeof info.directory !== "string"
189
+ || typeof info.title !== "string" || typeof info.version !== "string"
190
+ || typeof info.time.created !== "number" || typeof info.time.updated !== "number") return undefined
191
+ return { sessionID: info.id, outcome: "abandoned" }
192
+ }
193
+ return undefined
194
+ }
195
+
196
+ const capabilityCommandPlugin: Plugin = async ({ directory, worktree, project }) => {
197
+ const projectRoot = validatedProjectRoot([directory, worktree, project.worktree])
198
+ const sessions = new Map<string, SessionState>()
199
+
200
+ const sessionState = (sessionID: string): SessionState | undefined => {
201
+ const existing = sessions.get(sessionID)
202
+ if (existing) return existing
203
+ if (sessions.size >= MAX_TRACKED_SESSIONS) return undefined
204
+ const created: SessionState = {
205
+ unfinished: false,
206
+ terminalEpoch: 0,
207
+ operations: 0,
208
+ tail: Promise.resolve(),
209
+ }
210
+ sessions.set(sessionID, created)
211
+ return created
212
+ }
213
+
214
+ const forgetFinishedSession = (sessionID: string, state: SessionState) => {
215
+ if (!state.unfinished && state.operations === 0 && sessions.get(sessionID) === state) {
216
+ sessions.delete(sessionID)
217
+ }
218
+ }
219
+
220
+ const serialize = async <T>(sessionID: string, state: SessionState, operation: () => Promise<T>): Promise<T> => {
221
+ const result = state.operations === 0
222
+ ? Promise.resolve(operation())
223
+ : state.tail.then(operation, operation)
224
+ state.operations += 1
225
+ state.tail = result.then(() => undefined, () => undefined)
226
+ try {
227
+ return await result
228
+ } finally {
229
+ state.operations -= 1
230
+ }
231
+ }
232
+
233
+ const callCoordinator = (operation: string, payload: JsonObject) => coordinator(operation, payload)
234
+ .catch(() => undefined)
235
+
236
+ return {
237
+ "command.execute.before": async (input, output) => {
238
+ if (!contractEnabled()) return
239
+ if (!isObject(input) || typeof input.command !== "string" || !COMMANDS.has(input.command)) return
240
+ const parsed = admission(input, output)
241
+ if (!parsed || !projectRoot || !fs.existsSync(COORDINATOR)) throw new Error(FIXED_BLOCK)
242
+ const state = sessionState(parsed.sessionID)
243
+ const terminalEpoch = state?.terminalEpoch
244
+ const invoke = () => callCoordinator("harness-before", {
245
+ schema_version: 1,
246
+ harness: "opencode",
247
+ command: parsed.command,
248
+ session_id: parsed.sessionID,
249
+ project_root: projectRoot,
250
+ })
251
+ let decision: JsonObject | undefined
252
+ try {
253
+ decision = state
254
+ ? await serialize(parsed.sessionID, state, invoke)
255
+ : await invoke()
256
+ } catch (error) {
257
+ if (state) forgetFinishedSession(parsed.sessionID, state)
258
+ throw error
259
+ }
260
+ if (decision?.schema_version !== 1 || (decision.decision !== "allow" && decision.decision !== "block")) {
261
+ if (state) forgetFinishedSession(parsed.sessionID, state)
262
+ throw new Error(FIXED_BLOCK)
263
+ }
264
+ if (decision.decision === "block") {
265
+ if (state) forgetFinishedSession(parsed.sessionID, state)
266
+ throw new Error(FIXED_BLOCK)
267
+ }
268
+ if (state && state.terminalEpoch === terminalEpoch) state.unfinished = true
269
+ else if (state) forgetFinishedSession(parsed.sessionID, state)
270
+ },
271
+ event: async ({ event }) => {
272
+ if (!contractEnabled() || !fs.existsSync(COORDINATOR)) return
273
+ const terminal = eventTerminal(event)
274
+ if (!terminal) return
275
+ const state = sessionState(terminal.sessionID)
276
+ if (state) {
277
+ state.unfinished = false
278
+ state.terminalEpoch += 1
279
+ }
280
+ const payload = {
281
+ schema_version: 1,
282
+ harness: "opencode",
283
+ session_id: terminal.sessionID,
284
+ outcome: terminal.outcome,
285
+ } as const
286
+ const invoke = () => terminalRequiresSync()
287
+ ? Promise.resolve(coordinatorSync("harness-terminal", payload))
288
+ : callCoordinator("harness-terminal", payload)
289
+ if (state) {
290
+ await serialize(terminal.sessionID, state, invoke)
291
+ forgetFinishedSession(terminal.sessionID, state)
292
+ } else await invoke()
293
+ },
294
+ dispose: async () => {
295
+ if (!contractEnabled() || !fs.existsSync(COORDINATOR)) return
296
+ const unfinished = [...sessions.entries()].filter(([, state]) => state.unfinished)
297
+ for (const [, state] of unfinished) {
298
+ state.unfinished = false
299
+ state.terminalEpoch += 1
300
+ }
301
+ for (let offset = 0; offset < unfinished.length; offset += DISPOSAL_BATCH_SIZE) {
302
+ const batch = unfinished.slice(offset, offset + DISPOSAL_BATCH_SIZE)
303
+ for (const [sessionID, state] of batch) {
304
+ await serialize(sessionID, state, () => callCoordinator("harness-terminal", {
305
+ schema_version: 1,
306
+ harness: "opencode",
307
+ session_id: sessionID,
308
+ outcome: "abandoned",
309
+ }))
310
+ }
311
+ }
312
+ for (const [sessionID, state] of unfinished) forgetFinishedSession(sessionID, state)
313
+ },
314
+ }
315
+ }
316
+
317
+ export const CapabilityCommandPlugin = Object.assign(capabilityCommandPlugin, {
318
+ contract: OPENCODE_CAPABILITY_CONTRACT,
319
+ })
@@ -7,22 +7,21 @@ tools:
7
7
  task: true
8
8
  ---
9
9
 
10
- **STOP -- DO NOT READ THIS FILE. You are already reading it. This prompt was injected into your context by Claude Code's command system. Using the Read tool on this file wastes tokens. Begin executing Step 0 immediately.**
10
+ **STOP -- DO NOT READ THIS FILE. You are already reading it. This prompt was injected into your context by OpenCode's command system. Using the Read tool on this file wastes tokens. Begin executing Step 1 immediately.**
11
11
 
12
12
  **graphify CLI:** the `graphify` subcommand is exposed by `gsd-tools` (on PATH). Invoke as `gsd-tools graphify …` as documented in this command and in `docs/CLI-TOOLS.md`. Other GSD queries use `gsd-tools <subcmd>` where a handler exists.
13
13
 
14
- ## Step 0 -- Banner
14
+ ## Output heading
15
15
 
16
- **Before ANY tool calls**, display this banner:
17
-
18
- ```
19
- GRAPHIFY
20
- ```
21
-
22
- Then proceed to Step 1.
16
+ Begin the final user-facing response with `GRAPHIFY`. Do not emit a standalone
17
+ banner before owner I/O: in headless OpenCode, content-only output can end the
18
+ turn before the command reaches its tools.
23
19
 
24
20
  ## Step 1 -- Config Gate
25
21
 
22
+ **First action:** invoke the Read tool for `.planning/config.json`; do not emit
23
+ text before this tool call.
24
+
26
25
  Check if graphify is enabled by reading `.planning/config.json` directly using the Read tool.
27
26
 
28
27
  **DO NOT use the gsd-tools config get-value command** -- it hard-exits on missing keys.
@@ -49,7 +48,15 @@ Then run /graphify build to create the initial graph.
49
48
 
50
49
  ## Step 2 -- Parse Argument
51
50
 
52
- Parse `$ARGUMENTS` to determine the operation mode:
51
+ The exact invocation argument is delimited here:
52
+
53
+ ```text
54
+ <argument>$ARGUMENTS</argument>
55
+ ```
56
+
57
+ Parse only the text between those markers to determine the operation mode. If
58
+ the markers contain `status`, that is the status mode; do not treat it as an
59
+ empty or unknown argument. Apply the same exact matching rule to every row:
53
60
 
54
61
  | Argument | Action |
55
62
  |----------|--------|
@@ -75,7 +82,8 @@ Modes:
75
82
 
76
83
  ### Step 2a -- Query
77
84
 
78
- Run:
85
+ **Invoke the shell tool** with this exact command; do not print the command as
86
+ the result and do not continue until the tool returns:
79
87
 
80
88
  ```bash
81
89
  gsd-tools graphify query <term>
@@ -91,7 +99,8 @@ Parse the JSON output and display results:
91
99
 
92
100
  ### Step 2b -- Status
93
101
 
94
- Run:
102
+ **Invoke the shell tool** with this exact command; do not print the command as
103
+ the result and do not continue until the tool returns:
95
104
 
96
105
  ```bash
97
106
  gsd-tools graphify status
@@ -105,7 +114,8 @@ Parse the JSON output and display:
105
114
 
106
115
  ### Step 2c -- Diff
107
116
 
108
- Run:
117
+ **Invoke the shell tool** with this exact command; do not print the command as
118
+ the result and do not continue until the tool returns:
109
119
 
110
120
  ```bash
111
121
  gsd-tools graphify diff
@@ -123,7 +133,8 @@ If no snapshot exists, suggest running `build` twice (first to create, second to
123
133
 
124
134
  ## Step 3 -- Build (Inline)
125
135
 
126
- Run:
136
+ **Invoke the shell tool** with this exact command; do not print the command as
137
+ the result and do not continue until the tool returns:
127
138
 
128
139
  ```bash
129
140
  gsd-tools graphify build [--force]