@aipper/aiws-spec 0.0.28 → 0.0.30

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 (138) hide show
  1. package/docs/cli-interface.md +10 -12
  2. package/docs/opencode-autonomous-swarm.md +178 -0
  3. package/docs/opencode-omo-adapter.md +123 -4
  4. package/docs/opencode-omo-validation-checklist.md +47 -0
  5. package/docs/opencode-subagent-first.md +187 -0
  6. package/docs/workflow-delegation-context-injection.md +217 -0
  7. package/docs/workflow-delegation-contracts.json +108 -2
  8. package/docs/workflow-delegation-contracts.md +13 -0
  9. package/docs/workflow-delegation-contracts.schema.json +152 -0
  10. package/docs/workflow-governance-rules.json +47 -6
  11. package/docs/workflow-governance-rules.md +8 -7
  12. package/docs/workflow-governance-rules.schema.json +39 -1
  13. package/docs/workflow-review-gates.json +6 -0
  14. package/docs/workflow-review-gates.md +14 -5
  15. package/docs/workflow-review-gates.schema.json +4 -0
  16. package/docs/workflow-router-rules.json +72 -8
  17. package/docs/workflow-router-rules.md +21 -14
  18. package/docs/workflow-router-rules.schema.json +4 -0
  19. package/docs/workflow-stage-contracts.json +24 -5
  20. package/docs/workflow-stage-contracts.md +16 -15
  21. package/docs/workflow-stage-contracts.schema.json +12 -1
  22. package/package.json +1 -1
  23. package/templates/workspace/.agents/skills/using-aiws/SKILL.md +16 -10
  24. package/templates/workspace/.agents/skills/ws-commit/SKILL.md +6 -118
  25. package/templates/workspace/.agents/skills/ws-deliver/SKILL.md +6 -218
  26. package/templates/workspace/.agents/skills/ws-dev/SKILL.md +82 -141
  27. package/templates/workspace/.agents/skills/ws-finish/SKILL.md +9 -204
  28. package/templates/workspace/.agents/skills/ws-handoff/SKILL.md +10 -44
  29. package/templates/workspace/.agents/skills/ws-migrate/SKILL.md +6 -42
  30. package/templates/workspace/.agents/skills/ws-plan-verify/SKILL.md +6 -58
  31. package/templates/workspace/.agents/skills/ws-pull/SKILL.md +8 -109
  32. package/templates/workspace/.agents/skills/ws-push/SKILL.md +8 -100
  33. package/templates/workspace/.agents/skills/ws-review/SKILL.md +6 -1
  34. package/templates/workspace/.agents/skills/ws-submodule-setup/SKILL.md +10 -57
  35. package/templates/workspace/.agents/skills/ws-verify-before-complete/SKILL.md +12 -53
  36. package/templates/workspace/{changes → .aiws/changes}/README.md +20 -23
  37. package/templates/workspace/{changes → .aiws/changes}/templates/proposal.md +12 -12
  38. package/templates/workspace/{changes → .aiws/changes}/templates/tasks.md +8 -8
  39. package/templates/workspace/{issues → .aiws/issues}/problem-issues.csv +1 -1
  40. package/templates/workspace/.aiws/journal/.gitkeep +1 -0
  41. package/templates/workspace/{requirements → .aiws/requirements}/requirements-issues.csv +1 -1
  42. package/templates/workspace/.aiws/tmp/.gitkeep +0 -0
  43. package/templates/workspace/{tools → .aiws/tools}/requirements_contract.py +2 -2
  44. package/templates/workspace/{tools → .aiws/tools}/requirements_contract_sync.py +2 -2
  45. package/templates/workspace/{tools → .aiws/tools}/requirements_flow_gen.py +1 -1
  46. package/templates/workspace/{tools → .aiws/tools}/server_test_runner.py +10 -10
  47. package/templates/workspace/{tools → .aiws/tools}/ws_change_check.py +23 -23
  48. package/templates/workspace/{tools → .aiws/tools}/ws_resolve_sub_target.sh +2 -2
  49. package/templates/workspace/{tools → .aiws/tools}/ws_tasks_plan.py +3 -3
  50. package/templates/workspace/.claude/commands/ws-review.md +5 -1
  51. package/templates/workspace/.claude/settings.json.example +26 -0
  52. package/templates/workspace/.claude/skills/ws-commit/SKILL.md +6 -118
  53. package/templates/workspace/.claude/skills/ws-deliver/SKILL.md +6 -218
  54. package/templates/workspace/.claude/skills/ws-dev/SKILL.md +52 -141
  55. package/templates/workspace/.claude/skills/ws-finish/SKILL.md +9 -204
  56. package/templates/workspace/.claude/skills/ws-handoff/SKILL.md +10 -44
  57. package/templates/workspace/.claude/skills/ws-migrate/SKILL.md +6 -42
  58. package/templates/workspace/.claude/skills/ws-plan-verify/SKILL.md +6 -49
  59. package/templates/workspace/.claude/skills/ws-pull/SKILL.md +8 -109
  60. package/templates/workspace/.claude/skills/ws-push/SKILL.md +8 -100
  61. package/templates/workspace/.claude/skills/ws-review/SKILL.md +6 -1
  62. package/templates/workspace/.claude/skills/ws-submodule-setup/SKILL.md +10 -57
  63. package/templates/workspace/.claude/skills/ws-verify-before-complete/SKILL.md +12 -53
  64. package/templates/workspace/.githooks/pre-commit +13 -0
  65. package/templates/workspace/.githooks/pre-push +7 -0
  66. package/templates/workspace/.opencode/command/ws-auto.md +33 -0
  67. package/templates/workspace/.opencode/command/ws-autonomy.md +25 -0
  68. package/templates/workspace/.opencode/command/ws-review.md +5 -1
  69. package/templates/workspace/.opencode/commands/ws-auto.md +33 -0
  70. package/templates/workspace/.opencode/commands/ws-autonomy.md +25 -0
  71. package/templates/workspace/.opencode/commands/ws-commit.md +4 -56
  72. package/templates/workspace/.opencode/commands/ws-deliver.md +10 -50
  73. package/templates/workspace/.opencode/commands/ws-finish.md +11 -3
  74. package/templates/workspace/.opencode/commands/ws-handoff.md +9 -17
  75. package/templates/workspace/.opencode/commands/ws-migrate.md +10 -17
  76. package/templates/workspace/.opencode/commands/ws-plan-verify.md +5 -15
  77. package/templates/workspace/.opencode/commands/ws-pull.md +6 -75
  78. package/templates/workspace/.opencode/commands/ws-push.md +7 -82
  79. package/templates/workspace/.opencode/commands/ws-review.md +5 -1
  80. package/templates/workspace/.opencode/commands/ws-submodule-setup.md +8 -47
  81. package/templates/workspace/.opencode/commands/ws-verify-before-complete.md +10 -19
  82. package/templates/workspace/.opencode/helpers/approval-whitelist-check.sh +148 -0
  83. package/templates/workspace/.opencode/helpers/approval-whitelist-run.sh +82 -0
  84. package/templates/workspace/.opencode/helpers/approval-whitelist-watchdog.sh +144 -0
  85. package/templates/workspace/.opencode/helpers/tmux-swarm-rescue.sh +56 -0
  86. package/templates/workspace/.opencode/helpers/tmux-swarm-scan.sh +46 -0
  87. package/templates/workspace/.opencode/lib/aiws-context.js +953 -0
  88. package/templates/workspace/.opencode/oh-my-opencode.json.example +64 -4
  89. package/templates/workspace/.opencode/plugins/aiws-inject-context.js +201 -0
  90. package/templates/workspace/.opencode/plugins/aiws-session-start.js +86 -0
  91. package/templates/workspace/.opencode/plugins/aiws-workflow-state.js +116 -0
  92. package/templates/workspace/.opencode/scripts/ws-plan-gen-submodule-targets.sh +27 -0
  93. package/templates/workspace/.opencode/scripts/ws-plan-setup-worktree.sh +31 -0
  94. package/templates/workspace/.opencode/skills/using-aiws/SKILL.md +130 -77
  95. package/templates/workspace/.opencode/skills/ws-analyze/SKILL.md +1 -1
  96. package/templates/workspace/.opencode/skills/ws-auto/SKILL.md +46 -0
  97. package/templates/workspace/.opencode/skills/ws-autonomy/SKILL.md +62 -0
  98. package/templates/workspace/.opencode/skills/ws-bugfix/SKILL.md +1 -1
  99. package/templates/workspace/.opencode/skills/ws-commit/SKILL.md +6 -118
  100. package/templates/workspace/.opencode/skills/ws-delegate/SKILL.md +115 -40
  101. package/templates/workspace/.opencode/skills/ws-deliver/SKILL.md +6 -218
  102. package/templates/workspace/.opencode/skills/ws-dev/SKILL.md +105 -146
  103. package/templates/workspace/.opencode/skills/ws-dev-lite/SKILL.md +23 -7
  104. package/templates/workspace/.opencode/skills/ws-finish/SKILL.md +9 -204
  105. package/templates/workspace/.opencode/skills/ws-frontend-design/SKILL.md +1 -1
  106. package/templates/workspace/.opencode/skills/ws-handoff/SKILL.md +10 -44
  107. package/templates/workspace/.opencode/skills/ws-intake/SKILL.md +43 -4
  108. package/templates/workspace/.opencode/skills/ws-migrate/SKILL.md +6 -42
  109. package/templates/workspace/.opencode/skills/ws-plan/SKILL.md +15 -55
  110. package/templates/workspace/.opencode/skills/ws-plan-verify/SKILL.md +6 -49
  111. package/templates/workspace/.opencode/skills/ws-preflight/SKILL.md +1 -1
  112. package/templates/workspace/.opencode/skills/ws-pull/SKILL.md +8 -109
  113. package/templates/workspace/.opencode/skills/ws-push/SKILL.md +8 -100
  114. package/templates/workspace/.opencode/skills/ws-quality-review/SKILL.md +7 -2
  115. package/templates/workspace/.opencode/skills/ws-req-change/SKILL.md +1 -1
  116. package/templates/workspace/.opencode/skills/ws-req-contract-sync/SKILL.md +1 -1
  117. package/templates/workspace/.opencode/skills/ws-req-contract-validate/SKILL.md +1 -1
  118. package/templates/workspace/.opencode/skills/ws-req-flow-sync/SKILL.md +1 -1
  119. package/templates/workspace/.opencode/skills/ws-req-review/SKILL.md +1 -1
  120. package/templates/workspace/.opencode/skills/ws-review/SKILL.md +35 -4
  121. package/templates/workspace/.opencode/skills/ws-rule/SKILL.md +1 -1
  122. package/templates/workspace/.opencode/skills/ws-spec-review/SKILL.md +1 -1
  123. package/templates/workspace/.opencode/skills/ws-submodule-setup/SKILL.md +10 -57
  124. package/templates/workspace/.opencode/skills/ws-verify-before-complete/SKILL.md +45 -55
  125. package/templates/workspace/AGENTS.md +32 -84
  126. package/templates/workspace/AI_PROJECT.md +5 -5
  127. package/templates/workspace/AI_WORKSPACE.md +1 -1
  128. package/templates/workspace/REQUIREMENTS.md +1 -1
  129. package/templates/workspace/gitignore +1 -1
  130. package/templates/workspace/manifest.json +215 -395
  131. /package/templates/workspace/{changes → .aiws/changes}/templates/design.md +0 -0
  132. /package/templates/workspace/{memory-bank → .aiws/memory-bank}/README.md +0 -0
  133. /package/templates/workspace/{memory-bank → .aiws/memory-bank}/architecture.md +0 -0
  134. /package/templates/workspace/{memory-bank → .aiws/memory-bank}/implementation-plan.md +0 -0
  135. /package/templates/workspace/{memory-bank → .aiws/memory-bank}/progress.md +0 -0
  136. /package/templates/workspace/{memory-bank → .aiws/memory-bank}/tech-stack.md +0 -0
  137. /package/templates/workspace/{requirements → .aiws/requirements}/CHANGELOG.md +0 -0
  138. /package/templates/workspace/{secrets → .aiws/secrets}/test-accounts.example.json +0 -0
