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.
@@ -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 핸드오프` | `0_omx-핸드오프_omx-handoff` | OMX / Codex handoff plans and run instructions |
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.6.0",
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",
@@ -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
 
@@ -36,7 +36,8 @@ const RECORD_PATTERNS = [
36
36
 
37
37
  export const PROJECT_KNOWLEDGE_FOLDERS = {
38
38
  DASHBOARD: "!대시보드_dashboard",
39
- OMX_HANDOFF: "0_omx-핸드오프_omx-handoff",
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.OMX_HANDOFF,
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",