oh-my-llmwikimode 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/KNOWLEDGE_FOLDER_TAXONOMY.md +1 -1
- package/docs/KNOWLEDGE_UX_DASHBOARD_CONTRACT.md +1 -1
- package/docs/design/EOS_DESIGN.md +33 -0
- package/docs/eos-approval-handoff-workbench.md +76 -0
- package/package.json +2 -1
- package/src/eos/approval/handoff-contract.js +200 -0
- package/src/eos/approval/handoff-read-model.js +80 -0
- package/src/eos/approval/handoffs.js +245 -0
- package/src/eos/artifact-inspector/view-model.js +5 -0
- package/src/eos/home-review-queue.js +9 -1
- package/src/eos/home-workspace.js +9 -0
- package/src/eos/home.js +2 -2
- package/src/eos/http/app.js +2 -1
- package/src/eos/http/routes/home.js +18 -0
- package/src/eos/workspace/static/app.js +67 -0
- package/src/eos/workspace/static/index.html +8 -0
- package/src/wiki/save-routing.js +4 -3
|
@@ -25,7 +25,7 @@ Example:
|
|
|
25
25
|
| Current folder | Canonical label | Purpose |
|
|
26
26
|
| --- | --- | --- |
|
|
27
27
|
| `!대시보드` | `!대시보드_dashboard` | User-facing entry point and daily summary |
|
|
28
|
-
| `0_omx 핸드오프` | `
|
|
28
|
+
| `0_omx 핸드오프` | `0_omw-handoff_omw-handoff` | OMW / Codex handoff plans and run instructions; old OMX folder names remain legacy readable inputs |
|
|
29
29
|
| `개발-진행-기록` | `개발-진행-기록_dev-history` | Work logs, roadmap updates, implementation and release history |
|
|
30
30
|
| `개발-진행-기록/로드맵` | `개발-진행-기록_dev-history/로드맵_roadmap` | Roadmaps and planning timeline |
|
|
31
31
|
| `개발-진행-기록/구현-보고` | `개발-진행-기록_dev-history/구현-보고_implementation-reports` | Implementation reports |
|
|
@@ -22,7 +22,7 @@ Snapshot source: local wiki root `C:\Users\USER\Documents\llm-wiki`.
|
|
|
22
22
|
- Markdown entries under the project area: about 177
|
|
23
23
|
- Current top-level project folders:
|
|
24
24
|
- `!대시보드`
|
|
25
|
-
- `0_omx 핸드오프`
|
|
25
|
+
- `0_omx 핸드오프` (legacy readable input; new handoff output uses `0_omw-handoff_omw-handoff`)
|
|
26
26
|
- `가이드-및-정책`
|
|
27
27
|
- `개발-진행-기록`
|
|
28
28
|
- `아카이브`
|
|
@@ -372,3 +372,36 @@ Verification anchors:
|
|
|
372
372
|
- `test/eos/timeline-status-panel.test.js`
|
|
373
373
|
- `test/eos/approval-inspector-gate.test.js`
|
|
374
374
|
- `npm run verify:eos-approval-inspector`
|
|
375
|
+
|
|
376
|
+
## Implemented EOS Approval Handoff Workbench Slice - 2026-06-02
|
|
377
|
+
|
|
378
|
+
The EOS Approval Handoff Workbench slice adds the first safe step after an
|
|
379
|
+
approval request reaches `approved_not_executed`.
|
|
380
|
+
|
|
381
|
+
What changed:
|
|
382
|
+
|
|
383
|
+
- EOS can create a local review-required handoff package under
|
|
384
|
+
`.system/eos/approval/handoffs/`.
|
|
385
|
+
- Handoff package audit records are appended to
|
|
386
|
+
`.system/eos/approval/handoff-audit.jsonl`.
|
|
387
|
+
- The source approval request transitions to `handoff_prepared` only after the
|
|
388
|
+
package artifact exists.
|
|
389
|
+
- `GET /api/home` includes a Handoff Queue summary.
|
|
390
|
+
- `GET /api/workspace/artifacts/inspect?type=approval_handoff&id=<id>` exposes
|
|
391
|
+
a redacted, whitelisted package inspector view.
|
|
392
|
+
- `/workspace/` renders Handoff Queue and package inspection controls using
|
|
393
|
+
package/review language.
|
|
394
|
+
|
|
395
|
+
Boundary:
|
|
396
|
+
|
|
397
|
+
- Handoff packages are human review packages, not external-worker dispatches.
|
|
398
|
+
- This slice does not add shell, git, provider, Discord, worker, queue,
|
|
399
|
+
source-wiki mutation, hosted sync, auto-merge/delete/push/publish, or hidden
|
|
400
|
+
execution behavior.
|
|
401
|
+
|
|
402
|
+
Verification anchors:
|
|
403
|
+
|
|
404
|
+
- `test/eos/approval-handoff-artifacts.test.js`
|
|
405
|
+
- `test/eos/approval-handoff-routes.test.js`
|
|
406
|
+
- `test/eos/workspace-home-routes.test.js`
|
|
407
|
+
- `npm run verify:eos-approval-handoff`
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# EOS Approval Handoff Workbench
|
|
2
|
+
|
|
3
|
+
Status: implemented local product slice.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
EOS can prepare a local handoff package from an approval request that is already
|
|
8
|
+
`approved_not_executed`.
|
|
9
|
+
|
|
10
|
+
The package is written under:
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
.system/eos/approval/handoffs/
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Audit records are appended under:
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
.system/eos/approval/handoff-audit.jsonl
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The source approval request is updated to `handoff_prepared` only after the
|
|
23
|
+
handoff package artifact exists.
|
|
24
|
+
|
|
25
|
+
## Browser Surface
|
|
26
|
+
|
|
27
|
+
Open the local daemon and visit:
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
http://127.0.0.1:4825/workspace/
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The Workspace shows:
|
|
34
|
+
|
|
35
|
+
- Approval Queue
|
|
36
|
+
- Handoff Queue
|
|
37
|
+
- Artifact Inspector
|
|
38
|
+
- Lifecycle Timeline
|
|
39
|
+
- Local Only / Review Required / Not Applied safety labels
|
|
40
|
+
|
|
41
|
+
## API Surface
|
|
42
|
+
|
|
43
|
+
Create a package from a local approved request:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
POST /api/workspace/approval-handoffs
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Inspect a package:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
GET /api/workspace/artifacts/inspect?type=approval_handoff&id=<id>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Read current queues through Home:
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
GET /api/home
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Safety Boundary
|
|
62
|
+
|
|
63
|
+
Handoff packages are review-required local artifacts only.
|
|
64
|
+
|
|
65
|
+
This slice does not execute shell, git, providers, Discord, external workers,
|
|
66
|
+
queues, wiki source mutation, merge, publish, push, hosted sync, or hidden
|
|
67
|
+
agents. It also does not reuse external-worker dispatch as the primary model.
|
|
68
|
+
|
|
69
|
+
## Verification
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
npm run verify:eos-approval-handoff
|
|
73
|
+
npm run test:eos
|
|
74
|
+
npm run verify:eos-boundary-policy
|
|
75
|
+
npm run verify:eos-boundary
|
|
76
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oh-my-llmwikimode",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "LLM Wiki plugin for OpenCode ??auto-memory, retrieval, and knowledge promotion",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server.js",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"verify:eos-workspace-product": "npm run test:eos && npm run verify:eos-boundary-policy && npm run verify:eos-boundary && npm run eos:a11y-snapshot -- --json",
|
|
32
32
|
"verify:eos-workspace-completion": "node scripts/verify-eos-workspace-completion.js",
|
|
33
33
|
"verify:eos-approval-inspector": "node scripts/verify-eos-approval-inspector.js",
|
|
34
|
+
"verify:eos-approval-handoff": "node scripts/verify-eos-approval-handoff.js",
|
|
34
35
|
"verify:package": "node test/verify-package.test.js",
|
|
35
36
|
"verify:docs": "node test/verify-docs.test.js",
|
|
36
37
|
"verify:side-effects": "node scripts/side-effect-audit.js",
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const HANDOFF_PACKAGE_TYPE = "eos_approval_handoff_package";
|
|
5
|
+
export const HANDOFF_PACKAGE_STATUS = "review_required";
|
|
6
|
+
export const HANDOFF_PACKAGE_INTENTS = Object.freeze(["human_handoff"]);
|
|
7
|
+
export const HANDOFF_AUDIT_ACTIONS = Object.freeze([
|
|
8
|
+
"handoff_package_requested",
|
|
9
|
+
"handoff_package_prepared",
|
|
10
|
+
"reject_handoff_package",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const SENSITIVE_RE = /\b(token|password|api[_-]?key|authorization)\s*[:=]\s*([^\s,;]+)/gi;
|
|
14
|
+
const API_KEY_RE = /\bsk-(?:proj-)?[A-Za-z0-9_-]{8,}\b/g;
|
|
15
|
+
const WINDOWS_USER_HOME_RE = /[a-z]:\\users\\[^\\/\s]+\\[^\s"'`)]*/gi;
|
|
16
|
+
const UNIX_USER_HOME_RE = /(?:\/users|\/home)\/[^/\s"'`)]+(?:\/[^\s"'`)]+)*/gi;
|
|
17
|
+
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stableJson(value) {
|
|
23
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
24
|
+
if (isRecord(value)) {
|
|
25
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
26
|
+
}
|
|
27
|
+
return JSON.stringify(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function shortHash(value) {
|
|
31
|
+
return createHash("sha256").update(stableJson(value)).digest("hex").slice(0, 12);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function iso(value) {
|
|
35
|
+
return (value instanceof Date ? value : new Date(value || Date.now())).toISOString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function safeHandoffId(value, fallback = "handoff") {
|
|
39
|
+
return String(value || fallback)
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
42
|
+
.replace(/^-+|-+$/g, "")
|
|
43
|
+
.slice(0, 80) || fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isSafeHandoffId(value) {
|
|
47
|
+
return typeof value === "string" && /^[a-z0-9][a-z0-9_-]{0,159}$/.test(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function sanitizeHandoffValue(value) {
|
|
51
|
+
if (typeof value === "string") return sanitizeString(value);
|
|
52
|
+
if (Array.isArray(value)) return value.map((item) => sanitizeHandoffValue(item));
|
|
53
|
+
if (isRecord(value)) {
|
|
54
|
+
const output = {};
|
|
55
|
+
for (const [key, item] of Object.entries(value)) output[key] = sanitizeHandoffValue(item);
|
|
56
|
+
return output;
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function relativeHandoffPath(id) {
|
|
62
|
+
return path.posix.join(".system", "eos", "approval", "handoffs", `${id}.json`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function validateHandoffRequest(input = {}) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
const sourceApprovalRequestId = typeof input.source_approval_request_id === "string"
|
|
68
|
+
? input.source_approval_request_id.trim()
|
|
69
|
+
: typeof input.sourceApprovalRequestId === "string"
|
|
70
|
+
? input.sourceApprovalRequestId.trim()
|
|
71
|
+
: "";
|
|
72
|
+
const packageIntent = typeof input.package_intent === "string" && input.package_intent.trim()
|
|
73
|
+
? input.package_intent.trim()
|
|
74
|
+
: typeof input.packageIntent === "string" && input.packageIntent.trim()
|
|
75
|
+
? input.packageIntent.trim()
|
|
76
|
+
: "human_handoff";
|
|
77
|
+
|
|
78
|
+
if (!sourceApprovalRequestId) errors.push("source_approval_request_id is required");
|
|
79
|
+
if (!isSafeHandoffId(sourceApprovalRequestId)) errors.push("source_approval_request_id must be a safe artifact id");
|
|
80
|
+
if (!HANDOFF_PACKAGE_INTENTS.includes(packageIntent)) errors.push("package_intent is not supported");
|
|
81
|
+
|
|
82
|
+
if (errors.length > 0) return Object.freeze({ valid: false, errors: Object.freeze(errors) });
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
valid: true,
|
|
85
|
+
value: Object.freeze({
|
|
86
|
+
source_approval_request_id: sourceApprovalRequestId,
|
|
87
|
+
package_intent: packageIntent,
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createHandoffPackageArtifact(approvalRequest, request, options = {}) {
|
|
93
|
+
const seed = {
|
|
94
|
+
source_approval_request_id: approvalRequest.id,
|
|
95
|
+
source_approval_artifact_path: approvalRequest.artifact_path,
|
|
96
|
+
package_intent: request.package_intent,
|
|
97
|
+
};
|
|
98
|
+
const id = `handoff-${safeHandoffId(approvalRequest.id)}-${safeHandoffId(request.package_intent)}-${shortHash(seed)}`;
|
|
99
|
+
const artifactPath = relativeHandoffPath(id);
|
|
100
|
+
const now = iso(options.now);
|
|
101
|
+
const evidenceManifest = Array.from(new Set([
|
|
102
|
+
approvalRequest.artifact_path,
|
|
103
|
+
approvalRequest.source_artifact_path,
|
|
104
|
+
approvalRequest.source_path,
|
|
105
|
+
...(Array.isArray(approvalRequest.evidence) ? approvalRequest.evidence : []),
|
|
106
|
+
].filter(Boolean))).map((item) => sanitizeString(String(item)));
|
|
107
|
+
const timelineSummary = Object.freeze([
|
|
108
|
+
`approval_request:${approvalRequest.status}`,
|
|
109
|
+
`handoff_package:${HANDOFF_PACKAGE_STATUS}`,
|
|
110
|
+
]);
|
|
111
|
+
const handoffPrompt = [
|
|
112
|
+
"Review this local EOS handoff package.",
|
|
113
|
+
`Source approval request: ${approvalRequest.id}`,
|
|
114
|
+
`Requested action: ${approvalRequest.requested_action || "prepare_handoff"}`,
|
|
115
|
+
"This package is review-required and not applied.",
|
|
116
|
+
"Do not execute shell, git, providers, Discord, workers, wiki mutation, merge, publish, or push from this artifact.",
|
|
117
|
+
].join("\n");
|
|
118
|
+
|
|
119
|
+
const artifact = Object.freeze({
|
|
120
|
+
schema_version: 1,
|
|
121
|
+
type: HANDOFF_PACKAGE_TYPE,
|
|
122
|
+
id,
|
|
123
|
+
status: HANDOFF_PACKAGE_STATUS,
|
|
124
|
+
package_intent: request.package_intent,
|
|
125
|
+
source_approval_request_id: approvalRequest.id,
|
|
126
|
+
source_approval_artifact_path: approvalRequest.artifact_path,
|
|
127
|
+
source_decision_id: approvalRequest.source_decision_id || "",
|
|
128
|
+
source_artifact_path: approvalRequest.source_artifact_path || "",
|
|
129
|
+
requested_action: approvalRequest.requested_action || "prepare_handoff",
|
|
130
|
+
reason: sanitizeString(approvalRequest.reason || ""),
|
|
131
|
+
evidence_manifest: Object.freeze(evidenceManifest),
|
|
132
|
+
timeline_summary: timelineSummary,
|
|
133
|
+
human_checklist: Object.freeze([
|
|
134
|
+
"Inspect the source approval request and evidence manifest.",
|
|
135
|
+
"Confirm the handoff prompt matches the approved intent.",
|
|
136
|
+
"Keep this package local and review-required until a separate execution contract exists.",
|
|
137
|
+
]),
|
|
138
|
+
handoff_prompt: sanitizeString(handoffPrompt),
|
|
139
|
+
safety_flags: Object.freeze({
|
|
140
|
+
review_required: true,
|
|
141
|
+
local_only: true,
|
|
142
|
+
executed: false,
|
|
143
|
+
live_execution: false,
|
|
144
|
+
source_entry_mutated: false,
|
|
145
|
+
}),
|
|
146
|
+
boundaries: Object.freeze({
|
|
147
|
+
no_hidden_execution: true,
|
|
148
|
+
no_source_mutation: true,
|
|
149
|
+
external_worker_dispatch: false,
|
|
150
|
+
shell_execution: false,
|
|
151
|
+
git_mutation: false,
|
|
152
|
+
provider_call: false,
|
|
153
|
+
discord_runtime: false,
|
|
154
|
+
auto_apply: false,
|
|
155
|
+
auto_promote: false,
|
|
156
|
+
auto_merge: false,
|
|
157
|
+
auto_delete: false,
|
|
158
|
+
auto_push: false,
|
|
159
|
+
auto_publish: false,
|
|
160
|
+
hosted_sync: false,
|
|
161
|
+
}),
|
|
162
|
+
created_at: now,
|
|
163
|
+
updated_at: now,
|
|
164
|
+
artifact_path: artifactPath,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return Object.freeze({
|
|
168
|
+
artifactPath,
|
|
169
|
+
artifact,
|
|
170
|
+
auditEntry: createHandoffAuditEntry("handoff_package_prepared", request, options, {
|
|
171
|
+
packageId: id,
|
|
172
|
+
artifactPath,
|
|
173
|
+
sourceApprovalRequestId: approvalRequest.id,
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function createHandoffAuditEntry(action, request = {}, options = {}, refs = {}) {
|
|
179
|
+
return Object.freeze({
|
|
180
|
+
schema_version: 1,
|
|
181
|
+
action,
|
|
182
|
+
recorded_at: iso(options.now),
|
|
183
|
+
actor: options.actor || "eos-approval-handoff",
|
|
184
|
+
package_id: refs.packageId || null,
|
|
185
|
+
artifact_path: refs.artifactPath || null,
|
|
186
|
+
source_approval_request_id: refs.sourceApprovalRequestId || request.source_approval_request_id || "",
|
|
187
|
+
package_intent: request.package_intent || "human_handoff",
|
|
188
|
+
executed: false,
|
|
189
|
+
live_execution: false,
|
|
190
|
+
source_entry_mutated: false,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function sanitizeString(value) {
|
|
195
|
+
return String(value || "")
|
|
196
|
+
.replace(SENSITIVE_RE, "$1=[REDACTED]")
|
|
197
|
+
.replace(API_KEY_RE, "[REDACTED]")
|
|
198
|
+
.replace(WINDOWS_USER_HOME_RE, "[REDACTED_PATH]")
|
|
199
|
+
.replace(UNIX_USER_HOME_RE, "[REDACTED_PATH]");
|
|
200
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { HANDOFF_PACKAGE_TYPE } from "./handoff-contract.js";
|
|
5
|
+
|
|
6
|
+
function zeroCounts() {
|
|
7
|
+
return { review_required: 0 };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function toHandoffSummary(artifact) {
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
id: artifact.id,
|
|
13
|
+
status: artifact.status,
|
|
14
|
+
package_intent: artifact.package_intent,
|
|
15
|
+
source_approval_request_id: artifact.source_approval_request_id,
|
|
16
|
+
source_approval_artifact_path: artifact.source_approval_artifact_path,
|
|
17
|
+
requested_action: artifact.requested_action,
|
|
18
|
+
artifact_path: artifact.artifact_path,
|
|
19
|
+
created_at: artifact.created_at || null,
|
|
20
|
+
safety_flags: artifact.safety_flags || {},
|
|
21
|
+
review_required: artifact.status === "review_required",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function summarizeApprovalHandoffQueue(wikiRoot, options = {}) {
|
|
26
|
+
const counts = zeroCounts();
|
|
27
|
+
const recent = [];
|
|
28
|
+
const loadErrors = [];
|
|
29
|
+
const handoffsDir = path.join(wikiRoot, ".system", "eos", "approval", "handoffs");
|
|
30
|
+
let entries;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
entries = fs.readdirSync(handoffsDir, { withFileTypes: true });
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error?.code === "ENOENT") return emptyHandoffQueue(counts);
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
41
|
+
try {
|
|
42
|
+
const artifact = JSON.parse(fs.readFileSync(path.join(handoffsDir, entry.name), "utf-8"));
|
|
43
|
+
if (artifact?.type !== HANDOFF_PACKAGE_TYPE || artifact.status !== "review_required") continue;
|
|
44
|
+
counts.review_required += 1;
|
|
45
|
+
recent.push(toHandoffSummary(artifact));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
loadErrors.push({
|
|
48
|
+
path: path.posix.join(".system", "eos", "approval", "handoffs", entry.name),
|
|
49
|
+
error: error instanceof Error ? error.message : String(error),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
recent.sort((left, right) => {
|
|
55
|
+
return String(right.created_at || "").localeCompare(String(left.created_at || "")) ||
|
|
56
|
+
String(left.id).localeCompare(String(right.id));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return Object.freeze({
|
|
60
|
+
counts: Object.freeze(counts),
|
|
61
|
+
total: recent.length,
|
|
62
|
+
recent: Object.freeze(recent.slice(0, options.limit || 5)),
|
|
63
|
+
load_errors: Object.freeze(loadErrors),
|
|
64
|
+
review_required: counts.review_required,
|
|
65
|
+
local_only: true,
|
|
66
|
+
prepare_only: true,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function emptyHandoffQueue(counts) {
|
|
71
|
+
return Object.freeze({
|
|
72
|
+
counts: Object.freeze(counts),
|
|
73
|
+
total: 0,
|
|
74
|
+
recent: Object.freeze([]),
|
|
75
|
+
load_errors: Object.freeze([]),
|
|
76
|
+
review_required: 0,
|
|
77
|
+
local_only: true,
|
|
78
|
+
prepare_only: true,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { APPROVAL_REQUEST_STATUSES, getApprovalArtifactPaths } from "./artifacts.js";
|
|
5
|
+
import {
|
|
6
|
+
HANDOFF_AUDIT_ACTIONS,
|
|
7
|
+
HANDOFF_PACKAGE_TYPE,
|
|
8
|
+
createHandoffAuditEntry,
|
|
9
|
+
createHandoffPackageArtifact,
|
|
10
|
+
isSafeHandoffId,
|
|
11
|
+
sanitizeHandoffValue,
|
|
12
|
+
validateHandoffRequest,
|
|
13
|
+
} from "./handoff-contract.js";
|
|
14
|
+
|
|
15
|
+
export { HANDOFF_PACKAGE_TYPE } from "./handoff-contract.js";
|
|
16
|
+
|
|
17
|
+
export function getApprovalHandoffArtifactPaths(wikiRoot) {
|
|
18
|
+
const root = path.join(wikiRoot, ".system", "eos", "approval");
|
|
19
|
+
return Object.freeze({
|
|
20
|
+
root,
|
|
21
|
+
handoffs: path.join(root, "handoffs"),
|
|
22
|
+
auditFile: path.join(root, "handoff-audit.jsonl"),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function ensureApprovalHandoffArtifactStructure(wikiRoot) {
|
|
27
|
+
const paths = getApprovalHandoffArtifactPaths(wikiRoot);
|
|
28
|
+
await mkdir(paths.handoffs, { recursive: true });
|
|
29
|
+
return paths;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function prepareApprovalHandoffPackage(wikiRoot, input = {}, options = {}) {
|
|
33
|
+
if (!wikiRoot || typeof wikiRoot !== "string") {
|
|
34
|
+
throw new Error("wikiRoot is required for Eos approval handoff artifacts");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const validation = validateHandoffRequest(input);
|
|
38
|
+
if (!validation.valid) {
|
|
39
|
+
await appendHandoffAudit(wikiRoot, "reject_handoff_package", input, options);
|
|
40
|
+
return rejected(validation.errors.join("; "));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const request = validation.value;
|
|
44
|
+
const source = await readApprovalRequestById(wikiRoot, request.source_approval_request_id);
|
|
45
|
+
if (!source.success) {
|
|
46
|
+
await appendHandoffAudit(wikiRoot, "reject_handoff_package", request, options);
|
|
47
|
+
return rejected(source.error);
|
|
48
|
+
}
|
|
49
|
+
const prepared = createHandoffPackageArtifact(source.artifact, request, options);
|
|
50
|
+
const resolvedPackagePath = resolveHandoffArtifactPath(wikiRoot, prepared.artifactPath);
|
|
51
|
+
const existing = await readExistingJson(resolvedPackagePath);
|
|
52
|
+
if (existing.exists && source.artifact.status === "handoff_prepared") {
|
|
53
|
+
return Object.freeze({
|
|
54
|
+
success: true,
|
|
55
|
+
duplicate: true,
|
|
56
|
+
package: existing.value,
|
|
57
|
+
artifactPath: existing.value.artifact_path || prepared.artifactPath,
|
|
58
|
+
executed: false,
|
|
59
|
+
sourceEntryMutated: false,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (source.artifact.status !== "approved_not_executed") {
|
|
63
|
+
await appendHandoffAudit(wikiRoot, "reject_handoff_package", request, options);
|
|
64
|
+
return rejected("handoff packages require approved_not_executed approval requests");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (existing.exists) {
|
|
68
|
+
await markApprovalRequestHandoffPrepared(wikiRoot, source.artifact, existing.value.artifact_path || prepared.artifactPath, options);
|
|
69
|
+
return Object.freeze({
|
|
70
|
+
success: true,
|
|
71
|
+
duplicate: true,
|
|
72
|
+
package: existing.value,
|
|
73
|
+
artifactPath: existing.value.artifact_path || prepared.artifactPath,
|
|
74
|
+
executed: false,
|
|
75
|
+
sourceEntryMutated: false,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await ensureApprovalHandoffArtifactStructure(wikiRoot);
|
|
80
|
+
await appendHandoffAudit(wikiRoot, "handoff_package_requested", request, options);
|
|
81
|
+
await writeFile(resolvedPackagePath, `${JSON.stringify(prepared.artifact, null, 2)}\n`, {
|
|
82
|
+
encoding: "utf-8",
|
|
83
|
+
flag: "wx",
|
|
84
|
+
});
|
|
85
|
+
await appendHandoffAuditEntry(wikiRoot, prepared.auditEntry);
|
|
86
|
+
await markApprovalRequestHandoffPrepared(wikiRoot, source.artifact, prepared.artifactPath, options);
|
|
87
|
+
|
|
88
|
+
return Object.freeze({
|
|
89
|
+
success: true,
|
|
90
|
+
duplicate: false,
|
|
91
|
+
package: prepared.artifact,
|
|
92
|
+
artifactPath: prepared.artifactPath,
|
|
93
|
+
audit: prepared.auditEntry,
|
|
94
|
+
auditPath: ".system/eos/approval/handoff-audit.jsonl",
|
|
95
|
+
executed: false,
|
|
96
|
+
sourceEntryMutated: false,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function readApprovalHandoffPackages(wikiRoot) {
|
|
101
|
+
const paths = getApprovalHandoffArtifactPaths(wikiRoot);
|
|
102
|
+
const packages = [];
|
|
103
|
+
const load_errors = [];
|
|
104
|
+
try {
|
|
105
|
+
const entries = await readdir(paths.handoffs, { withFileTypes: true });
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
108
|
+
const filePath = path.join(paths.handoffs, entry.name);
|
|
109
|
+
try {
|
|
110
|
+
const artifact = JSON.parse(await readFile(filePath, "utf-8"));
|
|
111
|
+
if (artifact?.type === HANDOFF_PACKAGE_TYPE && artifact.status === "review_required") {
|
|
112
|
+
packages.push(sanitizeHandoffValue(artifact));
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
load_errors.push({
|
|
116
|
+
path: path.posix.join(".system", "eos", "approval", "handoffs", entry.name),
|
|
117
|
+
error: error instanceof Error ? error.message : String(error),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error?.code !== "ENOENT") throw error;
|
|
123
|
+
}
|
|
124
|
+
return Object.freeze({
|
|
125
|
+
paths,
|
|
126
|
+
packages: Object.freeze(packages.sort(sortRecent)),
|
|
127
|
+
load_errors: Object.freeze(load_errors),
|
|
128
|
+
summary: Object.freeze({ packages: packages.length, load_errors: load_errors.length }),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function readApprovalRequestById(wikiRoot, id) {
|
|
133
|
+
if (!isSafeHandoffId(id)) return { success: false, error: "source_approval_request_id must be safe" };
|
|
134
|
+
const artifactPath = path.posix.join(".system", "eos", "approval", "requests", `${id}.json`);
|
|
135
|
+
const filePath = resolveApprovalRequestPath(wikiRoot, artifactPath);
|
|
136
|
+
let artifact;
|
|
137
|
+
try {
|
|
138
|
+
artifact = JSON.parse(await readFile(filePath, "utf-8"));
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error?.code === "ENOENT") return { success: false, error: `Unknown approval request: ${id}` };
|
|
141
|
+
return { success: false, error: "Approval request could not be read" };
|
|
142
|
+
}
|
|
143
|
+
if (artifact?.type !== "eos_approval_request" || !APPROVAL_REQUEST_STATUSES.includes(artifact.status)) {
|
|
144
|
+
return { success: false, error: "Approval request is not valid" };
|
|
145
|
+
}
|
|
146
|
+
return { success: true, artifact: sanitizeHandoffValue(artifact), filePath };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function markApprovalRequestHandoffPrepared(wikiRoot, approvalRequest, packagePath, options = {}) {
|
|
150
|
+
const artifactPath = approvalRequest.artifact_path || path.posix.join(".system", "eos", "approval", "requests", `${approvalRequest.id}.json`);
|
|
151
|
+
const filePath = resolveApprovalRequestPath(wikiRoot, artifactPath);
|
|
152
|
+
const current = JSON.parse(await readFile(filePath, "utf-8"));
|
|
153
|
+
if (current.status === "handoff_prepared") return;
|
|
154
|
+
const updated = {
|
|
155
|
+
...current,
|
|
156
|
+
status: "handoff_prepared",
|
|
157
|
+
handoff_package_artifact_path: packagePath,
|
|
158
|
+
safety_flags: {
|
|
159
|
+
...(current.safety_flags || {}),
|
|
160
|
+
review_required: true,
|
|
161
|
+
approved_not_executed: true,
|
|
162
|
+
executed: false,
|
|
163
|
+
source_entry_mutated: false,
|
|
164
|
+
},
|
|
165
|
+
updated_at: new Date(options.now || Date.now()).toISOString(),
|
|
166
|
+
};
|
|
167
|
+
await writeFile(filePath, `${JSON.stringify(updated, null, 2)}\n`, "utf-8");
|
|
168
|
+
await appendApprovalAudit(wikiRoot, {
|
|
169
|
+
schema_version: 1,
|
|
170
|
+
action: "handoff_prepared",
|
|
171
|
+
recorded_at: new Date(options.now || Date.now()).toISOString(),
|
|
172
|
+
actor: options.actor || "eos-approval-handoff",
|
|
173
|
+
request_id: current.id,
|
|
174
|
+
artifact_path: artifactPath,
|
|
175
|
+
handoff_package_artifact_path: packagePath,
|
|
176
|
+
source_decision_id: current.source_decision_id || "",
|
|
177
|
+
requested_action: current.requested_action || "",
|
|
178
|
+
executed: false,
|
|
179
|
+
live_execution: false,
|
|
180
|
+
source_entry_mutated: false,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function appendApprovalAudit(wikiRoot, entry) {
|
|
185
|
+
const paths = getApprovalArtifactPaths(wikiRoot);
|
|
186
|
+
await mkdir(paths.requests, { recursive: true });
|
|
187
|
+
await appendFile(paths.auditFile, `${JSON.stringify(entry)}\n`, "utf-8");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function appendHandoffAudit(wikiRoot, action, request = {}, options = {}) {
|
|
191
|
+
return appendHandoffAuditEntry(wikiRoot, createHandoffAuditEntry(action, request, options));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function appendHandoffAuditEntry(wikiRoot, entry) {
|
|
195
|
+
if (!HANDOFF_AUDIT_ACTIONS.includes(entry.action)) {
|
|
196
|
+
throw new Error(`Unsupported Eos approval handoff audit action: ${entry.action}`);
|
|
197
|
+
}
|
|
198
|
+
const paths = await ensureApprovalHandoffArtifactStructure(wikiRoot);
|
|
199
|
+
await appendFile(paths.auditFile, `${JSON.stringify(entry)}\n`, "utf-8");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function readExistingJson(filePath) {
|
|
203
|
+
try {
|
|
204
|
+
return Object.freeze({ exists: true, value: JSON.parse(await readFile(filePath, "utf-8")) });
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error?.code === "ENOENT") return Object.freeze({ exists: false, value: null });
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function resolveApprovalRequestPath(wikiRoot, relativePath) {
|
|
212
|
+
return resolveUnderDirectory(wikiRoot, relativePath, path.join(wikiRoot, ".system", "eos", "approval", "requests"));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function resolveHandoffArtifactPath(wikiRoot, relativePath) {
|
|
216
|
+
return resolveUnderDirectory(wikiRoot, relativePath, path.join(wikiRoot, ".system", "eos", "approval", "handoffs"));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function resolveUnderDirectory(wikiRoot, relativePath, allowedDirectory) {
|
|
220
|
+
const root = path.resolve(wikiRoot);
|
|
221
|
+
const target = path.resolve(root, ...String(relativePath).split("/"));
|
|
222
|
+
const relativeRoot = path.relative(root, target);
|
|
223
|
+
if (!relativeRoot || relativeRoot.startsWith("..") || path.isAbsolute(relativeRoot)) {
|
|
224
|
+
throw new Error("Eos approval handoff path escapes wiki root");
|
|
225
|
+
}
|
|
226
|
+
const relativeAllowed = path.relative(path.resolve(allowedDirectory), target);
|
|
227
|
+
if (!relativeAllowed || relativeAllowed.startsWith("..") || path.isAbsolute(relativeAllowed)) {
|
|
228
|
+
throw new Error("Eos approval handoff path escapes allowed directory");
|
|
229
|
+
}
|
|
230
|
+
return target;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function sortRecent(left, right) {
|
|
234
|
+
return String(right.created_at || "").localeCompare(String(left.created_at || "")) ||
|
|
235
|
+
String(left.id).localeCompare(String(right.id));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function rejected(error) {
|
|
239
|
+
return Object.freeze({
|
|
240
|
+
success: false,
|
|
241
|
+
error,
|
|
242
|
+
executed: false,
|
|
243
|
+
sourceEntryMutated: false,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
@@ -25,6 +25,11 @@ const ARTIFACT_TYPES = Object.freeze({
|
|
|
25
25
|
relativePath: (id) => `.system/eos/approval/requests/${id}.json`,
|
|
26
26
|
format: "json",
|
|
27
27
|
}),
|
|
28
|
+
approval_handoff: Object.freeze({
|
|
29
|
+
title: "Approval Handoff Package",
|
|
30
|
+
relativePath: (id) => `.system/eos/approval/handoffs/${id}.json`,
|
|
31
|
+
format: "json",
|
|
32
|
+
}),
|
|
28
33
|
visual_qa: Object.freeze({
|
|
29
34
|
title: "Visual QA Artifact",
|
|
30
35
|
relativePath: (id) => `.system/eos/visual-qa/${id}.json`,
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
buildLlmWikiCuratorBrowserData,
|
|
6
6
|
buildLlmWikiGraphReasoningBrowserData,
|
|
7
7
|
} from "../llmwiki/eos-adapter.js";
|
|
8
|
+
import { summarizeApprovalHandoffQueue } from "./approval/handoff-read-model.js";
|
|
8
9
|
import { summarizeApprovalQueue } from "./approval/read-model.js";
|
|
9
10
|
import { classifyChatIntent } from "./chat/intent-hints.js";
|
|
10
11
|
|
|
@@ -23,6 +24,7 @@ export function buildReviewQueue(wikiRoot) {
|
|
|
23
24
|
const chatRequests = summarizeChatRequestsForHome(wikiRoot);
|
|
24
25
|
const sideReviewDecisions = summarizeSideReviewDecisionsForHome(wikiRoot);
|
|
25
26
|
const approvalQueue = summarizeApprovalQueue(wikiRoot);
|
|
27
|
+
const approvalHandoffQueue = summarizeApprovalHandoffQueue(wikiRoot);
|
|
26
28
|
|
|
27
29
|
return {
|
|
28
30
|
curator_queue_count: curatorQueueCount,
|
|
@@ -41,13 +43,19 @@ export function buildReviewQueue(wikiRoot) {
|
|
|
41
43
|
approval_request_recent: approvalQueue.recent,
|
|
42
44
|
approval_request_load_errors: approvalQueue.load_errors,
|
|
43
45
|
approval_queue: approvalQueue,
|
|
46
|
+
approval_handoff_count: approvalHandoffQueue.total,
|
|
47
|
+
approval_handoff_counts: approvalHandoffQueue.counts,
|
|
48
|
+
approval_handoff_recent: approvalHandoffQueue.recent,
|
|
49
|
+
approval_handoff_load_errors: approvalHandoffQueue.load_errors,
|
|
50
|
+
approval_handoff_queue: approvalHandoffQueue,
|
|
44
51
|
total_review_required: curatorQueueCount
|
|
45
52
|
+ curatorProposalCount
|
|
46
53
|
+ graphQueueCardCount
|
|
47
54
|
+ graphInsightCount
|
|
48
55
|
+ chatRequests.total
|
|
49
56
|
+ sideReviewDecisions.total
|
|
50
|
-
+ approvalQueue.review_required
|
|
57
|
+
+ approvalQueue.review_required
|
|
58
|
+
+ approvalHandoffQueue.review_required,
|
|
51
59
|
};
|
|
52
60
|
}
|
|
53
61
|
|
|
@@ -36,16 +36,19 @@ export function buildWorkspaceHome() {
|
|
|
36
36
|
primary_surface: "chat_capture",
|
|
37
37
|
companion_surface: "side_review",
|
|
38
38
|
approval_surface: "approval_queue",
|
|
39
|
+
handoff_surface: "handoff_queue",
|
|
39
40
|
capture_endpoint: "/api/chat/requests",
|
|
40
41
|
home_endpoint: "/api/home",
|
|
41
42
|
side_review_endpoint: "/api/side-review",
|
|
42
43
|
approval_queue_endpoint: "/api/home",
|
|
44
|
+
approval_handoff_endpoint: "/api/workspace/approval-handoffs",
|
|
43
45
|
safe_copy: {
|
|
44
46
|
primary: "Chat-first capture",
|
|
45
47
|
safety: "Local Only / Review Required / Not Applied",
|
|
46
48
|
capture: "Capture for Review",
|
|
47
49
|
review: "Open Side Review",
|
|
48
50
|
approval: "Open Approval Queue",
|
|
51
|
+
handoff: "Prepare Handoff Package",
|
|
49
52
|
},
|
|
50
53
|
nav: [
|
|
51
54
|
{
|
|
@@ -66,6 +69,12 @@ export function buildWorkspaceHome() {
|
|
|
66
69
|
href: "/workspace/",
|
|
67
70
|
target: "approval_queue",
|
|
68
71
|
},
|
|
72
|
+
{
|
|
73
|
+
id: "handoff-queue",
|
|
74
|
+
label: "Handoff Queue",
|
|
75
|
+
href: "/workspace/",
|
|
76
|
+
target: "handoff_queue",
|
|
77
|
+
},
|
|
69
78
|
],
|
|
70
79
|
forbidden_capabilities: [
|
|
71
80
|
"hidden_execution",
|
package/src/eos/home.js
CHANGED
|
@@ -35,7 +35,7 @@ function buildDraftPressure(stats) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function buildCommandCenterHealth(reviewQueue, librarian) {
|
|
38
|
-
const loadErrorCount = safeCount(reviewQueue.chat_request_load_errors?.length) + safeCount(reviewQueue.side_review_decision_load_errors?.length) + safeCount(reviewQueue.approval_request_load_errors?.length);
|
|
38
|
+
const loadErrorCount = safeCount(reviewQueue.chat_request_load_errors?.length) + safeCount(reviewQueue.side_review_decision_load_errors?.length) + safeCount(reviewQueue.approval_request_load_errors?.length) + safeCount(reviewQueue.approval_handoff_load_errors?.length);
|
|
39
39
|
const waitingCount = safeCount(reviewQueue.total_review_required);
|
|
40
40
|
const status = loadErrorCount > 0 ? "needs_attention" : waitingCount > 0 ? "ready_for_review" : "clear";
|
|
41
41
|
const label = loadErrorCount > 0 ? "Needs Attention" : waitingCount > 0 ? "Ready for Review" : "Clear";
|
|
@@ -58,7 +58,7 @@ function buildCommandCenterHealth(reviewQueue, librarian) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
function buildCommandCenterSections(reviewQueue, librarian) {
|
|
61
|
-
const loadErrorCount = safeCount(reviewQueue.chat_request_load_errors?.length) + safeCount(reviewQueue.side_review_decision_load_errors?.length) + safeCount(reviewQueue.approval_request_load_errors?.length);
|
|
61
|
+
const loadErrorCount = safeCount(reviewQueue.chat_request_load_errors?.length) + safeCount(reviewQueue.side_review_decision_load_errors?.length) + safeCount(reviewQueue.approval_request_load_errors?.length) + safeCount(reviewQueue.approval_handoff_load_errors?.length);
|
|
62
62
|
return [
|
|
63
63
|
{
|
|
64
64
|
id: "waiting_review",
|
package/src/eos/http/app.js
CHANGED
|
@@ -80,7 +80,8 @@ function isLocalStaticUiRoute(url) {
|
|
|
80
80
|
|
|
81
81
|
function isGuardedLocalMutationRoute(url) {
|
|
82
82
|
const pathname = getPathname(url);
|
|
83
|
-
return pathname.startsWith("/api/agent/") || pathname.startsWith("/api/chat/")
|
|
83
|
+
return pathname.startsWith("/api/agent/") || pathname.startsWith("/api/chat/") ||
|
|
84
|
+
pathname === "/api/workspace/approval-handoffs";
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
// ---------------------------------------------------------------------------
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildEosHomeData } from "../../home.js";
|
|
2
|
+
import { prepareApprovalHandoffPackage } from "../../approval/handoffs.js";
|
|
2
3
|
import { inspectArtifact } from "../../artifact-inspector/view-model.js";
|
|
3
4
|
import { buildTimelineStatusPanel } from "../../timeline-status/read-model.js";
|
|
4
5
|
import { errorResponse } from "../errors.js";
|
|
@@ -46,6 +47,23 @@ export async function registerHomeRoutes(fastify, config) {
|
|
|
46
47
|
}
|
|
47
48
|
return { artifact, safety };
|
|
48
49
|
});
|
|
50
|
+
|
|
51
|
+
fastify.post("/api/workspace/approval-handoffs", async (request, reply) => {
|
|
52
|
+
const result = await prepareApprovalHandoffPackage(config.wikiRoot, request.body || {}, {
|
|
53
|
+
actor: request.headers["x-eos-user-id"] || "eos-http",
|
|
54
|
+
});
|
|
55
|
+
if (!result.success) {
|
|
56
|
+
return reply.status(400).send({
|
|
57
|
+
error: {
|
|
58
|
+
code: "APPROVAL_HANDOFF_REJECTED",
|
|
59
|
+
message: result.error,
|
|
60
|
+
statusCode: 400,
|
|
61
|
+
},
|
|
62
|
+
result,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return reply.status(201).send(result);
|
|
66
|
+
});
|
|
49
67
|
}
|
|
50
68
|
|
|
51
69
|
export default registerHomeRoutes;
|
|
@@ -16,6 +16,8 @@ const elements = {
|
|
|
16
16
|
decisionSummary: document.querySelector("#decision-summary"),
|
|
17
17
|
approvalSummary: document.querySelector("#approval-summary"),
|
|
18
18
|
approvalList: document.querySelector("#approval-list"),
|
|
19
|
+
handoffSummary: document.querySelector("#handoff-summary"),
|
|
20
|
+
handoffList: document.querySelector("#handoff-list"),
|
|
19
21
|
timelineGate: document.querySelector("#timeline-gate"),
|
|
20
22
|
timelineSummary: document.querySelector("#timeline-summary"),
|
|
21
23
|
timelineList: document.querySelector("#timeline-list"),
|
|
@@ -23,6 +25,7 @@ const elements = {
|
|
|
23
25
|
inspectorPath: document.querySelector("#inspector-path"),
|
|
24
26
|
inspectorMeta: document.querySelector("#inspector-meta"),
|
|
25
27
|
inspectorEvidence: document.querySelector("#inspector-evidence"),
|
|
28
|
+
handoffActionSummary: document.querySelector("#handoff-action-summary"),
|
|
26
29
|
};
|
|
27
30
|
|
|
28
31
|
function setText(node, value) {
|
|
@@ -133,10 +136,41 @@ function renderApprovalQueue(requests = []) {
|
|
|
133
136
|
button.textContent = "Inspect Artifact";
|
|
134
137
|
button.addEventListener("click", () => inspectApprovalRequest(request.id));
|
|
135
138
|
item.append(title, status, artifact, button);
|
|
139
|
+
if (request.status === "approved_not_executed") {
|
|
140
|
+
const packageButton = document.createElement("button");
|
|
141
|
+
packageButton.type = "button";
|
|
142
|
+
packageButton.textContent = "Prepare Package";
|
|
143
|
+
packageButton.addEventListener("click", () => prepareHandoffPackage(request.id));
|
|
144
|
+
item.append(packageButton);
|
|
145
|
+
}
|
|
136
146
|
elements.approvalList?.append(item);
|
|
137
147
|
}
|
|
138
148
|
}
|
|
139
149
|
|
|
150
|
+
function renderHandoffQueue(packages = []) {
|
|
151
|
+
clearChildren(elements.handoffList);
|
|
152
|
+
setText(elements.handoffSummary, packages.length === 0
|
|
153
|
+
? "No handoff packages yet. Approved requests can be prepared as local review packages."
|
|
154
|
+
: `${packages.length} handoff package${packages.length === 1 ? "" : "s"} ready for human review.`);
|
|
155
|
+
for (const itemData of packages) {
|
|
156
|
+
const item = document.createElement("li");
|
|
157
|
+
item.className = "result-item";
|
|
158
|
+
const title = document.createElement("strong");
|
|
159
|
+
title.textContent = itemData.source_approval_request_id || itemData.id || "Handoff package";
|
|
160
|
+
const status = document.createElement("p");
|
|
161
|
+
status.textContent = `Status: ${itemData.status || "review_required"}. Package: ${itemData.package_intent || "human_handoff"}.`;
|
|
162
|
+
const artifact = document.createElement("p");
|
|
163
|
+
artifact.className = "artifact-path";
|
|
164
|
+
artifact.textContent = itemData.artifact_path || "Artifact path unavailable";
|
|
165
|
+
const button = document.createElement("button");
|
|
166
|
+
button.type = "button";
|
|
167
|
+
button.textContent = "Inspect Package";
|
|
168
|
+
button.addEventListener("click", () => inspectHandoffPackage(itemData.id));
|
|
169
|
+
item.append(title, status, artifact, button);
|
|
170
|
+
elements.handoffList?.append(item);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
140
174
|
function renderTimelineStatus(panel = {}) {
|
|
141
175
|
const status = panel.status_panel || {};
|
|
142
176
|
const timeline = Array.isArray(panel.timeline) ? panel.timeline : [];
|
|
@@ -185,6 +219,34 @@ async function inspectApprovalRequest(id) {
|
|
|
185
219
|
renderInspector(payload.artifact || { status: "degraded", summary: "Artifact could not be inspected." });
|
|
186
220
|
}
|
|
187
221
|
|
|
222
|
+
async function inspectHandoffPackage(id) {
|
|
223
|
+
if (!id) return;
|
|
224
|
+
const query = new URLSearchParams({ type: "approval_handoff", id });
|
|
225
|
+
const response = await fetch(`/api/workspace/artifacts/inspect?${query.toString()}`, {
|
|
226
|
+
headers: { accept: "application/json" },
|
|
227
|
+
});
|
|
228
|
+
const payload = await response.json();
|
|
229
|
+
renderInspector(payload.artifact || { status: "degraded", summary: "Handoff package could not be inspected." });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function prepareHandoffPackage(id) {
|
|
233
|
+
if (!id) return;
|
|
234
|
+
setText(elements.handoffActionSummary, "Preparing a local review-required handoff package...");
|
|
235
|
+
const response = await fetch("/api/workspace/approval-handoffs", {
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
238
|
+
body: JSON.stringify({ source_approval_request_id: id, package_intent: "human_handoff" }),
|
|
239
|
+
});
|
|
240
|
+
const payload = await response.json();
|
|
241
|
+
if (!response.ok || payload.success === false) {
|
|
242
|
+
setText(elements.handoffActionSummary, payload.error?.message || payload.error || "Handoff package needs attention.");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
setText(elements.handoffActionSummary, `Handoff package prepared locally: ${payload.artifactPath}. Not applied.`);
|
|
246
|
+
await Promise.all([loadHome(), loadTimelineStatus()]);
|
|
247
|
+
if (payload.package?.id) await inspectHandoffPackage(payload.package.id);
|
|
248
|
+
}
|
|
249
|
+
|
|
188
250
|
function renderInspector(artifact) {
|
|
189
251
|
clearChildren(elements.inspectorMeta);
|
|
190
252
|
clearChildren(elements.inspectorEvidence);
|
|
@@ -197,6 +259,9 @@ function renderInspector(artifact) {
|
|
|
197
259
|
appendListItem(elements.inspectorMeta, "Local Only", flags.local_only === false ? "No" : "Yes");
|
|
198
260
|
appendListItem(elements.inspectorMeta, "Live Work", flags.executed === true ? "Yes" : "No");
|
|
199
261
|
for (const evidence of artifact.evidence || []) appendListItem(elements.inspectorEvidence, "Evidence", evidence);
|
|
262
|
+
if (artifact.type === "approval_handoff") {
|
|
263
|
+
setText(elements.handoffActionSummary, "Selected handoff package is local, review-required, and not applied.");
|
|
264
|
+
}
|
|
200
265
|
}
|
|
201
266
|
|
|
202
267
|
async function loadHome() {
|
|
@@ -214,6 +279,7 @@ async function loadHome() {
|
|
|
214
279
|
setText(elements.healthSummary, health);
|
|
215
280
|
setText(elements.decisionSummary, formatDecisionSummary(queue.side_review_decision_recent));
|
|
216
281
|
renderApprovalQueue(queue.approval_request_recent || queue.approval_queue?.recent || []);
|
|
282
|
+
renderHandoffQueue(queue.approval_handoff_recent || queue.approval_handoff_queue?.recent || []);
|
|
217
283
|
} catch {
|
|
218
284
|
setText(elements.wikiCount, "Unavailable");
|
|
219
285
|
setText(elements.reviewCount, "Check local server");
|
|
@@ -221,6 +287,7 @@ async function loadHome() {
|
|
|
221
287
|
setText(elements.healthSummary, "Home data is unavailable. Nothing was applied.");
|
|
222
288
|
setText(elements.decisionSummary, "Prepared decisions are unavailable.");
|
|
223
289
|
renderApprovalQueue([]);
|
|
290
|
+
renderHandoffQueue([]);
|
|
224
291
|
} finally {
|
|
225
292
|
setLoadingHome(false);
|
|
226
293
|
}
|
|
@@ -85,6 +85,13 @@
|
|
|
85
85
|
<ol id="approval-list" class="result-list"></ol>
|
|
86
86
|
</section>
|
|
87
87
|
|
|
88
|
+
<section class="result-panel" aria-live="polite" aria-label="Handoff Queue">
|
|
89
|
+
<p class="eyebrow">Review package area</p>
|
|
90
|
+
<h2>Handoff Queue</h2>
|
|
91
|
+
<p id="handoff-summary" class="muted">Loading handoff packages...</p>
|
|
92
|
+
<ol id="handoff-list" class="result-list"></ol>
|
|
93
|
+
</section>
|
|
94
|
+
|
|
88
95
|
<section class="result-panel" aria-live="polite" aria-label="Lifecycle Timeline">
|
|
89
96
|
<p class="eyebrow">Local status panel</p>
|
|
90
97
|
<h2>Lifecycle Timeline</h2>
|
|
@@ -101,6 +108,7 @@
|
|
|
101
108
|
<p id="inspector-path" class="artifact-path"></p>
|
|
102
109
|
<ul id="inspector-meta" class="inspector-list"></ul>
|
|
103
110
|
<ul id="inspector-evidence" class="inspector-list"></ul>
|
|
111
|
+
<p id="handoff-action-summary" class="muted">Handoff packages are review-required and not applied.</p>
|
|
104
112
|
<p class="muted">Inspector is local-first, read-only, and review-required.</p>
|
|
105
113
|
</aside>
|
|
106
114
|
|
package/src/wiki/save-routing.js
CHANGED
|
@@ -36,7 +36,8 @@ const RECORD_PATTERNS = [
|
|
|
36
36
|
|
|
37
37
|
export const PROJECT_KNOWLEDGE_FOLDERS = {
|
|
38
38
|
DASHBOARD: "!대시보드_dashboard",
|
|
39
|
-
|
|
39
|
+
OMW_HANDOFF: "0_omw-handoff_omw-handoff",
|
|
40
|
+
OMX_HANDOFF: "0_omw-handoff_omw-handoff",
|
|
40
41
|
DEV_HISTORY: "개발-진행-기록_dev-history",
|
|
41
42
|
IMPLEMENTATION_REPORTS: "개발-진행-기록_dev-history/구현-보고_implementation-reports",
|
|
42
43
|
VERIFICATION_LOGS: "개발-진행-기록_dev-history/검증-로그_verification-logs",
|
|
@@ -85,8 +86,8 @@ const PROJECT_PLACEMENT_RULES = [
|
|
|
85
86
|
},
|
|
86
87
|
{
|
|
87
88
|
kind: "handoff",
|
|
88
|
-
folder: PROJECT_KNOWLEDGE_FOLDERS.
|
|
89
|
-
patterns: [/\bhandoff\b/i, /\bultragoal\b/i, /\bomx\b/i, /핸드오프/u, /울트라골/u],
|
|
89
|
+
folder: PROJECT_KNOWLEDGE_FOLDERS.OMW_HANDOFF,
|
|
90
|
+
patterns: [/\bhandoff\b/i, /\bultragoal\b/i, /\bomw\b/i, /\bomx\b/i, /핸드오프/u, /울트라골/u],
|
|
90
91
|
},
|
|
91
92
|
{
|
|
92
93
|
kind: "implementation-report",
|