@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.
- package/docs/cli-interface.md +10 -12
- package/docs/opencode-autonomous-swarm.md +178 -0
- package/docs/opencode-omo-adapter.md +123 -4
- package/docs/opencode-omo-validation-checklist.md +47 -0
- package/docs/opencode-subagent-first.md +187 -0
- package/docs/workflow-delegation-context-injection.md +217 -0
- package/docs/workflow-delegation-contracts.json +108 -2
- package/docs/workflow-delegation-contracts.md +13 -0
- package/docs/workflow-delegation-contracts.schema.json +152 -0
- package/docs/workflow-governance-rules.json +47 -6
- package/docs/workflow-governance-rules.md +8 -7
- package/docs/workflow-governance-rules.schema.json +39 -1
- package/docs/workflow-review-gates.json +6 -0
- package/docs/workflow-review-gates.md +14 -5
- package/docs/workflow-review-gates.schema.json +4 -0
- package/docs/workflow-router-rules.json +72 -8
- package/docs/workflow-router-rules.md +21 -14
- package/docs/workflow-router-rules.schema.json +4 -0
- package/docs/workflow-stage-contracts.json +24 -5
- package/docs/workflow-stage-contracts.md +16 -15
- package/docs/workflow-stage-contracts.schema.json +12 -1
- package/package.json +1 -1
- package/templates/workspace/.agents/skills/using-aiws/SKILL.md +16 -10
- package/templates/workspace/.agents/skills/ws-commit/SKILL.md +6 -118
- package/templates/workspace/.agents/skills/ws-deliver/SKILL.md +6 -218
- package/templates/workspace/.agents/skills/ws-dev/SKILL.md +82 -141
- package/templates/workspace/.agents/skills/ws-finish/SKILL.md +9 -204
- package/templates/workspace/.agents/skills/ws-handoff/SKILL.md +10 -44
- package/templates/workspace/.agents/skills/ws-migrate/SKILL.md +6 -42
- package/templates/workspace/.agents/skills/ws-plan-verify/SKILL.md +6 -58
- package/templates/workspace/.agents/skills/ws-pull/SKILL.md +8 -109
- package/templates/workspace/.agents/skills/ws-push/SKILL.md +8 -100
- package/templates/workspace/.agents/skills/ws-review/SKILL.md +6 -1
- package/templates/workspace/.agents/skills/ws-submodule-setup/SKILL.md +10 -57
- package/templates/workspace/.agents/skills/ws-verify-before-complete/SKILL.md +12 -53
- package/templates/workspace/{changes → .aiws/changes}/README.md +20 -23
- package/templates/workspace/{changes → .aiws/changes}/templates/proposal.md +12 -12
- package/templates/workspace/{changes → .aiws/changes}/templates/tasks.md +8 -8
- package/templates/workspace/{issues → .aiws/issues}/problem-issues.csv +1 -1
- package/templates/workspace/.aiws/journal/.gitkeep +1 -0
- package/templates/workspace/{requirements → .aiws/requirements}/requirements-issues.csv +1 -1
- package/templates/workspace/.aiws/tmp/.gitkeep +0 -0
- package/templates/workspace/{tools → .aiws/tools}/requirements_contract.py +2 -2
- package/templates/workspace/{tools → .aiws/tools}/requirements_contract_sync.py +2 -2
- package/templates/workspace/{tools → .aiws/tools}/requirements_flow_gen.py +1 -1
- package/templates/workspace/{tools → .aiws/tools}/server_test_runner.py +10 -10
- package/templates/workspace/{tools → .aiws/tools}/ws_change_check.py +23 -23
- package/templates/workspace/{tools → .aiws/tools}/ws_resolve_sub_target.sh +2 -2
- package/templates/workspace/{tools → .aiws/tools}/ws_tasks_plan.py +3 -3
- package/templates/workspace/.claude/commands/ws-review.md +5 -1
- package/templates/workspace/.claude/settings.json.example +26 -0
- package/templates/workspace/.claude/skills/ws-commit/SKILL.md +6 -118
- package/templates/workspace/.claude/skills/ws-deliver/SKILL.md +6 -218
- package/templates/workspace/.claude/skills/ws-dev/SKILL.md +52 -141
- package/templates/workspace/.claude/skills/ws-finish/SKILL.md +9 -204
- package/templates/workspace/.claude/skills/ws-handoff/SKILL.md +10 -44
- package/templates/workspace/.claude/skills/ws-migrate/SKILL.md +6 -42
- package/templates/workspace/.claude/skills/ws-plan-verify/SKILL.md +6 -49
- package/templates/workspace/.claude/skills/ws-pull/SKILL.md +8 -109
- package/templates/workspace/.claude/skills/ws-push/SKILL.md +8 -100
- package/templates/workspace/.claude/skills/ws-review/SKILL.md +6 -1
- package/templates/workspace/.claude/skills/ws-submodule-setup/SKILL.md +10 -57
- package/templates/workspace/.claude/skills/ws-verify-before-complete/SKILL.md +12 -53
- package/templates/workspace/.githooks/pre-commit +13 -0
- package/templates/workspace/.githooks/pre-push +7 -0
- package/templates/workspace/.opencode/command/ws-auto.md +33 -0
- package/templates/workspace/.opencode/command/ws-autonomy.md +25 -0
- package/templates/workspace/.opencode/command/ws-review.md +5 -1
- package/templates/workspace/.opencode/commands/ws-auto.md +33 -0
- package/templates/workspace/.opencode/commands/ws-autonomy.md +25 -0
- package/templates/workspace/.opencode/commands/ws-commit.md +4 -56
- package/templates/workspace/.opencode/commands/ws-deliver.md +10 -50
- package/templates/workspace/.opencode/commands/ws-finish.md +11 -3
- package/templates/workspace/.opencode/commands/ws-handoff.md +9 -17
- package/templates/workspace/.opencode/commands/ws-migrate.md +10 -17
- package/templates/workspace/.opencode/commands/ws-plan-verify.md +5 -15
- package/templates/workspace/.opencode/commands/ws-pull.md +6 -75
- package/templates/workspace/.opencode/commands/ws-push.md +7 -82
- package/templates/workspace/.opencode/commands/ws-review.md +5 -1
- package/templates/workspace/.opencode/commands/ws-submodule-setup.md +8 -47
- package/templates/workspace/.opencode/commands/ws-verify-before-complete.md +10 -19
- package/templates/workspace/.opencode/helpers/approval-whitelist-check.sh +148 -0
- package/templates/workspace/.opencode/helpers/approval-whitelist-run.sh +82 -0
- package/templates/workspace/.opencode/helpers/approval-whitelist-watchdog.sh +144 -0
- package/templates/workspace/.opencode/helpers/tmux-swarm-rescue.sh +56 -0
- package/templates/workspace/.opencode/helpers/tmux-swarm-scan.sh +46 -0
- package/templates/workspace/.opencode/lib/aiws-context.js +953 -0
- package/templates/workspace/.opencode/oh-my-opencode.json.example +64 -4
- package/templates/workspace/.opencode/plugins/aiws-inject-context.js +201 -0
- package/templates/workspace/.opencode/plugins/aiws-session-start.js +86 -0
- package/templates/workspace/.opencode/plugins/aiws-workflow-state.js +116 -0
- package/templates/workspace/.opencode/scripts/ws-plan-gen-submodule-targets.sh +27 -0
- package/templates/workspace/.opencode/scripts/ws-plan-setup-worktree.sh +31 -0
- package/templates/workspace/.opencode/skills/using-aiws/SKILL.md +130 -77
- package/templates/workspace/.opencode/skills/ws-analyze/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-auto/SKILL.md +46 -0
- package/templates/workspace/.opencode/skills/ws-autonomy/SKILL.md +62 -0
- package/templates/workspace/.opencode/skills/ws-bugfix/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-commit/SKILL.md +6 -118
- package/templates/workspace/.opencode/skills/ws-delegate/SKILL.md +115 -40
- package/templates/workspace/.opencode/skills/ws-deliver/SKILL.md +6 -218
- package/templates/workspace/.opencode/skills/ws-dev/SKILL.md +105 -146
- package/templates/workspace/.opencode/skills/ws-dev-lite/SKILL.md +23 -7
- package/templates/workspace/.opencode/skills/ws-finish/SKILL.md +9 -204
- package/templates/workspace/.opencode/skills/ws-frontend-design/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-handoff/SKILL.md +10 -44
- package/templates/workspace/.opencode/skills/ws-intake/SKILL.md +43 -4
- package/templates/workspace/.opencode/skills/ws-migrate/SKILL.md +6 -42
- package/templates/workspace/.opencode/skills/ws-plan/SKILL.md +15 -55
- package/templates/workspace/.opencode/skills/ws-plan-verify/SKILL.md +6 -49
- package/templates/workspace/.opencode/skills/ws-preflight/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-pull/SKILL.md +8 -109
- package/templates/workspace/.opencode/skills/ws-push/SKILL.md +8 -100
- package/templates/workspace/.opencode/skills/ws-quality-review/SKILL.md +7 -2
- package/templates/workspace/.opencode/skills/ws-req-change/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-req-contract-sync/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-req-contract-validate/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-req-flow-sync/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-req-review/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-review/SKILL.md +35 -4
- package/templates/workspace/.opencode/skills/ws-rule/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-spec-review/SKILL.md +1 -1
- package/templates/workspace/.opencode/skills/ws-submodule-setup/SKILL.md +10 -57
- package/templates/workspace/.opencode/skills/ws-verify-before-complete/SKILL.md +45 -55
- package/templates/workspace/AGENTS.md +32 -84
- package/templates/workspace/AI_PROJECT.md +5 -5
- package/templates/workspace/AI_WORKSPACE.md +1 -1
- package/templates/workspace/REQUIREMENTS.md +1 -1
- package/templates/workspace/gitignore +1 -1
- package/templates/workspace/manifest.json +215 -395
- /package/templates/workspace/{changes → .aiws/changes}/templates/design.md +0 -0
- /package/templates/workspace/{memory-bank → .aiws/memory-bank}/README.md +0 -0
- /package/templates/workspace/{memory-bank → .aiws/memory-bank}/architecture.md +0 -0
- /package/templates/workspace/{memory-bank → .aiws/memory-bank}/implementation-plan.md +0 -0
- /package/templates/workspace/{memory-bank → .aiws/memory-bank}/progress.md +0 -0
- /package/templates/workspace/{memory-bank → .aiws/memory-bank}/tech-stack.md +0 -0
- /package/templates/workspace/{requirements → .aiws/requirements}/CHANGELOG.md +0 -0
- /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()
|