@shivaedev/work-board 0.9.0 → 0.10.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/CHANGELOG.md +25 -0
- package/README.md +31 -1
- package/assets/native.js +35 -29
- package/dist/board.d.ts.map +1 -1
- package/dist/board.js +14 -1
- package/dist/board.js.map +1 -1
- package/dist/browser/client.d.ts +2 -0
- package/dist/browser/client.d.ts.map +1 -1
- package/dist/browser/client.js +2 -1
- package/dist/browser/client.js.map +1 -1
- package/dist/browser/entry.d.ts.map +1 -1
- package/dist/browser/entry.js +2 -0
- package/dist/browser/entry.js.map +1 -1
- package/dist/browser/handoffs/copy.d.ts +3 -0
- package/dist/browser/handoffs/copy.d.ts.map +1 -0
- package/dist/browser/handoffs/copy.js +47 -0
- package/dist/browser/handoffs/copy.js.map +1 -0
- package/dist/browser/handoffs/fields.d.ts +11 -0
- package/dist/browser/handoffs/fields.d.ts.map +1 -0
- package/dist/browser/handoffs/fields.js +45 -0
- package/dist/browser/handoffs/fields.js.map +1 -0
- package/dist/browser/handoffs/schema.d.ts +89 -0
- package/dist/browser/handoffs/schema.d.ts.map +1 -0
- package/dist/browser/handoffs/schema.js +38 -0
- package/dist/browser/handoffs/schema.js.map +1 -0
- package/dist/browser/handoffs/setup.d.ts +2 -0
- package/dist/browser/handoffs/setup.d.ts.map +1 -0
- package/dist/browser/handoffs/setup.js +108 -0
- package/dist/browser/handoffs/setup.js.map +1 -0
- package/dist/browser/responses/draftSession.d.ts +1 -1
- package/dist/browser/responses/draftSession.d.ts.map +1 -1
- package/dist/browser/responses/draftSession.js +4 -4
- package/dist/browser/responses/draftSession.js.map +1 -1
- package/dist/browser/responses/preview.js.map +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/files/list.d.ts.map +1 -1
- package/dist/handoffs/html.d.ts +37 -0
- package/dist/handoffs/html.d.ts.map +1 -0
- package/dist/handoffs/html.js +31 -0
- package/dist/handoffs/html.js.map +1 -0
- package/dist/handoffs/page.d.ts +7 -0
- package/dist/handoffs/page.d.ts.map +1 -0
- package/dist/handoffs/page.js +31 -0
- package/dist/handoffs/page.js.map +1 -0
- package/dist/handoffs/records.d.ts +7 -0
- package/dist/handoffs/records.d.ts.map +1 -0
- package/dist/handoffs/records.js +30 -0
- package/dist/handoffs/records.js.map +1 -0
- package/dist/handoffs/service.d.ts +55 -0
- package/dist/handoffs/service.d.ts.map +1 -0
- package/dist/handoffs/service.js +82 -0
- package/dist/handoffs/service.js.map +1 -0
- package/dist/handoffs/source.d.ts +11 -0
- package/dist/handoffs/source.d.ts.map +1 -0
- package/dist/handoffs/source.js +31 -0
- package/dist/handoffs/source.js.map +1 -0
- package/dist/http/assets.d.ts.map +1 -1
- package/dist/http/assets.js.map +1 -1
- package/dist/http/events.d.ts.map +1 -1
- package/dist/http/loopback.d.ts.map +1 -1
- package/dist/http/page.d.ts.map +1 -1
- package/dist/http/respond.d.ts.map +1 -1
- package/dist/metadata/backlinks.d.ts.map +1 -1
- package/dist/metadata/backlinks.js +1 -0
- package/dist/metadata/backlinks.js.map +1 -1
- package/dist/metadata/fields.d.ts +14 -1
- package/dist/metadata/fields.d.ts.map +1 -1
- package/dist/metadata/model.js.map +1 -1
- package/dist/metadata/problems.d.ts.map +1 -1
- package/dist/metadata/problems.js +1 -0
- package/dist/metadata/problems.js.map +1 -1
- package/dist/metadata/schema.d.ts +18 -1
- package/dist/metadata/schema.d.ts.map +1 -1
- package/dist/metadata/schema.js +3 -1
- package/dist/metadata/schema.js.map +1 -1
- package/dist/page/escape.d.ts.map +1 -1
- package/dist/page/metadata.d.ts.map +1 -1
- package/dist/page/metadata.js +6 -1
- package/dist/page/metadata.js.map +1 -1
- package/dist/page/metadataDetails.d.ts.map +1 -1
- package/dist/page/metadataDetails.js +3 -0
- package/dist/page/metadataDetails.js.map +1 -1
- package/dist/page/nav.d.ts.map +1 -1
- package/dist/page/navigationScript.js +1 -1
- package/dist/page/shell.d.ts +1 -1
- package/dist/page/shell.d.ts.map +1 -1
- package/dist/page/shell.js.map +1 -1
- package/dist/page/startTemplates.d.ts +89 -3
- package/dist/page/startTemplates.d.ts.map +1 -1
- package/dist/page/swap.d.ts +1 -1
- package/dist/page/swap.d.ts.map +1 -1
- package/dist/page/swap.js +2 -2
- package/dist/page/updated.d.ts.map +1 -1
- package/dist/render/board.d.ts.map +1 -1
- package/dist/render/callouts.d.ts.map +1 -1
- package/dist/render/code.d.ts.map +1 -1
- package/dist/render/documentHeadings.d.ts.map +1 -1
- package/dist/render/documentLinks.d.ts.map +1 -1
- package/dist/render/footnotes.d.ts.map +1 -1
- package/dist/render/highlighter.d.ts.map +1 -1
- package/dist/render/highlighter.js.map +1 -1
- package/dist/render/markdown.js.map +1 -1
- package/dist/render/questionnaireDocuments.d.ts.map +1 -1
- package/dist/render/visualDocuments.d.ts.map +1 -1
- package/dist/responses/agent.d.ts +8 -8
- package/dist/responses/agent.js.map +1 -1
- package/dist/responses/flags.d.ts.map +1 -1
- package/dist/responses/outcome.d.ts.map +1 -1
- package/dist/responses/publish.d.ts +1 -1
- package/dist/responses/publish.d.ts.map +1 -1
- package/dist/responses/publish.js +7 -7
- package/dist/responses/publish.js.map +1 -1
- package/dist/responses/publishIn.d.ts +1 -1
- package/dist/responses/publishIn.d.ts.map +1 -1
- package/dist/responses/publishIn.js +3 -3
- package/dist/responses/publishIn.js.map +1 -1
- package/dist/responses/records.d.ts.map +1 -1
- package/dist/responses/records.js +2 -1
- package/dist/responses/records.js.map +1 -1
- package/dist/responses/waitFor.d.ts.map +1 -1
- package/dist/rpc/contract.d.ts +67 -1
- package/dist/rpc/contract.d.ts.map +1 -1
- package/dist/rpc/contract.js +3 -2
- package/dist/rpc/contract.js.map +1 -1
- package/dist/rpc/handoffContract.d.ts +142 -0
- package/dist/rpc/handoffContract.d.ts.map +1 -0
- package/dist/rpc/handoffContract.js +26 -0
- package/dist/rpc/handoffContract.js.map +1 -0
- package/dist/rpc/server.d.ts +2 -0
- package/dist/rpc/server.d.ts.map +1 -1
- package/dist/rpc/server.js +4 -1
- package/dist/rpc/server.js.map +1 -1
- package/dist/search/collector.d.ts.map +1 -1
- package/dist/search/entries.js.map +1 -1
- package/dist/search/linkCollector.d.ts.map +1 -1
- package/dist/serve-cli.js +2 -2
- package/dist/serve-cli.js.map +1 -1
- package/dist/serve.d.ts.map +1 -1
- package/dist/views/state.js.map +1 -1
- package/package.json +3 -3
- package/src/board.ts +14 -1
- package/src/browser/client.ts +3 -1
- package/src/browser/entry.ts +2 -0
- package/src/browser/handoffs/copy.ts +45 -0
- package/src/browser/handoffs/fields.ts +50 -0
- package/src/browser/handoffs/schema.ts +44 -0
- package/src/browser/handoffs/setup.ts +113 -0
- package/src/browser/responses/draftSession.ts +11 -4
- package/src/handoffs/html.ts +42 -0
- package/src/handoffs/page.ts +45 -0
- package/src/handoffs/records.ts +34 -0
- package/src/handoffs/service.ts +103 -0
- package/src/handoffs/source.ts +35 -0
- package/src/metadata/backlinks.ts +1 -0
- package/src/metadata/problems.ts +1 -0
- package/src/metadata/schema.ts +3 -1
- package/src/page/metadata.ts +7 -1
- package/src/page/metadataDetails.ts +3 -0
- package/src/page/navigationScript.ts +1 -1
- package/src/page/shell.ts +1 -1
- package/src/page/swap.ts +2 -2
- package/src/responses/publish.ts +7 -7
- package/src/responses/publishIn.ts +10 -3
- package/src/responses/records.ts +4 -1
- package/src/rpc/contract.ts +3 -2
- package/src/rpc/handoffContract.ts +26 -0
- package/src/rpc/server.ts +6 -1
- package/src/serve-cli.ts +2 -2
package/src/browser/client.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { Contract } from "@shivaedev/effect-contract/contract.ts";
|
|
|
12
12
|
import { live } from "@shivaedev/effect-contract/live.ts";
|
|
13
13
|
import { type ResumeOptions, resumeSignal } from "@shivaedev/effect-contract/resume.ts";
|
|
14
14
|
import { type ReadFailed, workContract, workRpcs } from "#rpc/contract.ts";
|
|
15
|
+
import { handoffContract } from "#rpc/handoffContract.ts";
|
|
15
16
|
import { workspace } from "#rpc/keys.ts";
|
|
16
17
|
import { responseContract } from "#rpc/responseContract.ts";
|
|
17
18
|
|
|
@@ -23,6 +24,7 @@ type BoundContract<TContract> =
|
|
|
23
24
|
TContract extends Contract<infer TName, infer TQueries, infer TCommands, infer TRpcs> ? Bound<TName, TQueries, TCommands, TRpcs, Reader> : never;
|
|
24
25
|
export interface BrowserClient {
|
|
25
26
|
readonly api: BoundContract<typeof workContract>;
|
|
27
|
+
readonly handoffs: BoundContract<typeof handoffContract>;
|
|
26
28
|
readonly mutate: <A, E>(effect: Effect.Effect<A, E, Reader | Reactivity.Reactivity>, signal?: AbortSignal) => Promise<A>;
|
|
27
29
|
readonly read: <A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>, signal?: AbortSignal) => Promise<A>;
|
|
28
30
|
readonly registry: AtomRegistry.AtomRegistry;
|
|
@@ -81,5 +83,5 @@ export function browserClient(url: string, options: ResumeOptions = {}): Browser
|
|
|
81
83
|
return result.success;
|
|
82
84
|
}
|
|
83
85
|
const responses = bind(responseContract, Client);
|
|
84
|
-
return { api, mutate, read, registry, responses, run, updates };
|
|
86
|
+
return { api, handoffs: bind(handoffContract, Client), mutate, read, registry, responses, run, updates };
|
|
85
87
|
}
|
package/src/browser/entry.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { setupHandoffs } from "#browser/handoffs/setup.ts";
|
|
1
2
|
import { setupResponses } from "#browser/responses/setup.ts";
|
|
2
3
|
|
|
3
4
|
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
4
5
|
setupResponses();
|
|
6
|
+
setupHandoffs();
|
|
5
7
|
}
|
|
6
8
|
|
|
7
9
|
import { session as ownedSession } from "./session.ts";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const bound = new WeakSet<HTMLButtonElement>();
|
|
2
|
+
export function copyPrompts(root: ParentNode) {
|
|
3
|
+
for (const button of root.querySelectorAll<HTMLButtonElement>("[data-handoff-copy]")) {
|
|
4
|
+
if (bound.has(button)) {
|
|
5
|
+
continue;
|
|
6
|
+
}
|
|
7
|
+
bound.add(button);
|
|
8
|
+
button.addEventListener("click", async () => {
|
|
9
|
+
const field = document.getElementById(button.dataset.handoffCopy ?? "");
|
|
10
|
+
const status = button.parentElement?.querySelector("[data-copy-status]");
|
|
11
|
+
if (!(field instanceof HTMLTextAreaElement && status)) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
await navigator.clipboard.writeText(field.value);
|
|
16
|
+
status.textContent = "Prompt copied. Paste it into your existing agent session.";
|
|
17
|
+
} catch {
|
|
18
|
+
field.focus();
|
|
19
|
+
field.select();
|
|
20
|
+
status.textContent = "Clipboard unavailable. The prompt is selected; copy it manually.";
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function preparedPrompt(container: HTMLElement, id: string, prompt: string) {
|
|
26
|
+
const label = document.createElement("label");
|
|
27
|
+
label.textContent = "Prompt for your existing agent session";
|
|
28
|
+
const field = document.createElement("textarea");
|
|
29
|
+
field.id = `handoff-prompt-${id}`;
|
|
30
|
+
field.readOnly = true;
|
|
31
|
+
field.rows = 3;
|
|
32
|
+
field.value = prompt;
|
|
33
|
+
label.append(field);
|
|
34
|
+
const button = document.createElement("button");
|
|
35
|
+
button.type = "button";
|
|
36
|
+
button.dataset.handoffCopy = field.id;
|
|
37
|
+
button.textContent = "Copy tiny prompt";
|
|
38
|
+
const status = document.createElement("p");
|
|
39
|
+
status.dataset.copyStatus = "";
|
|
40
|
+
status.setAttribute("role", "status");
|
|
41
|
+
status.textContent = "Prepared; receipt not confirmed. Copy the prompt to your agent.";
|
|
42
|
+
container.replaceChildren(label, button, status);
|
|
43
|
+
container.hidden = false;
|
|
44
|
+
copyPrompts(container);
|
|
45
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Option, Schema } from "effect";
|
|
2
|
+
import type { HandoffInput } from "#browser/handoffs/schema.ts";
|
|
3
|
+
import type { Draft } from "#browser/responses/drafts.ts";
|
|
4
|
+
|
|
5
|
+
const TextFields = Schema.Struct({ constraints: Schema.String, goal: Schema.String, nextAction: Schema.String });
|
|
6
|
+
export function handoffFields(form: HTMLFormElement) {
|
|
7
|
+
const recipient = form.elements.namedItem("recipient");
|
|
8
|
+
const goal = form.elements.namedItem("goal");
|
|
9
|
+
const constraints = form.elements.namedItem("constraints");
|
|
10
|
+
const nextAction = form.elements.namedItem("nextAction");
|
|
11
|
+
if (
|
|
12
|
+
!(
|
|
13
|
+
recipient instanceof HTMLInputElement
|
|
14
|
+
&& goal instanceof HTMLTextAreaElement
|
|
15
|
+
&& constraints instanceof HTMLTextAreaElement
|
|
16
|
+
&& nextAction instanceof HTMLTextAreaElement
|
|
17
|
+
)
|
|
18
|
+
) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
return { constraints, goal, nextAction, recipient };
|
|
22
|
+
}
|
|
23
|
+
export function restoreFields(fields: NonNullable<ReturnType<typeof handoffFields>>, draft: Draft) {
|
|
24
|
+
if (!draft.body) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const saved = Schema.decodeUnknownOption(TextFields)(JSON.parse(draft.body));
|
|
29
|
+
if (Option.isSome(saved)) {
|
|
30
|
+
fields.constraints.value = saved.value.constraints;
|
|
31
|
+
fields.goal.value = saved.value.goal;
|
|
32
|
+
fields.nextAction.value = saved.value.nextAction;
|
|
33
|
+
fields.recipient.value = draft.author;
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
fields.goal.value = draft.body;
|
|
37
|
+
fields.recipient.value = draft.author;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function inputFrom(form: HTMLFormElement, draft: Draft): HandoffInput {
|
|
41
|
+
const fields = Schema.decodeUnknownSync(TextFields)(JSON.parse(draft.body));
|
|
42
|
+
return {
|
|
43
|
+
...fields,
|
|
44
|
+
id: draft.id,
|
|
45
|
+
item: form.dataset.item ?? "",
|
|
46
|
+
recipient: draft.author,
|
|
47
|
+
revision: draft.revision,
|
|
48
|
+
source: form.dataset.source ?? "",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { Revision } from "#browser/responses/schema.ts";
|
|
3
|
+
import { Identity } from "#path/Identity.ts";
|
|
4
|
+
|
|
5
|
+
const Text = Schema.String.check(Schema.isPattern(/\S/u), Schema.isMaxLength(8192));
|
|
6
|
+
const HandoffFields = Schema.Struct({
|
|
7
|
+
by: Schema.optional(Text),
|
|
8
|
+
constraints: Schema.String.check(Schema.isMaxLength(8192)),
|
|
9
|
+
goal: Text,
|
|
10
|
+
item: Identity,
|
|
11
|
+
nextAction: Text,
|
|
12
|
+
note: Schema.optional(Text),
|
|
13
|
+
preparedAt: Schema.Number.check(Schema.isFinite(), Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(8_640_000_000_000_000)),
|
|
14
|
+
recipient: Text.check(Schema.isMaxLength(200)),
|
|
15
|
+
reviewedRevision: Revision,
|
|
16
|
+
source: Schema.String,
|
|
17
|
+
state: Schema.Literals(["requested", "acknowledged", "rejected", "unavailable"]),
|
|
18
|
+
});
|
|
19
|
+
export const HandoffRecord = HandoffFields.pipe(
|
|
20
|
+
Schema.encodeKeys({ nextAction: "next_action", preparedAt: "prepared_at", reviewedRevision: "reviewed_revision" }),
|
|
21
|
+
);
|
|
22
|
+
export type HandoffRecord = typeof HandoffRecord.Type;
|
|
23
|
+
export const HandoffInput = Schema.Struct({
|
|
24
|
+
constraints: HandoffFields.fields.constraints,
|
|
25
|
+
goal: HandoffFields.fields.goal,
|
|
26
|
+
id: Identity,
|
|
27
|
+
item: Identity,
|
|
28
|
+
nextAction: HandoffFields.fields.nextAction,
|
|
29
|
+
recipient: HandoffFields.fields.recipient,
|
|
30
|
+
revision: Revision,
|
|
31
|
+
source: Schema.String,
|
|
32
|
+
});
|
|
33
|
+
export type HandoffInput = typeof HandoffInput.Type;
|
|
34
|
+
export const Handoff = Schema.Struct({ context: Schema.String, file: Schema.String, handoff: HandoffRecord, id: Identity, prompt: Schema.String });
|
|
35
|
+
export type Handoff = typeof Handoff.Type;
|
|
36
|
+
export const HandoffReading = Schema.Struct({ records: Schema.Array(Handoff), unknown: Schema.Array(Schema.String) });
|
|
37
|
+
export const HandoffPreview = Schema.Struct({
|
|
38
|
+
context: Schema.String,
|
|
39
|
+
id: Identity,
|
|
40
|
+
item: Identity,
|
|
41
|
+
reviewedRevision: Revision,
|
|
42
|
+
source: Schema.String,
|
|
43
|
+
});
|
|
44
|
+
export type HandoffPreview = typeof HandoffPreview.Type;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { copyPrompts, preparedPrompt } from "#browser/handoffs/copy.ts";
|
|
2
|
+
import { handoffFields, inputFrom, restoreFields } from "#browser/handoffs/fields.ts";
|
|
3
|
+
import { draftSession } from "#browser/responses/draftSession.ts";
|
|
4
|
+
import { ResponseFailed } from "#browser/responses/schema.ts";
|
|
5
|
+
import { session } from "#browser/session.ts";
|
|
6
|
+
|
|
7
|
+
const bound = new WeakSet<HTMLFormElement>();
|
|
8
|
+
function attach() {
|
|
9
|
+
const doc = document.getElementById("doc");
|
|
10
|
+
if (doc?.dataset.view !== "handoff") {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
copyPrompts(doc);
|
|
14
|
+
const form = doc.querySelector<HTMLFormElement>("#handoff-form");
|
|
15
|
+
if (!form || bound.has(form)) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const found = handoffFields(form);
|
|
19
|
+
const status = form.querySelector<HTMLParagraphElement>("#handoff-status");
|
|
20
|
+
const preview = form.querySelector<HTMLPreElement>("#handoff-preview-content");
|
|
21
|
+
const previewButton = form.querySelector<HTMLButtonElement>("#handoff-preview");
|
|
22
|
+
const submit = form.querySelector<HTMLButtonElement>("#handoff-submit");
|
|
23
|
+
const clear = form.querySelector<HTMLButtonElement>("#handoff-clear");
|
|
24
|
+
const prepared = form.querySelector<HTMLElement>("#handoff-prepared");
|
|
25
|
+
if (!(found && status && preview && previewButton && submit && clear && prepared)) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const fields = found;
|
|
29
|
+
bound.add(form);
|
|
30
|
+
form.dataset.bound = "true";
|
|
31
|
+
const revision = form.dataset.revision ?? "";
|
|
32
|
+
const question = form.dataset.question ?? "";
|
|
33
|
+
let storage: Storage | undefined;
|
|
34
|
+
try {
|
|
35
|
+
storage = window.localStorage;
|
|
36
|
+
} catch {
|
|
37
|
+
storage = undefined;
|
|
38
|
+
}
|
|
39
|
+
if (status.textContent?.includes("JavaScript is required")) {
|
|
40
|
+
status.textContent = "Draft ready. Review and preview before preparing the file.";
|
|
41
|
+
}
|
|
42
|
+
const drafts = draftSession(
|
|
43
|
+
`handoff:${form.dataset.item}`,
|
|
44
|
+
revision,
|
|
45
|
+
storage,
|
|
46
|
+
`work-board:drafts:${document.documentElement.dataset.workspace}`,
|
|
47
|
+
(message) => {
|
|
48
|
+
status.textContent = message;
|
|
49
|
+
},
|
|
50
|
+
"handoff",
|
|
51
|
+
);
|
|
52
|
+
let draft = drafts.current();
|
|
53
|
+
restoreFields(fields, draft);
|
|
54
|
+
function save() {
|
|
55
|
+
draft = drafts.update({
|
|
56
|
+
author: fields.recipient.value,
|
|
57
|
+
body: JSON.stringify({ constraints: fields.constraints.value, goal: fields.goal.value, nextAction: fields.nextAction.value }),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
form.addEventListener("input", () => {
|
|
61
|
+
save();
|
|
62
|
+
preview.hidden = true;
|
|
63
|
+
submit.disabled = true;
|
|
64
|
+
});
|
|
65
|
+
previewButton.disabled = false;
|
|
66
|
+
clear.disabled = false;
|
|
67
|
+
clear.addEventListener("click", drafts.clear);
|
|
68
|
+
previewButton.addEventListener("click", () => {
|
|
69
|
+
save();
|
|
70
|
+
draft = drafts.update({
|
|
71
|
+
id: draft.revision === revision && (!draft.question || draft.question === question) ? draft.id : `handoff.${crypto.randomUUID()}`,
|
|
72
|
+
question,
|
|
73
|
+
revision,
|
|
74
|
+
});
|
|
75
|
+
const input = inputFrom(form, draft);
|
|
76
|
+
preview.textContent = `New file: handoffs/${input.id}.md\nPrepared time and reviewed snapshot are assigned by the server.\n\n---\n${JSON.stringify({ handoff: { constraints: input.constraints, goal: input.goal, item: input.item, "next_action": input.nextAction, recipient: input.recipient, "reviewed_revision": input.revision, source: input.source, state: "requested" }, id: input.id, kind: "handoff" }, null, 2)}\n---\nThe complete reviewed source is preserved below the frontmatter.`;
|
|
77
|
+
preview.hidden = false;
|
|
78
|
+
submit.disabled = form.dataset.writable !== "true" || !form.checkValidity();
|
|
79
|
+
});
|
|
80
|
+
form.addEventListener("submit", async (event) => {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
if (submit.disabled || !form.reportValidity()) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const pending = draft;
|
|
86
|
+
submit.disabled = true;
|
|
87
|
+
previewButton.disabled = true;
|
|
88
|
+
status.textContent = "Pending: preparing the local handoff file…";
|
|
89
|
+
try {
|
|
90
|
+
const native = session(window);
|
|
91
|
+
const saved = await native.mutate(native.handoffs.prepareHandoff.run(inputFrom(form, pending)));
|
|
92
|
+
const cleared = drafts.saved(pending);
|
|
93
|
+
draft = drafts.current();
|
|
94
|
+
if (form.isConnected) {
|
|
95
|
+
status.textContent = `Prepared: ${saved.file}. Receipt: ${saved.handoff.state}; execution and acceptance remain separate.${cleared ? "" : " Your newer draft remains unsaved."}`;
|
|
96
|
+
preparedPrompt(prepared, saved.id, saved.prompt);
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (form.isConnected) {
|
|
100
|
+
status.textContent = failureStatus(error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
previewButton.disabled = false;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function failureStatus(error: unknown) {
|
|
107
|
+
return `${error instanceof ResponseFailed && error.code !== "Uncertain" ? "Rejected" : "Uncertain"}: ${String(error)}. Draft retained; preview and retry.`;
|
|
108
|
+
}
|
|
109
|
+
export function setupHandoffs() {
|
|
110
|
+
document.addEventListener("board-page", attach);
|
|
111
|
+
window.addEventListener("pageshow", attach);
|
|
112
|
+
attach();
|
|
113
|
+
}
|
|
@@ -2,7 +2,14 @@ import { Clock, Effect } from "effect";
|
|
|
2
2
|
import { DRAFT_AGE, type Draft, encodeDrafts, readDrafts } from "./drafts.ts";
|
|
3
3
|
|
|
4
4
|
const states = new Map<string, Draft>();
|
|
5
|
-
export function draftSession(
|
|
5
|
+
export function draftSession(
|
|
6
|
+
key: string,
|
|
7
|
+
revision: string,
|
|
8
|
+
storage: Storage | undefined,
|
|
9
|
+
storageKey: string,
|
|
10
|
+
report: (message: string) => void,
|
|
11
|
+
prefix = "response",
|
|
12
|
+
) {
|
|
6
13
|
const stateKey = `${JSON.stringify(storageKey)}:${key}`;
|
|
7
14
|
function local() {
|
|
8
15
|
if (!storage) {
|
|
@@ -32,7 +39,7 @@ export function draftSession(key: string, revision: string, storage: Storage | u
|
|
|
32
39
|
?? drafts[key] ?? {
|
|
33
40
|
author: "",
|
|
34
41
|
body: "",
|
|
35
|
-
id:
|
|
42
|
+
id: `${prefix}.${crypto.randomUUID()}`,
|
|
36
43
|
revision,
|
|
37
44
|
type: "answer",
|
|
38
45
|
updatedAt: Effect.runSync(Clock.currentTimeMillis),
|
|
@@ -72,7 +79,7 @@ export function draftSession(key: string, revision: string, storage: Storage | u
|
|
|
72
79
|
if (states.get(stateKey) !== pending) {
|
|
73
80
|
const live = states.get(stateKey);
|
|
74
81
|
if (live?.id === pending.id) {
|
|
75
|
-
update({ id:
|
|
82
|
+
update({ id: `${prefix}.${crypto.randomUUID()}` });
|
|
76
83
|
}
|
|
77
84
|
return false;
|
|
78
85
|
}
|
|
@@ -90,7 +97,7 @@ export function draftSession(key: string, revision: string, storage: Storage | u
|
|
|
90
97
|
...current,
|
|
91
98
|
answers: undefined,
|
|
92
99
|
body: "",
|
|
93
|
-
id:
|
|
100
|
+
id: `${prefix}.${crypto.randomUUID()}`,
|
|
94
101
|
recovery: undefined,
|
|
95
102
|
supersedes: undefined,
|
|
96
103
|
updatedAt: Effect.runSync(Clock.currentTimeMillis),
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { Handoff, HandoffPreview } from "#browser/handoffs/schema.ts";
|
|
3
|
+
import { fileUrl } from "#files/url.ts";
|
|
4
|
+
import { metadataParse } from "#metadata/parse.ts";
|
|
5
|
+
import { escapeHtml } from "#page/escape.ts";
|
|
6
|
+
import type { RenderFailed } from "#render/failed.ts";
|
|
7
|
+
import type { Highlighter } from "#render/highlighter.ts";
|
|
8
|
+
import { renderMarkdown } from "#render/markdown.ts";
|
|
9
|
+
|
|
10
|
+
export function promptHtml(id: string, prompt: string) {
|
|
11
|
+
return `<div class="handoff-copy"><label>Prompt for your existing agent session<textarea id="handoff-prompt-${escapeHtml(id)}" readonly rows="3">${escapeHtml(prompt)}</textarea></label><button type="button" data-handoff-copy="handoff-prompt-${escapeHtml(id)}">Copy tiny prompt</button><p role="status" data-copy-status>Copy and paste this into your agent session. Preparing a file does not notify an agent.</p></div>`;
|
|
12
|
+
}
|
|
13
|
+
function receiptNote(h: Handoff["handoff"]) {
|
|
14
|
+
if (!(h.by || h.note)) {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
return `<p>${escapeHtml(h.by ?? "Attribution not recorded")}${h.note ? ` · ${escapeHtml(h.note)}` : ""}</p>`;
|
|
18
|
+
}
|
|
19
|
+
export const handoffHistoryHtml = Effect.fn("WorkBoard.handoffHistoryHtml")(function* (
|
|
20
|
+
records: readonly Handoff[],
|
|
21
|
+
current?: HandoffPreview,
|
|
22
|
+
): Effect.fn.Return<string[], RenderFailed, Highlighter> {
|
|
23
|
+
return yield* Effect.forEach(records, (record) =>
|
|
24
|
+
Effect.gen(function* () {
|
|
25
|
+
const h = record.handoff;
|
|
26
|
+
const goal = yield* renderMarkdown(h.goal, { file: h.source, safe: true });
|
|
27
|
+
const constraints = yield* renderMarkdown(h.constraints, { file: h.source, safe: true });
|
|
28
|
+
const next = yield* renderMarkdown(h.nextAction, { file: h.source, safe: true });
|
|
29
|
+
const same = current?.reviewedRevision === h.reviewedRevision && current.source === h.source;
|
|
30
|
+
return `<article id="${escapeHtml(record.id)}"><h3><a href="${fileUrl(record.file)}">${escapeHtml(record.id)}</a> · ${escapeHtml(h.recipient)}</h3><p><b>Receipt: ${escapeHtml(h.state)}</b>${h.state === "requested" ? " — prepared; receipt not confirmed" : ""}. Execution and acceptance are not recorded by this state.</p><p>${same ? "Current reviewed source" : "Earlier or unavailable reviewed source; recheck before acting"} · <a href="${fileUrl(h.source)}">${escapeHtml(h.source)}</a> · <code>${h.reviewedRevision}</code></p>${receiptNote(h)}<h4>Goal</h4>${goal}<h4>Constraints</h4>${constraints || "<p>None recorded.</p>"}<h4>Next action</h4>${next}${promptHtml(record.id, record.prompt)}<details><summary>Exact reviewed source snapshot</summary><pre>${escapeHtml(record.context)}</pre></details></article>`;
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
export const handoffFormHtml = Effect.fn("WorkBoard.handoffFormHtml")(function* (
|
|
35
|
+
source: HandoffPreview,
|
|
36
|
+
enabled: boolean,
|
|
37
|
+
): Effect.fn.Return<string, RenderFailed, Highlighter> {
|
|
38
|
+
const parsed = metadataParse(source.context);
|
|
39
|
+
const background = yield* renderMarkdown(parsed.body, { file: source.source, safe: true });
|
|
40
|
+
const goal = /^#\s+(?<title>.+)$/mu.exec(parsed.body)?.groups?.title ?? source.item;
|
|
41
|
+
return `<section class="response-context">${background}</section><details><summary>Exact source being reviewed</summary><pre>${escapeHtml(source.context)}</pre></details><form id="handoff-form" data-item="${escapeHtml(source.item)}" data-source="${escapeHtml(source.source)}" data-revision="${source.reviewedRevision}" data-question="${source.id}" data-writable="${enabled}"><label>Recipient — local agent label<input name="recipient" required maxlength="200"></label><label>Goal<textarea name="goal" required maxlength="8192" rows="3">${escapeHtml(goal)}</textarea></label><label>Constraints (optional)<textarea name="constraints" maxlength="8192" rows="4"></textarea></label><label>Next action<textarea name="nextAction" required maxlength="8192" rows="3">${escapeHtml(parsed.fields.nextAction ?? "")}</textarea></label><p>The file includes the full reviewed source and its declared acceptance criteria. Preparing it does not change the item or start an agent. Drafts use the existing 30-day/2-MiB workspace store.</p><button id="handoff-preview" type="button" disabled>Preview Markdown</button><pre id="handoff-preview-content" hidden></pre><button id="handoff-submit" type="submit" disabled>Prepare handoff</button><button id="handoff-clear" type="button" disabled>Clear workspace drafts</button><p id="handoff-status" role="status">${enabled ? "JavaScript is required to prepare a handoff." : "Read-only mode. Restart with --responses to enable local writes."}</p><section id="handoff-prepared" hidden></section></form>`;
|
|
42
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Effect, type FileSystem, type Path, type PlatformError } from "effect";
|
|
2
|
+
import type { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
import type { Changes } from "#files/changes.ts";
|
|
4
|
+
import { handoffFormHtml, handoffHistoryHtml } from "#handoffs/html.ts";
|
|
5
|
+
import type { handoffService } from "#handoffs/service.ts";
|
|
6
|
+
import { respond } from "#http/respond.ts";
|
|
7
|
+
import { escapeHtml } from "#page/escape.ts";
|
|
8
|
+
import { navHtml } from "#page/nav.ts";
|
|
9
|
+
import { shell } from "#page/shell.ts";
|
|
10
|
+
import type { Highlighter } from "#render/highlighter.ts";
|
|
11
|
+
|
|
12
|
+
function unknownHistory(files: readonly string[]) {
|
|
13
|
+
return files.length > 0
|
|
14
|
+
? `<p role="status">Some handoff history is unavailable: ${files.map(escapeHtml).join(", ")}. Missing records do not establish that no receipt exists.</p>`
|
|
15
|
+
: "";
|
|
16
|
+
}
|
|
17
|
+
export function handoffPage(
|
|
18
|
+
service: Effect.Success<ReturnType<typeof handoffService>>,
|
|
19
|
+
changes: Changes,
|
|
20
|
+
home: string | undefined,
|
|
21
|
+
enabled: boolean,
|
|
22
|
+
) {
|
|
23
|
+
return Effect.fn("WorkBoard.handoffPage")(function* (
|
|
24
|
+
request: HttpServerRequest.HttpServerRequest,
|
|
25
|
+
): Effect.fn.Return<HttpServerResponse.HttpServerResponse, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path | Highlighter> {
|
|
26
|
+
const item = new URL(request.url, "http://127.0.0.1").searchParams.get("item") ?? "";
|
|
27
|
+
const files = yield* changes.files;
|
|
28
|
+
function layout(body: string, status = 200) {
|
|
29
|
+
return respond(shell("Handoff", navHtml(files, "", home), body, changes.realRoot, false, undefined, "handoff"), "text/html", status);
|
|
30
|
+
}
|
|
31
|
+
return yield* Effect.gen(function* () {
|
|
32
|
+
const source = yield* Effect.result(service.locate(item));
|
|
33
|
+
const current = source._tag === "Success" ? source.success : undefined;
|
|
34
|
+
const reading = yield* service.read(item);
|
|
35
|
+
const history = yield* handoffHistoryHtml(reading.records, current);
|
|
36
|
+
const form = current
|
|
37
|
+
? yield* handoffFormHtml(current, enabled)
|
|
38
|
+
: `<p role="status">Source unavailable: ${escapeHtml(String(source._tag === "Failure" ? source.failure : ""))}. Existing handoffs remain readable.</p>`;
|
|
39
|
+
return layout(
|
|
40
|
+
`<section class="response-panel"><h1>Agent handoff · ${escapeHtml(item)}</h1><p>Prepare a Markdown file, then copy a tiny prompt into your existing agent session. Sessions may poll or wait for human answers. Agents record receipt with ordinary file edits.</p>${form}<section id="handoff-history"><h2>Prepared handoffs</h2>${unknownHistory(reading.unknown)}${history.join("") || "<p>No readable handoff is recorded for this item.</p>"}</section></section>`,
|
|
41
|
+
current || reading.records.length > 0 ? 200 : 409,
|
|
42
|
+
);
|
|
43
|
+
}).pipe(Effect.catch((error) => Effect.succeed(layout(`<h1>Handoff unavailable</h1><p>${escapeHtml(String(error))}</p>`, 409))));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
import { stringify } from "yaml";
|
|
4
|
+
import type { Handoff, HandoffInput, HandoffRecord } from "#browser/handoffs/schema.ts";
|
|
5
|
+
import { HandoffRecord as RecordSchema } from "#browser/handoffs/schema.ts";
|
|
6
|
+
import type { MetadataDocument } from "#metadata/model.ts";
|
|
7
|
+
|
|
8
|
+
export function handoffFrom(document: MetadataDocument | undefined, root: string): Handoff | undefined {
|
|
9
|
+
if (!document) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
const { id, kind, handoff } = document.parsed.fields;
|
|
13
|
+
return id && kind === "handoff" && handoff
|
|
14
|
+
? { context: document.parsed.body, file: document.file, handoff, id, prompt: handoffPrompt(join(root, document.file)) }
|
|
15
|
+
: undefined;
|
|
16
|
+
}
|
|
17
|
+
export function handoffPrompt(path: string): string {
|
|
18
|
+
return `Read the handoff at ${JSON.stringify(path)}. Record receipt by editing its handoff.state and add handoff.by/note; then follow its instructions.`;
|
|
19
|
+
}
|
|
20
|
+
export function handoffMarkdown(id: string, handoff: HandoffRecord, context: string): string {
|
|
21
|
+
return `---\n${stringify({ handoff: Schema.encodeSync(RecordSchema)(handoff), id, kind: "handoff" })}---\n${context}`;
|
|
22
|
+
}
|
|
23
|
+
export function matchesHandoff(record: Handoff, input: HandoffInput) {
|
|
24
|
+
const saved = record.handoff;
|
|
25
|
+
return (
|
|
26
|
+
saved.item === input.item
|
|
27
|
+
&& saved.source === input.source
|
|
28
|
+
&& saved.reviewedRevision === input.revision
|
|
29
|
+
&& saved.recipient === input.recipient
|
|
30
|
+
&& saved.goal === input.goal
|
|
31
|
+
&& saved.constraints === input.constraints
|
|
32
|
+
&& saved.nextAction === input.nextAction
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { Clock, Effect, FileSystem, Semaphore } from "effect";
|
|
3
|
+
import type { Handoff, HandoffInput } from "#browser/handoffs/schema.ts";
|
|
4
|
+
import { ResponseFailed } from "#browser/responses/schema.ts";
|
|
5
|
+
import { markdownPath } from "#files/markdownPath.ts";
|
|
6
|
+
import { handoffFrom, handoffMarkdown, handoffPrompt, matchesHandoff } from "#handoffs/records.ts";
|
|
7
|
+
import { handoffSource } from "#handoffs/source.ts";
|
|
8
|
+
import { publish } from "#responses/publish.ts";
|
|
9
|
+
import { revisionOf } from "#responses/records.ts";
|
|
10
|
+
import type { ResponseOptions } from "#responses/service.ts";
|
|
11
|
+
|
|
12
|
+
export const handoffService = Effect.fn("WorkBoard.handoffService")(function* (options: ResponseOptions) {
|
|
13
|
+
const fs = yield* FileSystem.FileSystem;
|
|
14
|
+
const lock = yield* Semaphore.make(1);
|
|
15
|
+
const snapshot = options.index.pipe(
|
|
16
|
+
Effect.catch(() => Effect.fail(new ResponseFailed({ code: "Unavailable", message: "The workspace could not be indexed." }))),
|
|
17
|
+
);
|
|
18
|
+
const locate = handoffSource(options.root, options.changes.realRoot, snapshot, fs);
|
|
19
|
+
const read = Effect.fn("WorkBoard.read")(function* (item: string) {
|
|
20
|
+
const data = yield* snapshot;
|
|
21
|
+
const unknown = [...data.unavailable];
|
|
22
|
+
const records = data.documents.flatMap((document) => {
|
|
23
|
+
const record = handoffFrom(document, options.changes.realRoot);
|
|
24
|
+
if (
|
|
25
|
+
(record && data.model.ids.get(record.id)?.length !== 1)
|
|
26
|
+
|| (document.parsed.fields.kind === "handoff" && !record)
|
|
27
|
+
|| (document.file.startsWith("handoffs/") && document.parsed.diagnostics.some((problem) => problem.field === undefined))
|
|
28
|
+
) {
|
|
29
|
+
unknown.push(document.file);
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
return record?.handoff.item === item ? [record] : [];
|
|
33
|
+
});
|
|
34
|
+
return { records: records.sort((a, b) => b.handoff.preparedAt - a.handoff.preparedAt || a.id.localeCompare(b.id)), unknown };
|
|
35
|
+
});
|
|
36
|
+
const reconcile = Effect.fn("WorkBoard.reconcile")(function* (record: Handoff | undefined, input: HandoffInput) {
|
|
37
|
+
if (!record || record.file !== `handoffs/${input.id}.md` || !matchesHandoff(record, input) || revisionOf(record.context) !== input.revision) {
|
|
38
|
+
return yield* Effect.fail(
|
|
39
|
+
new ResponseFailed({ code: "Conflict", message: "This handoff identity moved, is duplicated or records different direction." }),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const path = yield* markdownPath(options.root, options.changes.realRoot, record.file);
|
|
43
|
+
if (!path) {
|
|
44
|
+
return yield* Effect.fail(new ResponseFailed({ code: "Missing", message: "The prepared handoff moved or disappeared." }));
|
|
45
|
+
}
|
|
46
|
+
const raw = yield* fs
|
|
47
|
+
.readFileString(path)
|
|
48
|
+
.pipe(Effect.catch(() => Effect.fail(new ResponseFailed({ code: "Unavailable", message: "The handoff could not be reconciled." }))));
|
|
49
|
+
yield* publish(options.root, options.changes.realRoot, record.id, raw, "handoffs");
|
|
50
|
+
return record;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function prepare(input: HandoffInput) {
|
|
54
|
+
return lock.withPermit(
|
|
55
|
+
Effect.gen(function* () {
|
|
56
|
+
if (!options.enabled) {
|
|
57
|
+
return yield* Effect.fail(
|
|
58
|
+
new ResponseFailed({ code: "Disabled", message: "Restart with --responses to enable local response/handoff writes." }),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const data = yield* snapshot;
|
|
62
|
+
const existing = data.model.ids.get(input.id);
|
|
63
|
+
if (existing?.length) {
|
|
64
|
+
const record = existing.length === 1 ? handoffFrom(existing[0], options.changes.realRoot) : undefined;
|
|
65
|
+
return yield* reconcile(record, input);
|
|
66
|
+
}
|
|
67
|
+
const current = yield* locate(input.item);
|
|
68
|
+
if (current.reviewedRevision !== input.revision || current.source !== input.source) {
|
|
69
|
+
return yield* Effect.fail(
|
|
70
|
+
new ResponseFailed({ code: "Stale", message: "The source changed or moved. Keep your draft and review it again." }),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const file = `handoffs/${input.id}.md`;
|
|
74
|
+
const record: Handoff = {
|
|
75
|
+
context: current.context,
|
|
76
|
+
file,
|
|
77
|
+
handoff: {
|
|
78
|
+
constraints: input.constraints,
|
|
79
|
+
goal: input.goal,
|
|
80
|
+
item: input.item,
|
|
81
|
+
nextAction: input.nextAction,
|
|
82
|
+
preparedAt: yield* Clock.currentTimeMillis,
|
|
83
|
+
recipient: input.recipient,
|
|
84
|
+
reviewedRevision: input.revision,
|
|
85
|
+
source: input.source,
|
|
86
|
+
state: "requested",
|
|
87
|
+
},
|
|
88
|
+
id: input.id,
|
|
89
|
+
prompt: handoffPrompt(join(options.changes.realRoot, file)),
|
|
90
|
+
};
|
|
91
|
+
yield* publish(
|
|
92
|
+
options.root,
|
|
93
|
+
options.changes.realRoot,
|
|
94
|
+
record.id,
|
|
95
|
+
handoffMarkdown(record.id, record.handoff, record.context),
|
|
96
|
+
"handoffs",
|
|
97
|
+
).pipe(Effect.ensuring(options.changes.refresh ?? Effect.void));
|
|
98
|
+
return record;
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return { locate, prepare, read };
|
|
103
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Effect, type FileSystem } from "effect";
|
|
2
|
+
import type { HandoffPreview } from "#browser/handoffs/schema.ts";
|
|
3
|
+
import { ResponseFailed } from "#browser/responses/schema.ts";
|
|
4
|
+
import { markdownPath } from "#files/markdownPath.ts";
|
|
5
|
+
import { metadataParse } from "#metadata/parse.ts";
|
|
6
|
+
import { questionId, revisionOf } from "#responses/records.ts";
|
|
7
|
+
import type { Snapshot } from "#search/snapshot.ts";
|
|
8
|
+
|
|
9
|
+
export function handoffSource(root: string, realRoot: string, snapshot: Effect.Effect<Snapshot, ResponseFailed>, fs: FileSystem.FileSystem) {
|
|
10
|
+
return Effect.fn("WorkBoard.handoffSource")(function* (item: string) {
|
|
11
|
+
const data = yield* snapshot;
|
|
12
|
+
const matches = data.model.ids.get(item);
|
|
13
|
+
if (data.unavailable.length > 0 || matches?.length !== 1 || !matches[0]) {
|
|
14
|
+
return yield* Effect.fail(new ResponseFailed({ code: "Missing", message: "The item is missing, ambiguous or unavailable." }));
|
|
15
|
+
}
|
|
16
|
+
const file = matches[0].file;
|
|
17
|
+
const real = yield* markdownPath(root, realRoot, file);
|
|
18
|
+
const context = real ? yield* fs.readFileString(real).pipe(Effect.catch(() => Effect.succeed(undefined))) : undefined;
|
|
19
|
+
if (context === undefined) {
|
|
20
|
+
return yield* Effect.fail(new ResponseFailed({ code: "Unavailable", message: "The handoff source could not be read." }));
|
|
21
|
+
}
|
|
22
|
+
const fields = metadataParse(context).fields;
|
|
23
|
+
if (fields.id !== item || fields.kind === "question" || fields.kind === "response" || fields.kind === "handoff") {
|
|
24
|
+
return yield* Effect.fail(new ResponseFailed({ code: "Stale", message: "Choose a current project item for this handoff." }));
|
|
25
|
+
}
|
|
26
|
+
if (new TextEncoder().encode(context).length > 256 * 1024) {
|
|
27
|
+
return yield* Effect.fail(
|
|
28
|
+
new ResponseFailed({ code: "Unsupported", message: "Handoff context is limited to 256 KiB. Choose a focused item." }),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
const reviewedRevision = revisionOf(context);
|
|
32
|
+
const preview: HandoffPreview = { context, id: questionId(item, "handoff", reviewedRevision, file), item, reviewedRevision, source: file };
|
|
33
|
+
return preview;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -10,6 +10,7 @@ export interface Backlink {
|
|
|
10
10
|
function references(document: MetadataDocument, model: MetadataModel) {
|
|
11
11
|
const { fields, lines } = document.parsed;
|
|
12
12
|
return [
|
|
13
|
+
...(fields.handoff ? [{ kind: "agent handoff", line: lines.handoff, target: referenceFile(fields.handoff.item, model) }] : []),
|
|
13
14
|
...(fields.question ? [{ kind: "reviewed question", line: lines.question, target: referenceFile(fields.question.item, model) }] : []),
|
|
14
15
|
...(fields.response ? [{ kind: "recorded response", line: lines.response, target: referenceFile(fields.response.question, model) }] : []),
|
|
15
16
|
...(fields.response?.supersedes
|
package/src/metadata/problems.ts
CHANGED
|
@@ -27,6 +27,7 @@ function criterionProblems(document: MetadataDocument): readonly Diagnostic[] {
|
|
|
27
27
|
function referenceProblems(document: MetadataDocument, ids: ReadonlyMap<string, readonly MetadataDocument[]>): readonly Diagnostic[] {
|
|
28
28
|
const { fields, lines } = document.parsed;
|
|
29
29
|
const references = [
|
|
30
|
+
...(fields.handoff ? [{ field: "handoff", key: "handoff.item", target: fields.handoff.item }] : []),
|
|
30
31
|
...(fields.question ? [{ field: "question", key: "question.item", target: fields.question.item }] : []),
|
|
31
32
|
...(fields.response ? [{ field: "response", key: "response.question", target: fields.response.question }] : []),
|
|
32
33
|
...(fields.response?.supersedes ? [{ field: "response", key: "response.supersedes", target: fields.response.supersedes }] : []),
|
package/src/metadata/schema.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
|
+
import { HandoffRecord } from "#browser/handoffs/schema.ts";
|
|
2
3
|
import { QuestionRecord, ResponseRecord } from "#browser/responses/schema.ts";
|
|
3
4
|
import { Identity as SourceIdentity } from "#path/Identity.ts";
|
|
4
5
|
|
|
@@ -36,9 +37,10 @@ export const Metadata = Schema.Struct({
|
|
|
36
37
|
attention: Schema.optional(Schema.Array(Attention)),
|
|
37
38
|
criteria: Schema.optional(Schema.Array(Criterion)),
|
|
38
39
|
evidence: Schema.optional(Schema.Array(Evidence)),
|
|
40
|
+
handoff: Schema.optional(HandoffRecord),
|
|
39
41
|
id: Schema.optional(Identity),
|
|
40
42
|
items: Schema.optional(Schema.Array(Identity)),
|
|
41
|
-
kind: Schema.optional(Schema.Literals(["task", "investigation", "decision", "result", "project", "board", "question", "response"])),
|
|
43
|
+
kind: Schema.optional(Schema.Literals(["task", "investigation", "decision", "result", "project", "board", "question", "response", "handoff"])),
|
|
42
44
|
nextAction: Schema.optional(Text),
|
|
43
45
|
owner: Schema.optional(Text),
|
|
44
46
|
question: Schema.optional(QuestionRecord),
|