@@ -0,0 +1,953 @@
1
+ /**
2
+ * AiwsContext — Shared utility library for aiws OpenCode plugins
3
+ *
4
+ * Provides file reading, JSONL parsing (5-field format), session dedup,
5
+ * change detection, and spec index building.
6
+ *
7
+ * Zero external dependencies. All JS, no Python.
8
+ * Reference: trellis/packages/cli/src/templates/opencode/lib/trellis-context.js
9
+ */
10
+
11
+ import { existsSync, readFileSync, appendFileSync, readdirSync, statSync, mkdirSync } from "fs"
12
+ import { join, isAbsolute, relative } from "path"
13
+
14
+ const DEBUG_LOG = "/tmp/aiws-plugin-debug.log"
15
+
16
+ export function debugLog(prefix, ...args) {
17
+ const ts = new Date().toISOString()
18
+ const msg =
19
+ `[${ts}] [${prefix}] ${args.map(a => (typeof a === "object" ? JSON.stringify(a) : a)).join(" ")}\n`
20
+ try {
21
+ appendFileSync(DEBUG_LOG, msg)
22
+ } catch {
23
+ // ignore
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Check if a string value is non-empty
29
+ */
30
+ function str(v) {
31
+ return typeof v === "string" && v.trim() ? v.trim() : null
32
+ }
33
+
34
+ /**
35
+ * Look up a nested key from an object, trying multiple key names
36
+ */
37
+ function lookupString(data, keys) {
38
+ if (!data || typeof data !== "object") return null
39
+ for (const key of keys) {
40
+ const v = str(data[key])
41
+ if (v) return v
42
+ }
43
+ // recurse into common wrapper objects
44
+ for (const nested of ["input", "properties", "event", "hook_input", "hookInput"]) {
45
+ const v = lookupString(data[nested], keys)
46
+ if (v) return v
47
+ }
48
+ return null
49
+ }
50
+
51
+ /**
52
+ * Resolve a relative path against the project directory.
53
+ * If already absolute, return as-is.
54
+ */
55
+ function resolvePath(directory, relPath) {
56
+ if (isAbsolute(relPath)) return relPath
57
+ return join(directory, relPath)
58
+ }
59
+
60
+ /**
61
+ * Read optional numeric sections from a JSONL entry.
62
+ * Returns null if no sections specified.
63
+ */
64
+ function parseSections(entry) {
65
+ const raw = entry.sections
66
+ if (!Array.isArray(raw) || raw.length === 0) return null
67
+ return raw.map(s => {
68
+ if (!Array.isArray(s) || s.length < 2) return null
69
+ return [Math.max(1, s[0]), Math.max(1, s[1])]
70
+ }).filter(Boolean)
71
+ }
72
+
73
+ // ============================================================
74
+ // AiwsContext
75
+ // ============================================================
76
+
77
+ export class AiwsContext {
78
+ constructor(directory) {
79
+ this.directory = directory
80
+ debugLog("context", "AiwsContext initialized", { directory })
81
+ }
82
+
83
+ // -- Project detection ---------------------------------------
84
+
85
+ /** True when this is an aiws-managed project */
86
+ isAiwsProject() {
87
+ return (
88
+ existsSync(join(this.directory, "packages", "spec")) ||
89
+ existsSync(join(this.directory, "AI_PROJECT.md"))
90
+ )
91
+ }
92
+
93
+ // -- Session identity -----------------------------------------
94
+
95
+ /**
96
+ * Derive a stable session key from the environment or hook input.
97
+ *
98
+ * Priority order (matching trellis getContextKey):
99
+ * 1. OPENCODE_RUN_ID (set by OpenCode runtime)
100
+ * 2. input.sessionID (from chat.message or tool.execute.before hook)
101
+ * 3. input.conversationID
102
+ *
103
+ * Returns null when no identity source is available.
104
+ */
105
+ getSessionKey(platformInput = null) {
106
+ const runId = str(process.env.OPENCODE_RUN_ID)
107
+ if (runId) return `opencode_run_${runId}`
108
+
109
+ if (!platformInput || typeof platformInput !== "object") return null
110
+
111
+ const sid = lookupString(platformInput, ["session_id", "sessionId", "sessionID"])
112
+ if (sid) return `session_${sid}`
113
+
114
+ const cid = lookupString(platformInput, ["conversation_id", "conversationId", "conversationID"])
115
+ if (cid) return `conv_${cid}`
116
+
117
+ return null
118
+ }
119
+
120
+ // -- File reading ---------------------------------------------
121
+
122
+ /** Read a file, returning null on any error */
123
+ readFile(filePath) {
124
+ try {
125
+ if (existsSync(filePath)) return readFileSync(filePath, "utf-8")
126
+ } catch {
127
+ // ignore
128
+ }
129
+ return null
130
+ }
131
+
132
+ /** Read a file relative to the project root */
133
+ readProjectFile(relPath) {
134
+ return this.readFile(resolvePath(this.directory, relPath))
135
+ }
136
+
137
+ // -- JSONL parsing (5-field format) ---------------------------
138
+
139
+ /**
140
+ * Read a JSONL file and load referenced file contents.
141
+ *
142
+ * Supports aiws 5-field format:
143
+ * {"glob": "path/to/file.md", "sections": [[25,50]], "priority": "high", "kind": "truth", "reason": "why"}
144
+ *
145
+ * Returns array of { path, content, priority, kind, reason }
146
+ * Sorted by priority: high → medium → low
147
+ */
148
+ readJsonlWithFiles(jsonlPath) {
149
+ const content = this.readFile(jsonlPath)
150
+ if (!content) return []
151
+
152
+ const entries = []
153
+
154
+ for (const rawLine of content.split(/\r?\n/)) {
155
+ const line = rawLine.trim()
156
+ if (!line || line.startsWith("#")) continue
157
+ try {
158
+ const item = JSON.parse(line)
159
+
160
+ // Resolve glob to actual file
161
+ const globPattern = item.glob || item.file
162
+ if (!globPattern) continue
163
+
164
+ const priority = item.priority || "medium"
165
+ const kind = item.kind || "unknown"
166
+ const reason = item.reason || ""
167
+ const sections = parseSections(item)
168
+
169
+ // Read the file
170
+ const fullPath = resolvePath(this.directory, globPattern)
171
+ let fileContent = this.readFile(fullPath)
172
+ if (!fileContent) continue
173
+
174
+ // Apply section filtering if specified
175
+ if (sections) {
176
+ const lines = fileContent.split("\n")
177
+ const filtered = []
178
+ for (const [start, end] of sections) {
179
+ const from = Math.max(0, start - 1)
180
+ const to = Math.min(lines.length, end)
181
+ filtered.push(...lines.slice(from, to))
182
+ }
183
+ fileContent = filtered.join("\n")
184
+ }
185
+
186
+ entries.push({ path: globPattern, content: fileContent, priority, kind, reason })
187
+ } catch {
188
+ // Skip malformed lines
189
+ }
190
+ }
191
+
192
+ // Sort: high → medium → low
193
+ const order = { high: 0, medium: 1, low: 2 }
194
+ entries.sort((a, b) => (order[a.priority] ?? 2) - (order[b.priority] ?? 2))
195
+
196
+ return entries
197
+ }
198
+
199
+ // -- Context building ------------------------------------------
200
+
201
+ /**
202
+ * Build injected context text from JSONL entries.
203
+ * Returns a formatted string with file separators.
204
+ */
205
+ buildContextFromEntries(entries) {
206
+ if (!entries || entries.length === 0) return ""
207
+ return entries
208
+ .map(e => {
209
+ const header = `=== ${e.path} ===${e.reason ? ` (${e.reason})` : ""}`
210
+ return `${header}\n${e.content}`
211
+ })
212
+ .join("\n\n")
213
+ }
214
+
215
+ /** Build context and return a wrapped message block */
216
+ buildContextBlock(entries) {
217
+ const body = this.buildContextFromEntries(entries)
218
+ if (!body) return ""
219
+ return `<aiws-context>\n${body}\n</aiws-context>`
220
+ }
221
+
222
+ // -- Change detection ------------------------------------------
223
+
224
+ /**
225
+ * Detect currently active change by scanning changes/<id>/.
226
+ * Returns the most recently modified change's id and detail, or null.
227
+ */
228
+ getActiveChange() {
229
+ const changesDir = join(this.directory, "changes")
230
+ if (!existsSync(changesDir)) return null
231
+
232
+ let dirs
233
+ try {
234
+ dirs = readdirSync(changesDir)
235
+ .filter(name => name !== "archive" && name !== "templates")
236
+ .map(name => {
237
+ const full = join(changesDir, name)
238
+ try {
239
+ return { name, mtime: statSync(full).mtimeMs, dir: full }
240
+ } catch {
241
+ return null
242
+ }
243
+ })
244
+ .filter(Boolean)
245
+ } catch {
246
+ return null
247
+ }
248
+
249
+ if (dirs.length === 0) return null
250
+
251
+ // Most recently modified directory wins
252
+ dirs.sort((a, b) => b.mtime - a.mtime)
253
+ return this.getChangeDetail(dirs[0].name)
254
+ }
255
+
256
+ /**
257
+ * Get detailed info about a specific change directory.
258
+ * Scans plan/, analysis/, evidence/, patches/ subdirs.
259
+ */
260
+ getChangeDetail(changeId) {
261
+ const changeDir = join(this.directory, "changes", changeId)
262
+ if (!existsSync(changeDir)) return null
263
+
264
+ const detail = {
265
+ id: changeId,
266
+ dir: changeDir,
267
+ proposal: null,
268
+ planFiles: [],
269
+ analysisFiles: [],
270
+ contextRoles: [],
271
+ evidenceFiles: [],
272
+ patchesCount: 0,
273
+ hasHandoff: false,
274
+ phase: "unknown",
275
+ }
276
+
277
+ const proposalPath = join(changeDir, "proposal.md")
278
+ if (existsSync(proposalPath)) {
279
+ detail.proposal = this.readFile(proposalPath)
280
+ }
281
+
282
+ const planDir = join(changeDir, "plan")
283
+ if (existsSync(planDir)) {
284
+ try {
285
+ detail.planFiles = readdirSync(planDir)
286
+ .filter(f => f.endsWith(".md"))
287
+ .sort()
288
+ .reverse()
289
+ } catch { /* ignore */ }
290
+ }
291
+
292
+ const analysisDir = join(changeDir, "analysis")
293
+ if (existsSync(analysisDir)) {
294
+ try {
295
+ const files = readdirSync(analysisDir)
296
+ detail.analysisFiles = files.filter(f => f.endsWith(".md"))
297
+ detail.contextRoles = files
298
+ .filter(f => f.endsWith("-context.jsonl"))
299
+ .map(f => f.replace("-context.jsonl", ""))
300
+ } catch { /* ignore */ }
301
+ }
302
+
303
+ // Evidence dir
304
+ const evidenceDir = join(changeDir, "evidence")
305
+ if (existsSync(evidenceDir)) {
306
+ try {
307
+ detail.evidenceFiles = readdirSync(evidenceDir)
308
+ .filter(f => f.endsWith(".md"))
309
+ } catch { /* ignore */ }
310
+ }
311
+
312
+ // Patches dir
313
+ const patchesDir = join(changeDir, "patches")
314
+ if (existsSync(patchesDir)) {
315
+ try {
316
+ detail.patchesCount = readdirSync(patchesDir).length
317
+ } catch { /* ignore */ }
318
+ }
319
+
320
+ // Handoff
321
+ detail.hasHandoff = existsSync(join(changeDir, "handoff.md"))
322
+
323
+ // Phase estimation
324
+ detail.phase = this._estimatePhase(detail)
325
+
326
+ return detail
327
+ }
328
+
329
+ // State machine: intake → planning → ready-for-dev → in-progress → review → finished
330
+ static PHASES = [
331
+ "intake",
332
+ "planning",
333
+ "ready-for-dev",
334
+ "in-progress",
335
+ "review",
336
+ "finished",
337
+ ]
338
+
339
+ static PHASE_TRANSITIONS = {
340
+ intake: new Set(["planning"]),
341
+ planning: new Set(["ready-for-dev", "intake"]),
342
+ "ready-for-dev": new Set(["in-progress", "planning"]),
343
+ "in-progress": new Set(["review", "ready-for-dev"]),
344
+ review: new Set(["finished", "in-progress"]),
345
+ finished: new Set(["intake"]),
346
+ }
347
+
348
+ static isValidTransition(from, to) {
349
+ const allowed = AiwsContext.PHASE_TRANSITIONS[from]
350
+ return allowed ? allowed.has(to) : false
351
+ }
352
+
353
+ _estimatePhase(detail) {
354
+ if (!detail.proposal) return "intake"
355
+ if (detail.planFiles.length === 0) return "planning"
356
+ if (detail.hasHandoff) return "finished"
357
+ if (detail.patchesCount > 0) return "in-progress"
358
+ if (detail.evidenceFiles.length > 0) return "review"
359
+ return "ready-for-dev"
360
+ }
361
+
362
+ getPhaseSuggestions(changeId) {
363
+ const detail = this.getChangeDetail(changeId)
364
+ if (!detail) return []
365
+ const current = detail.phase
366
+ return [...(AiwsContext.PHASE_TRANSITIONS[current] || new Set())]
367
+ }
368
+
369
+ getPhaseSummary(changeId) {
370
+ const detail = this.getChangeDetail(changeId)
371
+ if (!detail) return ""
372
+ const current = detail.phase
373
+ const suggestions = this.getPhaseSuggestions(changeId)
374
+ const idx = AiwsContext.PHASES.indexOf(current)
375
+ const total = AiwsContext.PHASES.length
376
+ return (
377
+ `Phase: ${current} (${idx + 1}/${total})\n` +
378
+ `Valid next: ${suggestions.join(", ") || "(none)"}\n` +
379
+ `Proposal: ${detail.proposal ? "yes" : "no"}\n` +
380
+ `Plans: ${detail.planFiles.length}\n` +
381
+ `Analysis files: ${detail.analysisFiles.length}\n` +
382
+ `Context JSONLs: ${detail.contextRoles.join(", ") || "none"}\n` +
383
+ `Evidence: ${detail.evidenceFiles.length}\n` +
384
+ `Patches: ${detail.patchesCount}\n` +
385
+ `Handoff: ${detail.hasHandoff ? "yes" : "no"}`
386
+ )
387
+ }
388
+
389
+ // -- Spec index ------------------------------------------------
390
+
391
+ /**
392
+ * Build an index of packages/spec/docs/*.md files.
393
+ * Returns a formatted markdown string listing all spec docs with descriptions.
394
+ */
395
+ getSpecIndex() {
396
+ const specDir = join(this.directory, "packages", "spec", "docs")
397
+ if (!existsSync(specDir)) return ""
398
+
399
+ let files
400
+ try {
401
+ files = readdirSync(specDir)
402
+ .filter(f => f.endsWith(".md"))
403
+ .sort()
404
+ } catch {
405
+ return ""
406
+ }
407
+
408
+ if (files.length === 0) return ""
409
+
410
+ const parts = ["## packages/spec/docs/ — Spec Index"]
411
+ for (const f of files) {
412
+ // Extract first heading as description
413
+ const content = this.readFile(join(specDir, f))
414
+ let desc = ""
415
+ if (content) {
416
+ const firstH1 = content.match(/^#\s+(.+)/m)
417
+ const firstH2 = content.match(/^##\s+(.+)/m)
418
+ desc = firstH1?.[1] || firstH2?.[1] || f.replace(/\.md$/, "")
419
+ }
420
+ parts.push(`- \`spec/docs/${f}\` — ${desc}`)
421
+ }
422
+
423
+ return parts.join("\n") + "\n"
424
+ }
425
+
426
+ // -- Change state for continuation / new-session resume -------
427
+
428
+ /**
429
+ * Get the current change state for continuation routing.
430
+ *
431
+ * Returns { changeId, phase, lastEvent, lastDelegation, nextAction }
432
+ *
433
+ * - `lastEvent`: last breadcrumb event from today's journal (or null)
434
+ * - `lastDelegation`: last delegation entry from today's journal (or null)
435
+ * - `nextAction`: derived from phase + delegation status using the
436
+ * continuation decision table defined in the using-aiws SKILL.md.
437
+ *
438
+ * Safe to call even when no active change or journal exists — returns
439
+ * a minimal state object with phase="none" and nextAction pointing to
440
+ * ws-intake/ws-plan.
441
+ */
442
+ getChangeState(changeId = null) {
443
+ const id = changeId || this.getActiveChange()?.id || null
444
+ if (!id) {
445
+ return {
446
+ changeId: null,
447
+ phase: "none",
448
+ lastEvent: null,
449
+ lastDelegation: null,
450
+ nextAction: "Start with ws-intake or ws-plan to establish a change context",
451
+ }
452
+ }
453
+
454
+ const detail = this.getChangeDetail(id)
455
+ const phase = detail?.phase || "unknown"
456
+
457
+ // Read today's journal for last event and delegation
458
+ const journal = new AiwsSessionJournal(this.directory)
459
+ const todayEntries = journal.readToday()
460
+ const delegationEntries = todayEntries.filter(e => e.event === "delegation")
461
+ const breadcrumbEntries = todayEntries.filter(e => e.event === "breadcrumb")
462
+
463
+ const lastEvent = breadcrumbEntries.length > 0
464
+ ? breadcrumbEntries[breadcrumbEntries.length - 1]
465
+ : null
466
+
467
+ const lastDelegation = delegationEntries.length > 0
468
+ ? delegationEntries[delegationEntries.length - 1]
469
+ : null
470
+
471
+ // Derive next action from continuation decision table
472
+ const nextAction = this._deriveNextAction(phase, lastDelegation, detail)
473
+
474
+ return {
475
+ changeId: id,
476
+ phase,
477
+ lastEvent,
478
+ lastDelegation,
479
+ nextAction,
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Derive next action from phase + delegation status + detail artifacts.
485
+ * Mirrors the continuation decision table in using-aiws/SKILL.md.
486
+ */
487
+ _deriveNextAction(phase, lastDelegation, detail) {
488
+ // Check for blocked delegation
489
+ if (lastDelegation && lastDelegation.status === "BLOCKED") {
490
+ return `BLOCKED from previous delegation: ${lastDelegation.reason || "unknown reason"}. Resolve blocker before continuing.`
491
+ }
492
+
493
+ // Check for NEEDS_CONTEXT delegation
494
+ if (lastDelegation && lastDelegation.status === "NEEDS_CONTEXT") {
495
+ return `NEEDS_CONTEXT: missing context for ${lastDelegation.role || "worker"}. Supplement context and re-dispatch.`
496
+ }
497
+
498
+ // Phase-based routing
499
+ switch (phase) {
500
+ case "intake":
501
+ return "ws-intake or ws-plan to create a change and execution plan"
502
+ case "planning": {
503
+ // If plan files exist, recommend verify; otherwise recommend plan
504
+ if (detail && detail.planFiles.length > 0) {
505
+ return "ws-plan-verify to review existing plan"
506
+ }
507
+ return "ws-plan to complete planning"
508
+ }
509
+ case "ready-for-dev":
510
+ return "dispatch aiws-worker (subagent-first — main session does not write code directly)"
511
+ case "in-progress": {
512
+ // If worktree is dirty (patches exist) → recommend reviewer
513
+ if (detail && detail.patchesCount > 0) {
514
+ return "dispatch aiws-reviewer + ws-review to audit changes"
515
+ }
516
+ // If we have a prior delegation that was DONE_WITH_CONCERNS
517
+ if (lastDelegation && lastDelegation.status === "DONE_WITH_CONCERNS") {
518
+ return "ws-quality-review to assess residual risk before continuing"
519
+ }
520
+ return "continue implementation or dispatch aiws-reviewer"
521
+ }
522
+ case "review": {
523
+ if (detail && detail.evidenceFiles.length > 0) {
524
+ return "ws-finish or ws-commit — evidence exists, ready to finalize"
525
+ }
526
+ return "collect evidence first, then ws-commit"
527
+ }
528
+ case "finished":
529
+ return "ws-finish to finalize and archive the change"
530
+ default:
531
+ return "Review phase and artifacts; consider ws-preflight to reassess"
532
+ }
533
+ }
534
+
535
+ // -- Git status ------------------------------------------------
536
+
537
+ // -- Spec auto-update (P2 #1) ---------------------------------
538
+
539
+ /**
540
+ * Scan the active change's analysis/ directory for spec-update.jsonl.
541
+ * Returns parsed entries, or empty array if none found.
542
+ *
543
+ * Format: same 5-field as context-injection, plus optional "proposal" text.
544
+ * {"file": "packages/spec/docs/some-spec.md", "reason": "rule about X missing",
545
+ * "sections": [[25,50]], "proposal": "Add: new rule prohibiting async in constructors",
546
+ * "priority": "high", "kind": "spec-update"}
547
+ */
548
+ scanSpecUpdates(changeId) {
549
+ const changeDir = join(this.directory, "changes", changeId)
550
+ if (!existsSync(changeDir)) return []
551
+
552
+ const jsonlPath = join(changeDir, "analysis", "spec-update.jsonl")
553
+ const raw = this.readFile(jsonlPath)
554
+ if (!raw) return []
555
+
556
+ const entries = []
557
+ for (const line of raw.split(/\r?\n/)) {
558
+ const trimmed = line.trim()
559
+ if (!trimmed || trimmed.startsWith("#")) continue
560
+ try {
561
+ const item = JSON.parse(trimmed)
562
+ const filePath = item.file || item.glob
563
+ if (!filePath) continue
564
+ entries.push({
565
+ file: filePath,
566
+ sections: parseSections(item),
567
+ priority: item.priority || "medium",
568
+ kind: item.kind || "spec-update",
569
+ reason: item.reason || "",
570
+ proposal: item.proposal || "",
571
+ status: item.status || "pending",
572
+ })
573
+ } catch {
574
+ // skip malformed lines
575
+ }
576
+ }
577
+
578
+ return entries
579
+ }
580
+
581
+ /**
582
+ * Generate structured spec-update alert text from a change's spec-update.jsonl.
583
+ * For each entry, loads the current spec file content and shows the proposal.
584
+ */
585
+ proposeSpecUpdates(changeId) {
586
+ const entries = this.scanSpecUpdates(changeId)
587
+ if (entries.length === 0) return ""
588
+
589
+ const parts = ["## Spec Updates Pending"]
590
+ for (const e of entries) {
591
+ const fullPath = resolvePath(this.directory, e.file)
592
+ const currentContent = this.readFile(fullPath)
593
+ const excerpt = currentContent
594
+ ? currentContent.split("\n").slice(0, 15).join("\n")
595
+ : "(file not found)"
596
+
597
+ parts.push(`\n### ${e.file}`)
598
+ parts.push(`**Reason:** ${e.reason}`)
599
+ if (e.proposal) parts.push(`**Proposal:** ${e.proposal}`)
600
+ parts.push(`**Current (first 15 lines):**\n\`\`\`\n${excerpt}\n\`\`\``)
601
+ }
602
+
603
+ parts.push(
604
+ "\n**Action required:** Review each proposal. " +
605
+ "Edit the spec file directly, then update `spec-update.jsonl` with `status: \"applied\"`."
606
+ )
607
+
608
+ return parts.join("\n")
609
+ }
610
+
611
+ /** Get a short git status summary string */
612
+ getGitStatus() {
613
+ const head = str(process.env.GIT_HEAD) || null
614
+ const branch = str(process.env.GIT_BRANCH) || null
615
+
616
+ const parts = []
617
+ if (branch) parts.push(`branch: ${branch}`)
618
+ if (head) parts.push(`HEAD: ${head.slice(0, 8)}`)
619
+ return parts.length > 0 ? parts.join(", ") : ""
620
+ }
621
+ }
622
+
623
+ // ============================================================
624
+ // Session journal (G11)
625
+ // ============================================================
626
+
627
+ /**
628
+ * AiwsSessionJournal — Writes structured JSONL entries to .aiws/journal/.
629
+ *
630
+ * Each entry is a single JSONL line with:
631
+ * {"ts": "<ISO-time>", "event": "<type>", "session": "<key>", ...data}
632
+ *
633
+ * Event types: session_start, delegation, phase_transition, spec_change, milestone
634
+ */
635
+ export class AiwsSessionJournal {
636
+ constructor(directory) {
637
+ this.directory = directory
638
+ this.journalDir = join(directory, ".aiws", "journal")
639
+ this._ensureDir()
640
+ }
641
+
642
+ _ensureDir() {
643
+ try {
644
+ mkdirSync(this.journalDir, { recursive: true })
645
+ } catch { /* ignore */ }
646
+ }
647
+
648
+ get _journalPath() {
649
+ return join(this.journalDir, `${new Date().toISOString().slice(0, 10)}.jsonl`)
650
+ }
651
+
652
+ write(eventType, sessionKey, data = {}) {
653
+ const entry = JSON.stringify({
654
+ ts: new Date().toISOString(),
655
+ event: eventType,
656
+ session: sessionKey,
657
+ ...data,
658
+ })
659
+ try {
660
+ appendFileSync(this._journalPath, entry + "\n")
661
+ } catch { /* ignore */ }
662
+ }
663
+
664
+ readToday() {
665
+ try {
666
+ const content = readFileSync(this._journalPath, "utf-8")
667
+ return content
668
+ .split(/\r?\n/)
669
+ .filter(Boolean)
670
+ .map(l => { try { return JSON.parse(l) } catch { return null } })
671
+ .filter(Boolean)
672
+ } catch {
673
+ return []
674
+ }
675
+ }
676
+
677
+ getRecentSessions(count = 5) {
678
+ const entries = this.readToday()
679
+ const seen = new Set()
680
+ return entries
681
+ .filter(e => { const k = e.session; return seen.has(k) ? false : (seen.add(k), true) })
682
+ .slice(-count)
683
+ }
684
+
685
+ /**
686
+ * Record a delegation event (subagent dispatched).
687
+ *
688
+ * Example:
689
+ * journal.writeDelegation(sessionKey, "worker", "change-123", {
690
+ * taskDescription: "implement auth middleware",
691
+ * contextFiles: 3,
692
+ * })
693
+ */
694
+ writeDelegation(sessionKey, role, changeId, extra = {}) {
695
+ this.write("delegation", sessionKey, {
696
+ role,
697
+ changeId,
698
+ ...extra,
699
+ })
700
+ }
701
+
702
+ /**
703
+ * Record a per-turn breadcrumb event.
704
+ *
705
+ * Example:
706
+ * journal.writeBreadcrumb(sessionKey, "dev", changeId, { turn: 5 })
707
+ */
708
+ writeBreadcrumb(sessionKey, phase, changeId, extra = {}) {
709
+ this.write("breadcrumb", sessionKey, {
710
+ phase,
711
+ changeId,
712
+ ...extra,
713
+ })
714
+ }
715
+
716
+ /**
717
+ * Record a phase transition event.
718
+ *
719
+ * Example:
720
+ * journal.writePhaseTransition(sessionKey, "planning", "ready-for-dev", changeId)
721
+ */
722
+ writePhaseTransition(sessionKey, fromPhase, toPhase, changeId, extra = {}) {
723
+ this.write("phase_transition", sessionKey, {
724
+ from: fromPhase,
725
+ to: toPhase,
726
+ changeId,
727
+ ...extra,
728
+ })
729
+ }
730
+
731
+ /**
732
+ * Read today's journal and produce a structured summary (markdown).
733
+ * Returns null if no entries exist for today.
734
+ *
735
+ * Summary format:
736
+ * ## Session Journal (today)
737
+ * - session_start: N entries
738
+ * - delegation: N entries (by role: worker=N, explorer=N, ...)
739
+ * - breadcrumb: N entries
740
+ * - phase_transition: N entries
741
+ */
742
+ getJournalSummary() {
743
+ const entries = this.readToday()
744
+ if (entries.length === 0) return null
745
+
746
+ const counts = {}
747
+ const roleCounts = {}
748
+
749
+ for (const e of entries) {
750
+ const ev = e.event || "unknown"
751
+ counts[ev] = (counts[ev] || 0) + 1
752
+ if (ev === "delegation" && e.role) {
753
+ roleCounts[e.role] = (roleCounts[e.role] || 0) + 1
754
+ }
755
+ }
756
+
757
+ const lines = ["## Session Journal (today)"]
758
+ for (const [ev, n] of Object.entries(counts).sort()) {
759
+ if (ev === "delegation") {
760
+ const byRole = Object.entries(roleCounts)
761
+ .map(([r, n]) => `${r}=${n}`)
762
+ .join(", ")
763
+ lines.push(`- **delegation**: ${n} entries (by role: ${byRole})`)
764
+ } else {
765
+ lines.push(`- **${ev}**: ${n} entries`)
766
+ }
767
+ }
768
+
769
+ return lines.join("\n")
770
+ }
771
+ }
772
+
773
+ // ============================================================
774
+ // Spec digest (G2)
775
+ // ============================================================
776
+
777
+ import { createHash } from "crypto"
778
+
779
+ // sha256 digest of all truth + spec files, hex string or ""
780
+ export function computeSpecDigest(directory) {
781
+ const targets = [
782
+ "AI_PROJECT.md",
783
+ "REQUIREMENTS.md",
784
+ "AI_WORKSPACE.md",
785
+ ]
786
+ const specDir = join(directory, "packages", "spec", "docs")
787
+ if (existsSync(specDir)) {
788
+ try {
789
+ const docs = readdirSync(specDir).filter(f => f.endsWith(".json") || f.endsWith(".md"))
790
+ for (const d of docs) targets.push(join("packages", "spec", "docs", d))
791
+ } catch { /* ignore */ }
792
+ }
793
+
794
+ const hash = createHash("sha256")
795
+ for (const relPath of targets.sort()) {
796
+ const full = join(directory, relPath)
797
+ try {
798
+ const content = readFileSync(full)
799
+ hash.update(relPath)
800
+ hash.update("\x00")
801
+ hash.update(content)
802
+ } catch { /* skip */ }
803
+ }
804
+ return hash.digest("hex")
805
+ }
806
+
807
+ // ============================================================
808
+ // Session context builder (for session-start plugin)
809
+ // ============================================================
810
+
811
+ /**
812
+ * Build the full session-start context block.
813
+ * This is the aiws equivalent of trellis's buildSessionContext.
814
+ */
815
+ export function buildSessionContext(ctx, platformInput = null) {
816
+ const parts = []
817
+ const digest = computeSpecDigest(ctx.directory)
818
+ if (digest) {
819
+ parts.push(`<spec-digest>\n${digest}\n</spec-digest>`)
820
+ }
821
+
822
+ parts.push(`<aiws-context>
823
+ You are starting a new session in an aiws-managed project.
824
+ Read and follow the context below.
825
+ </aiws-context>`)
826
+
827
+ const specIndex = ctx.getSpecIndex()
828
+ if (specIndex) {
829
+ parts.push("<spec-index>")
830
+ parts.push(specIndex.trimEnd())
831
+ parts.push("</spec-index>")
832
+ }
833
+
834
+ const activeChange = ctx.getActiveChange()
835
+ if (activeChange) {
836
+ const phaseSummary = ctx.getPhaseSummary(activeChange.id)
837
+ parts.push("<change-context>")
838
+ parts.push(phaseSummary)
839
+ parts.push("</change-context>")
840
+
841
+ // Continuation resume recommendation
842
+ try {
843
+ const changeState = ctx.getChangeState(activeChange.id)
844
+ if (changeState && changeState.nextAction) {
845
+ parts.push("<resume-recommendation>")
846
+ parts.push("## Continuation / Resume Recommendation")
847
+ parts.push(`Active Change: ${changeState.changeId}`)
848
+ parts.push(`Phase: ${changeState.phase}`)
849
+ if (changeState.lastDelegation) {
850
+ parts.push(`Last Delegation: ${changeState.lastDelegation.role || "unknown"} (status: ${changeState.lastDelegation.status || "unknown"})`)
851
+ }
852
+ parts.push(`Recommended Next: ${changeState.nextAction}`)
853
+ parts.push("</resume-recommendation>")
854
+ }
855
+ } catch {
856
+ // Non-blocking: resume recommendation is advisory only
857
+ }
858
+
859
+ // P2 #1: Check for pending spec-update proposals
860
+ const specUpdates = ctx.proposeSpecUpdates(activeChange.id)
861
+ if (specUpdates) {
862
+ parts.push(`<pending-spec-update>\n${specUpdates}\n</pending-spec-update>`)
863
+ }
864
+ }
865
+
866
+ parts.push("<workflow-phases>")
867
+ parts.push(
868
+ "## aiws Workflow Phases\n" +
869
+ "\n" +
870
+ "The aiws workflow follows a linear pipeline with gates at each stage:\n" +
871
+ "1. **ws-preflight** — Pre-flight: reads truth files, checks git state, outputs constraints\n" +
872
+ "2. **ws-plan** — Create change context and execution plan with bindings\n" +
873
+ "3. **ws-plan-verify** — Review plan before execution\n" +
874
+ "4. **ws-dev** — Implementation against plan\n" +
875
+ "5. **ws-review** — Audit changes against spec\n" +
876
+ "6. **ws-commit** — Validate and commit\n" +
877
+ "7. **ws-finish** — Finalize and archive change\n" +
878
+ "8. **ws-deliver** — Push to remote\n" +
879
+ "\n" +
880
+ "Each phase has required inputs, outputs, and verification gates. " +
881
+ "See `packages/spec/docs/workflow-stage-contracts.json` for stage details."
882
+ )
883
+ parts.push("</workflow-phases>")
884
+
885
+ // -- Journal summary (today's delegation/breadcrumb activity)
886
+ const journal = new AiwsSessionJournal(ctx.directory)
887
+ const journalSummary = journal.getJournalSummary()
888
+ if (journalSummary) {
889
+ parts.push(`<journal-summary>\n${journalSummary}\n</journal-summary>`)
890
+ }
891
+
892
+ // -- Git status
893
+ const gitStatus = ctx.getGitStatus()
894
+ if (gitStatus) {
895
+ parts.push(`<git-status>\n${gitStatus}\n</git-status>`)
896
+ }
897
+
898
+ // Guidelines
899
+ parts.push("<guidelines>")
900
+ parts.push(
901
+ "Spec files are listed above under <spec-index> (digest: see <spec-digest>). " +
902
+ "If the digest changed from the previous session, re-read any spec files that may have been updated.\n" +
903
+ "When delegating sub-agents, include role markers (e.g. 'role: worker') so the inject-context plugin provides the correct JSONL context.\n" +
904
+ "If an active change is shown above, work inside that change context."
905
+ )
906
+ parts.push("</guidelines>")
907
+
908
+ const sessionKey = ctx.getSessionKey(platformInput)
909
+ if (sessionKey) {
910
+ journal.write("session_start", sessionKey, {
911
+ change: activeChange?.id || null,
912
+ phase: activeChange?.phase || null,
913
+ digest,
914
+ })
915
+ }
916
+
917
+ return parts.join("\n\n")
918
+ }
919
+
920
+ // ============================================================
921
+ // Context Collector (session dedup — singleton)
922
+ // ============================================================
923
+
924
+ export class AiwsContextCollector {
925
+ constructor(maxSize = 200) {
926
+ this.processed = new Set()
927
+ this.maxSize = maxSize
928
+ }
929
+
930
+ markProcessed(sessionKey) {
931
+ this.processed.add(sessionKey)
932
+ this._trim()
933
+ }
934
+
935
+ isProcessed(sessionKey) {
936
+ return this.processed.has(sessionKey)
937
+ }
938
+
939
+ clear(sessionKey) {
940
+ this.processed.delete(sessionKey)
941
+ }
942
+
943
+ _trim() {
944
+ if (this.processed.size <= this.maxSize) return
945
+ // Set preserves insertion order; drop oldest entries
946
+ const entries = [...this.processed]
947
+ const overflow = entries.length - this.maxSize
948
+ this.processed = new Set(entries.slice(overflow))
949
+ }
950
+ }
951
+
952
+ /** Singleton instance */
953
+ export const contextCollector = new AiwsContextCollector()