wtf-p 0.7.3 → 0.8.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/README.md +16 -12
- package/bin/commands/install-logic.js +1 -1
- package/bin/lib/adapter-compiler.js +175 -56
- package/bin/lib/clio-registration.js +127 -36
- package/bin/lib/manifest.js +11 -3
- package/bin/uninstall.js +3 -2
- package/package.json +1 -1
- package/protocol/actions/research-gap.json +1 -1
- package/protocol/roles/research-synthesizer.md +1 -1
- package/protocol/skills/wtfp-plan-section/references/actions.md +1 -1
- package/protocol/skills/wtfp-research-literature/references/actions.md +1 -1
- package/protocol/skills/wtfp-write-section/references/actions.md +1 -1
- package/protocol/workflows/plan-section.md +3 -2
- package/protocol/workflows/research-gap.md +2 -2
- package/protocol/workflows/write-section.md +1 -1
- package/vendors/antigravity/.wtfp-generated.json +18 -14
- package/vendors/antigravity/actions/research-gap.json +1 -1
- package/vendors/antigravity/agents/wtfp-research-synthesizer.md +1 -1
- package/vendors/antigravity/commands/wtfp-plan-section.md +3 -2
- package/vendors/antigravity/commands/wtfp-write-section.md +1 -1
- package/vendors/antigravity/plugin.json +1 -1
- package/vendors/antigravity/roles/research-synthesizer.md +1 -1
- package/vendors/antigravity/skills/wtfp-plan-section/references/actions.md +1 -1
- package/vendors/antigravity/skills/wtfp-research-literature/references/actions.md +1 -1
- package/vendors/antigravity/skills/wtfp-write-section/references/actions.md +1 -1
- package/vendors/antigravity/tools/README.md +1 -1
- package/vendors/antigravity/tools/package.json +1 -0
- package/vendors/antigravity/workflows/plan-section.md +3 -2
- package/vendors/antigravity/workflows/research-gap.md +2 -2
- package/vendors/antigravity/workflows/write-section.md +1 -1
- package/vendors/claude/.claude-plugin/marketplace.json +2 -2
- package/vendors/claude/.claude-plugin/plugin.json +1 -1
- package/vendors/claude/.wtfp-generated.json +33 -29
- package/vendors/claude/actions/research-gap.json +1 -1
- package/vendors/claude/agents/wtfp-research-synthesizer.md +1 -1
- package/vendors/claude/ai.iowarp.clio/agents/wtfp-research-synthesizer.md +1 -1
- package/vendors/claude/ai.iowarp.clio/prompts/wtfp/analyze-bib.md +2 -0
- package/vendors/claude/ai.iowarp.clio/prompts/wtfp/check-refs.md +2 -0
- package/vendors/claude/ai.iowarp.clio/prompts/wtfp/create-outline.md +2 -0
- package/vendors/claude/ai.iowarp.clio/prompts/wtfp/execute-outline.md +2 -0
- package/vendors/claude/ai.iowarp.clio/prompts/wtfp/help.md +1 -1
- package/vendors/claude/ai.iowarp.clio/prompts/wtfp/plan-section.md +5 -2
- package/vendors/claude/ai.iowarp.clio/prompts/wtfp/polish-prose.md +2 -0
- package/vendors/claude/ai.iowarp.clio/prompts/wtfp/quick.md +2 -0
- package/vendors/claude/ai.iowarp.clio/prompts/wtfp/research-gap.md +8 -2
- package/vendors/claude/ai.iowarp.clio/prompts/wtfp/review-section.md +2 -0
- package/vendors/claude/ai.iowarp.clio/prompts/wtfp/write-section.md +3 -1
- package/vendors/claude/commands/plan-section.md +3 -2
- package/vendors/claude/commands/research-gap.md +2 -2
- package/vendors/claude/commands/write-section.md +1 -1
- package/vendors/claude/plugin.json +1 -1
- package/vendors/claude/roles/research-synthesizer.md +1 -1
- package/vendors/claude/skills/wtfp-plan-section/references/actions.md +1 -1
- package/vendors/claude/skills/wtfp-research-literature/references/actions.md +1 -1
- package/vendors/claude/skills/wtfp-write-section/references/actions.md +1 -1
- package/vendors/claude/tools/README.md +1 -1
- package/vendors/claude/tools/package.json +1 -0
- package/vendors/claude/workflows/plan-section.md +3 -2
- package/vendors/claude/workflows/research-gap.md +2 -2
- package/vendors/claude/workflows/write-section.md +1 -1
- package/vendors/clio-extension/.wtfp-generated.json +228 -0
- package/vendors/clio-extension/actions/add-todo.json +52 -0
- package/vendors/clio-extension/actions/analyze-bib.json +97 -0
- package/vendors/clio-extension/actions/audit-milestone.json +63 -0
- package/vendors/clio-extension/actions/check-refs.json +80 -0
- package/vendors/clio-extension/actions/check-todos.json +52 -0
- package/vendors/clio-extension/actions/checkpoint.json +76 -0
- package/vendors/clio-extension/actions/contribute.json +57 -0
- package/vendors/clio-extension/actions/create-outline.json +84 -0
- package/vendors/clio-extension/actions/create-poster.json +54 -0
- package/vendors/clio-extension/actions/create-slides.json +54 -0
- package/vendors/clio-extension/actions/discuss-section.json +76 -0
- package/vendors/clio-extension/actions/execute-outline.json +116 -0
- package/vendors/clio-extension/actions/export-latex.json +65 -0
- package/vendors/clio-extension/actions/help.json +30 -0
- package/vendors/clio-extension/actions/insert-section.json +61 -0
- package/vendors/clio-extension/actions/list-assumptions.json +35 -0
- package/vendors/clio-extension/actions/map-project.json +71 -0
- package/vendors/clio-extension/actions/new-paper.json +70 -0
- package/vendors/clio-extension/actions/pause-writing.json +71 -0
- package/vendors/clio-extension/actions/plan-milestone-gaps.json +57 -0
- package/vendors/clio-extension/actions/plan-revision.json +58 -0
- package/vendors/clio-extension/actions/plan-section.json +100 -0
- package/vendors/clio-extension/actions/polish-prose.json +69 -0
- package/vendors/clio-extension/actions/progress.json +51 -0
- package/vendors/clio-extension/actions/quick.json +70 -0
- package/vendors/clio-extension/actions/remove-section.json +63 -0
- package/vendors/clio-extension/actions/report-bug.json +47 -0
- package/vendors/clio-extension/actions/request-feature.json +47 -0
- package/vendors/clio-extension/actions/research-gap.json +102 -0
- package/vendors/clio-extension/actions/resume-writing.json +65 -0
- package/vendors/clio-extension/actions/review-section.json +83 -0
- package/vendors/clio-extension/actions/settings.json +47 -0
- package/vendors/clio-extension/actions/submit-milestone.json +77 -0
- package/vendors/clio-extension/actions/update.json +42 -0
- package/vendors/clio-extension/actions/verify-work.json +63 -0
- package/vendors/clio-extension/actions/write-section.json +115 -0
- package/vendors/clio-extension/clio-coder-extension.yaml +53 -0
- package/vendors/clio-extension/compatibility/action-availability.json +295 -0
- package/vendors/clio-extension/project/schemas/checkpoint.schema.json +102 -0
- package/vendors/clio-extension/project/schemas/common.schema.json +33 -0
- package/vendors/clio-extension/project/schemas/config.schema.json +69 -0
- package/vendors/clio-extension/project/schemas/decisions.schema.json +31 -0
- package/vendors/clio-extension/project/schemas/evidence.schema.json +48 -0
- package/vendors/clio-extension/project/schemas/manifest.schema.json +102 -0
- package/vendors/clio-extension/project/schemas/outline.schema.json +67 -0
- package/vendors/clio-extension/project/schemas/section.schema.json +95 -0
- package/vendors/clio-extension/project/schemas/source.schema.json +73 -0
- package/vendors/clio-extension/project/schemas/state.schema.json +58 -0
- package/vendors/clio-extension/project/schemas/validation.schema.json +74 -0
- package/vendors/clio-extension/runtime/extension.test.ts +248 -0
- package/vendors/clio-extension/runtime/extension.ts +1321 -0
- package/vendors/clio-extension/runtime/json-schema.cjs +304 -0
- package/vendors/clio-extension/runtime/package.json +3 -0
- package/vendors/clio-extension/runtime/write-guard.json +21 -0
- package/vendors/clio-extension/skins/wtfp.json +20 -0
- package/vendors/codex/plugins/wtfp/.codex-plugin/plugin.json +1 -1
- package/vendors/codex/plugins/wtfp/.wtfp-generated.json +17 -13
- package/vendors/codex/plugins/wtfp/actions/research-gap.json +1 -1
- package/vendors/codex/plugins/wtfp/agents/wtfp-research-synthesizer.toml +1 -1
- package/vendors/codex/plugins/wtfp/plugin.json +1 -1
- package/vendors/codex/plugins/wtfp/roles/research-synthesizer.md +1 -1
- package/vendors/codex/plugins/wtfp/skills/wtfp-plan-section/references/actions.md +1 -1
- package/vendors/codex/plugins/wtfp/skills/wtfp-research-literature/references/actions.md +1 -1
- package/vendors/codex/plugins/wtfp/skills/wtfp-write-section/references/actions.md +1 -1
- package/vendors/codex/plugins/wtfp/tools/README.md +1 -1
- package/vendors/codex/plugins/wtfp/tools/package.json +1 -0
- package/vendors/codex/plugins/wtfp/workflows/plan-section.md +3 -2
- package/vendors/codex/plugins/wtfp/workflows/research-gap.md +2 -2
- package/vendors/codex/plugins/wtfp/workflows/write-section.md +1 -1
- package/vendors/copilot/.wtfp-generated.json +14 -14
- package/vendors/copilot/marketplace.json +2 -2
- package/vendors/copilot/plugins/wtfp/.claude-plugin/plugin.json +1 -1
- package/vendors/copilot/plugins/wtfp/.wtfp-generated.json +18 -14
- package/vendors/copilot/plugins/wtfp/actions/research-gap.json +1 -1
- package/vendors/copilot/plugins/wtfp/agents/wtfp-research-synthesizer.md +1 -1
- package/vendors/copilot/plugins/wtfp/commands/wtfp-plan-section.md +3 -2
- package/vendors/copilot/plugins/wtfp/commands/wtfp-write-section.md +1 -1
- package/vendors/copilot/plugins/wtfp/roles/research-synthesizer.md +1 -1
- package/vendors/copilot/plugins/wtfp/skills/wtfp-plan-section/references/actions.md +1 -1
- package/vendors/copilot/plugins/wtfp/skills/wtfp-research-literature/references/actions.md +1 -1
- package/vendors/copilot/plugins/wtfp/skills/wtfp-write-section/references/actions.md +1 -1
- package/vendors/copilot/plugins/wtfp/tools/README.md +1 -1
- package/vendors/copilot/plugins/wtfp/tools/package.json +1 -0
- package/vendors/copilot/plugins/wtfp/workflows/plan-section.md +3 -2
- package/vendors/copilot/plugins/wtfp/workflows/research-gap.md +2 -2
- package/vendors/copilot/plugins/wtfp/workflows/write-section.md +1 -1
- package/vendors/copilot/project/.github/agents/wtfp-research-synthesizer.agent.md +1 -1
- package/vendors/copilot/project/.github/skills/wtfp-plan-section/references/actions.md +1 -1
- package/vendors/copilot/project/.github/skills/wtfp-research-literature/references/actions.md +1 -1
- package/vendors/copilot/project/.github/skills/wtfp-write-section/references/actions.md +1 -1
- package/vendors/copilot/project/.github/wtfp/actions/research-gap.json +1 -1
- package/vendors/copilot/project/.github/wtfp/roles/research-synthesizer.md +1 -1
- package/vendors/copilot/project/.github/wtfp/skills/wtfp-plan-section/references/actions.md +1 -1
- package/vendors/copilot/project/.github/wtfp/skills/wtfp-research-literature/references/actions.md +1 -1
- package/vendors/copilot/project/.github/wtfp/skills/wtfp-write-section/references/actions.md +1 -1
- package/vendors/copilot/project/.github/wtfp/workflows/plan-section.md +3 -2
- package/vendors/copilot/project/.github/wtfp/workflows/research-gap.md +2 -2
- package/vendors/copilot/project/.github/wtfp/workflows/write-section.md +1 -1
- package/vendors/gemini/.wtfp-generated.json +24 -20
- package/vendors/gemini/actions/research-gap.json +1 -1
- package/vendors/gemini/agents/wtfp-research-synthesizer.md +1 -1
- package/vendors/gemini/commands/wtfp/discuss-section.toml +1 -1
- package/vendors/gemini/commands/wtfp/execute-outline.toml +1 -1
- package/vendors/gemini/commands/wtfp/insert-section.toml +1 -1
- package/vendors/gemini/commands/wtfp/list-assumptions.toml +1 -1
- package/vendors/gemini/commands/wtfp/plan-revision.toml +1 -1
- package/vendors/gemini/commands/wtfp/plan-section.toml +4 -3
- package/vendors/gemini/commands/wtfp/quick.toml +1 -1
- package/vendors/gemini/commands/wtfp/write-section.toml +2 -2
- package/vendors/gemini/gemini-extension.json +1 -1
- package/vendors/gemini/roles/research-synthesizer.md +1 -1
- package/vendors/gemini/skills/wtfp-plan-section/references/actions.md +1 -1
- package/vendors/gemini/skills/wtfp-research-literature/references/actions.md +1 -1
- package/vendors/gemini/skills/wtfp-write-section/references/actions.md +1 -1
- package/vendors/gemini/tools/README.md +1 -1
- package/vendors/gemini/tools/package.json +1 -0
- package/vendors/gemini/workflows/plan-section.md +3 -2
- package/vendors/gemini/workflows/research-gap.md +2 -2
- package/vendors/gemini/workflows/write-section.md +1 -1
- package/vendors/opencode/.wtfp-generated.json +23 -19
- package/vendors/opencode/actions/research-gap.json +1 -1
- package/vendors/opencode/agents/wtfp/research-synthesizer.md +1 -1
- package/vendors/opencode/commands/wtfp/discuss-section.md +1 -1
- package/vendors/opencode/commands/wtfp/execute-outline.md +1 -1
- package/vendors/opencode/commands/wtfp/insert-section.md +1 -1
- package/vendors/opencode/commands/wtfp/list-assumptions.md +1 -1
- package/vendors/opencode/commands/wtfp/plan-revision.md +1 -1
- package/vendors/opencode/commands/wtfp/plan-section.md +4 -3
- package/vendors/opencode/commands/wtfp/quick.md +1 -1
- package/vendors/opencode/commands/wtfp/write-section.md +2 -2
- package/vendors/opencode/roles/research-synthesizer.md +1 -1
- package/vendors/opencode/skills/wtfp-plan-section/references/actions.md +1 -1
- package/vendors/opencode/skills/wtfp-research-literature/references/actions.md +1 -1
- package/vendors/opencode/skills/wtfp-write-section/references/actions.md +1 -1
- package/vendors/opencode/tools/README.md +1 -1
- package/vendors/opencode/tools/package.json +1 -0
- package/vendors/opencode/workflows/plan-section.md +3 -2
- package/vendors/opencode/workflows/research-gap.md +2 -2
- package/vendors/opencode/workflows/write-section.md +1 -1
- package/vendors/plugin/.wtfp-generated.json +44 -40
- package/vendors/plugin/actions/research-gap.json +1 -1
- package/vendors/plugin/ai.iowarp.clio/agents/wtfp-research-synthesizer.md +1 -1
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/add-todo.md +6 -12
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/analyze-bib.md +9 -19
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/audit-milestone.md +7 -25
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/check-refs.md +9 -15
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/check-todos.md +9 -13
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/checkpoint.md +7 -27
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/create-outline.md +9 -25
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/discuss-section.md +7 -17
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/execute-outline.md +10 -27
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/help.md +2 -2
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/insert-section.md +7 -13
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/map-project.md +6 -14
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/new-paper.md +7 -17
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/pause-writing.md +6 -18
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/plan-milestone-gaps.md +7 -15
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/plan-section.md +12 -29
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/polish-prose.md +10 -15
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/quick.md +10 -15
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/research-gap.md +15 -23
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/resume-writing.md +7 -23
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/review-section.md +9 -19
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/settings.md +7 -9
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/submit-milestone.md +7 -25
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/verify-work.md +7 -15
- package/vendors/plugin/ai.iowarp.clio/prompts/wtfp/write-section.md +11 -29
- package/vendors/plugin/compatibility/action-availability.json +16 -1
- package/vendors/plugin/plugin.json +7 -7
- package/vendors/plugin/roles/research-synthesizer.md +1 -1
- package/vendors/plugin/skills/wtfp-plan-section/references/actions.md +1 -1
- package/vendors/plugin/skills/wtfp-research-literature/references/actions.md +1 -1
- package/vendors/plugin/skills/wtfp-write-section/references/actions.md +1 -1
- package/vendors/plugin/tools/README.md +1 -1
- package/vendors/plugin/tools/package.json +1 -0
- package/vendors/plugin/workflows/plan-section.md +3 -2
- package/vendors/plugin/workflows/research-gap.md +2 -2
- package/vendors/plugin/workflows/write-section.md +1 -1
- /package/vendors/plugin/ai.iowarp.clio/{fleets → playbooks}/wtfp-draft-review.md +0 -0
- /package/vendors/plugin/ai.iowarp.clio/{fleets → playbooks}/wtfp-plan-section.md +0 -0
|
@@ -0,0 +1,1321 @@
|
|
|
1
|
+
// Generated by WTF-P adapter compiler v5 from clio/runtime/extension.ts; do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* WTF-P writing desk for Clio Coder. Human-edited source; the adapter
|
|
4
|
+
* compiler copies it to vendors/clio-extension/runtime/ beside the
|
|
5
|
+
* generated write-guard.json and json-schema.cjs.
|
|
6
|
+
*
|
|
7
|
+
* The desk reads the portable `.planning` records and the manuscript, and
|
|
8
|
+
* draws them. It writes only two things, both under `.planning`: an author's
|
|
9
|
+
* answer to a gate (the checkpoint record and `state.active_checkpoint_uris`),
|
|
10
|
+
* each schema-validated before an atomic replace. Everything else stays with
|
|
11
|
+
* the actions' own workflows.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import type {
|
|
19
|
+
ExtensionApiV2,
|
|
20
|
+
ExtensionContextV2,
|
|
21
|
+
ExtensionHookResult,
|
|
22
|
+
ExtensionIsland,
|
|
23
|
+
ExtensionOutputV2,
|
|
24
|
+
ExtensionSkin,
|
|
25
|
+
InterviewStep,
|
|
26
|
+
View,
|
|
27
|
+
ViewTone,
|
|
28
|
+
} from "@iowarp/clio-coder/extensions";
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const { SchemaRegistry, validateInstance } = require("./json-schema.cjs") as {
|
|
32
|
+
SchemaRegistry: new (files: string[]) => { get(file: string): unknown };
|
|
33
|
+
validateInstance: (value: unknown, schema: unknown, file: string, registry: unknown) => string[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const PACKAGE_ROOT = fileURLToPath(new URL("../", import.meta.url));
|
|
37
|
+
const SCHEMAS = path.join(PACKAGE_ROOT, "project", "schemas");
|
|
38
|
+
const GUARD = JSON.parse(readFileSync(new URL("./write-guard.json", import.meta.url), "utf8")) as {
|
|
39
|
+
actions: Record<string, string[]>;
|
|
40
|
+
};
|
|
41
|
+
const SKIN = JSON.parse(readFileSync(new URL("../skins/wtfp.json", import.meta.url), "utf8")) as ExtensionSkin;
|
|
42
|
+
const RECORD_LIMIT = 1024 * 1024;
|
|
43
|
+
const PHASES = ["initialized", "mapped", "outlining", "planning", "writing", "reviewing", "ready", "delivered"] as const;
|
|
44
|
+
const WORDMARK = [
|
|
45
|
+
"██╗ ██╗████████╗███████╗ ██████╗ ",
|
|
46
|
+
"██║ ██║╚══██╔══╝██╔════╝ ██╔══██╗",
|
|
47
|
+
"██║ █╗ ██║ ██║ █████╗ █████╗██████╔╝",
|
|
48
|
+
"██║███╗██║ ██║ ██╔══╝ ╚════╝██╔═══╝ ",
|
|
49
|
+
"╚███╔███╔╝ ██║ ██║ ██║ ",
|
|
50
|
+
" ╚══╝╚══╝ ╚═╝ ╚═╝ ╚═╝ ",
|
|
51
|
+
];
|
|
52
|
+
const WORD_METHOD =
|
|
53
|
+
"Body words: front matter, headings, code, math, comments, citation keys and markup are left out; a word is a run of letters or digits, joined across an apostrophe or hyphen.";
|
|
54
|
+
|
|
55
|
+
// --- records -----------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
interface Checkpoint {
|
|
58
|
+
file: string;
|
|
59
|
+
id: string;
|
|
60
|
+
kind: string;
|
|
61
|
+
status: string;
|
|
62
|
+
blocking: boolean;
|
|
63
|
+
scope_uri: string;
|
|
64
|
+
request: string;
|
|
65
|
+
context?: string;
|
|
66
|
+
options?: Array<{ id: string; label: string; implication?: string }>;
|
|
67
|
+
created_at?: string;
|
|
68
|
+
[key: string]: unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface SectionRow {
|
|
72
|
+
id: string;
|
|
73
|
+
title: string;
|
|
74
|
+
wave: number;
|
|
75
|
+
status: string;
|
|
76
|
+
target: number;
|
|
77
|
+
words: number;
|
|
78
|
+
manuscript: string | null;
|
|
79
|
+
claims: { total: number; verified: number; unsupported: number };
|
|
80
|
+
stages: { discuss: boolean; plan: boolean; write: boolean; review: boolean };
|
|
81
|
+
gates: Checkpoint[];
|
|
82
|
+
research: { required: boolean; present: boolean };
|
|
83
|
+
dependsOn: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Desk {
|
|
87
|
+
workspace: string;
|
|
88
|
+
present: boolean;
|
|
89
|
+
title: string;
|
|
90
|
+
documentType: string;
|
|
91
|
+
venue: string | null;
|
|
92
|
+
deadline: string | null;
|
|
93
|
+
argument: string;
|
|
94
|
+
phase: string;
|
|
95
|
+
status: string;
|
|
96
|
+
confirmPlan: boolean;
|
|
97
|
+
sections: SectionRow[];
|
|
98
|
+
checkpoints: Checkpoint[];
|
|
99
|
+
sources: Record<string, number>;
|
|
100
|
+
evidence: number;
|
|
101
|
+
issues: Record<"blocker" | "error" | "warning" | "info", number>;
|
|
102
|
+
invalid: Array<{ file: string; errors: string[] }>;
|
|
103
|
+
targetWords: number;
|
|
104
|
+
paperExists: boolean;
|
|
105
|
+
/** The WTF-P plugin while Clio serves it; null when it is not installed and enabled. */
|
|
106
|
+
served: ServedPlugin;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type Json = Record<string, unknown>;
|
|
110
|
+
type ServedPlugin = ExtensionContextV2["snapshot"]["plugin"];
|
|
111
|
+
|
|
112
|
+
/** Actions the desk answers itself; they stay reachable as `/ext:wtfp:<name>` without the plugin. */
|
|
113
|
+
const DESK_COMMANDS = new Set(["progress", "help", "check-todos"]);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* How the desk names an action: the plugin's `/wtfp:` prompt while the plugin
|
|
117
|
+
* serves it, else the desk's own `/ext:wtfp:` command, else null because only
|
|
118
|
+
* the plugin can run it.
|
|
119
|
+
*/
|
|
120
|
+
function commandFor(served: ServedPlugin, action: string, args = ""): string | null {
|
|
121
|
+
const tail = args ? ` ${args}` : "";
|
|
122
|
+
if (served?.prompts.includes(`wtfp:${action}`)) return `/wtfp:${action}${tail}`;
|
|
123
|
+
if (DESK_COMMANDS.has(action)) return `/ext:wtfp:${action}${tail}`;
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** An action's command, or its bare name where no command runs it here. */
|
|
128
|
+
function actionName(served: ServedPlugin, action: string): string {
|
|
129
|
+
return commandFor(served, action) ?? action;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function inside(root: string, candidate: string): boolean {
|
|
133
|
+
const relative = path.relative(root, candidate);
|
|
134
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readText(workspace: string, relative: string): string | null {
|
|
138
|
+
const file = path.resolve(workspace, relative);
|
|
139
|
+
if (!inside(workspace, file) || !existsSync(file)) return null;
|
|
140
|
+
const stat = lstatSync(file);
|
|
141
|
+
if (!stat.isFile() || stat.size > RECORD_LIMIT) return null;
|
|
142
|
+
return readFileSync(file, "utf8");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readRecord(workspace: string, relative: string): Json | null {
|
|
146
|
+
const text = readText(workspace, relative);
|
|
147
|
+
if (text === null) return null;
|
|
148
|
+
try {
|
|
149
|
+
const value = JSON.parse(text) as unknown;
|
|
150
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Json) : null;
|
|
151
|
+
} catch {
|
|
152
|
+
// An unreadable record shows up as a schema problem from validation, not here.
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function listJson(workspace: string, relative: string): string[] {
|
|
158
|
+
const directory = path.resolve(workspace, relative);
|
|
159
|
+
if (!inside(workspace, directory) || !existsSync(directory)) return [];
|
|
160
|
+
return readdirSync(directory, { withFileTypes: true })
|
|
161
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
162
|
+
.map((entry) => path.posix.join(relative, entry.name))
|
|
163
|
+
.sort();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function text(value: unknown, fallback = ""): string {
|
|
167
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** `project://paper/{artifact}` to a manuscript file, trying the authored formats in order. */
|
|
171
|
+
function manuscriptFile(workspace: string, uri: unknown): string | null {
|
|
172
|
+
if (typeof uri !== "string" || !uri.startsWith("project://paper/")) return null;
|
|
173
|
+
const artifact = uri.slice("project://paper/".length);
|
|
174
|
+
if (artifact.length === 0 || artifact.split("/").some((part) => part === ".." || part === "")) return null;
|
|
175
|
+
for (const candidate of [artifact, `${artifact}.md`, `${artifact}.tex`, `${artifact}.typ`]) {
|
|
176
|
+
const relative = path.posix.join("paper", candidate);
|
|
177
|
+
const file = path.resolve(workspace, relative);
|
|
178
|
+
if (inside(workspace, file) && existsSync(file) && lstatSync(file).isFile()) return relative;
|
|
179
|
+
}
|
|
180
|
+
return path.posix.join("paper", artifact);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The one counting method every meter and tolerance uses (see WORD_METHOD). */
|
|
184
|
+
export function bodyWords(source: string, file: string): number {
|
|
185
|
+
let body = source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/u, "");
|
|
186
|
+
body = body.replace(/```[\s\S]*?```/gu, " ").replace(/`[^`\n]*`/gu, " ");
|
|
187
|
+
body = body.replace(/<!--[\s\S]*?-->/gu, " ");
|
|
188
|
+
body = body.replace(/\$\$[\s\S]*?\$\$/gu, " ").replace(/\$[^$\n]*\$/gu, " ");
|
|
189
|
+
if (file.endsWith(".tex")) {
|
|
190
|
+
body = body.replace(/(^|[^\\])%.*$/gmu, "$1");
|
|
191
|
+
body = body.replace(/\\(?:begin|end)\{[^}]*\}/gu, " ");
|
|
192
|
+
body = body.replace(/\\(?:cite[a-z]*|ref|eqref|autoref|label|input|include|bibliography\w*)\*?(?:\[[^\]]*\])*\{[^}]*\}/gu, " ");
|
|
193
|
+
body = body.replace(/\\(?:section|subsection|subsubsection|chapter|paragraph)\*?\{[^}]*\}/gu, " ");
|
|
194
|
+
body = body.replace(/\\[a-zA-Z@]+\*?/gu, " ");
|
|
195
|
+
}
|
|
196
|
+
if (file.endsWith(".typ")) body = body.replace(/^\s*=+\s.*$/gmu, " ").replace(/\/\/.*$/gmu, " ").replace(/#[a-z-]+(?:\([^)]*\))?/gu, " ");
|
|
197
|
+
body = body.replace(/^\s{0,3}#{1,6}\s.*$/gmu, " ");
|
|
198
|
+
body = body.replace(/\[@[^\]]+\]|@[A-Za-z][\w:-]+/gu, " ");
|
|
199
|
+
body = body.replace(/!\[[^\]]*\]\([^)]*\)/gu, " ").replace(/\[([^\]]*)\]\([^)]*\)/gu, "$1");
|
|
200
|
+
return (body.match(/[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu) ?? []).length;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function sectionGates(checkpoints: Checkpoint[], id: string, manuscript: string | null): Checkpoint[] {
|
|
204
|
+
const scopes = [`project://sections/${id}`, ...(manuscript ? [`project://${manuscript}`] : [])];
|
|
205
|
+
return checkpoints.filter(
|
|
206
|
+
(checkpoint) =>
|
|
207
|
+
checkpoint.status === "pending" &&
|
|
208
|
+
checkpoint.blocking &&
|
|
209
|
+
scopes.some((scope) => checkpoint.scope_uri === scope || checkpoint.scope_uri.startsWith(`${scope}/`)),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let registry: InstanceType<typeof SchemaRegistry> | undefined;
|
|
214
|
+
function schemaRegistry(): InstanceType<typeof SchemaRegistry> {
|
|
215
|
+
registry ??= new SchemaRegistry(
|
|
216
|
+
readdirSync(SCHEMAS)
|
|
217
|
+
.filter((name) => name.endsWith(".schema.json"))
|
|
218
|
+
.map((name) => path.join(SCHEMAS, name)),
|
|
219
|
+
);
|
|
220
|
+
return registry;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Schema errors for one record, as the canonical validator reports them. */
|
|
224
|
+
function validateRecord(value: unknown): string[] {
|
|
225
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return ["$: planning record must be a JSON object"];
|
|
226
|
+
const match = /^wtfp\.project\.([a-z][a-z0-9-]*)\/v1$/u.exec(text((value as Json).schema));
|
|
227
|
+
if (!match) return ["$: missing or unsupported wtfp.project.<record>/v1 schema discriminator"];
|
|
228
|
+
const schemaFile = path.join(SCHEMAS, `${match[1]}.schema.json`);
|
|
229
|
+
if (!existsSync(schemaFile)) return [`$: no canonical schema for record type ${match[1]}`];
|
|
230
|
+
const schemas = schemaRegistry();
|
|
231
|
+
return validateInstance(value, schemas.get(schemaFile), schemaFile, schemas);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function validateFile(workspace: string, relative: string): string[] {
|
|
235
|
+
const source = readText(workspace, relative);
|
|
236
|
+
if (source === null) return [];
|
|
237
|
+
try {
|
|
238
|
+
return validateRecord(JSON.parse(source));
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return [`$: invalid JSON (${error instanceof Error ? error.message : String(error)})`];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function planningRecords(workspace: string, relative = ".planning"): string[] {
|
|
245
|
+
const directory = path.resolve(workspace, relative);
|
|
246
|
+
if (!existsSync(directory)) return [];
|
|
247
|
+
const out: string[] = [];
|
|
248
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
249
|
+
const child = path.posix.join(relative, entry.name);
|
|
250
|
+
if (entry.isDirectory() && entry.name !== "archives") out.push(...planningRecords(workspace, child));
|
|
251
|
+
else if (entry.isFile() && entry.name.endsWith(".json")) out.push(child);
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function loadDesk(workspace: string): Desk {
|
|
257
|
+
const manifest = readRecord(workspace, ".planning/project.json");
|
|
258
|
+
const config = readRecord(workspace, ".planning/config.json");
|
|
259
|
+
const state = readRecord(workspace, ".planning/state.json");
|
|
260
|
+
const outline = readRecord(workspace, ".planning/structure/outline.json");
|
|
261
|
+
const target = (manifest?.target ?? {}) as Json;
|
|
262
|
+
const progress = (state?.progress ?? {}) as Json;
|
|
263
|
+
const checkpoints: Checkpoint[] = listJson(workspace, ".planning/checkpoints").flatMap((file) => {
|
|
264
|
+
const record = readRecord(workspace, file);
|
|
265
|
+
if (!record || typeof record.id !== "string") return [];
|
|
266
|
+
return [
|
|
267
|
+
{
|
|
268
|
+
...record,
|
|
269
|
+
file,
|
|
270
|
+
id: record.id,
|
|
271
|
+
kind: text(record.kind, "decision"),
|
|
272
|
+
status: text(record.status, "pending"),
|
|
273
|
+
blocking: record.blocking === true,
|
|
274
|
+
scope_uri: text(record.scope_uri),
|
|
275
|
+
request: text(record.request, record.id),
|
|
276
|
+
} as Checkpoint,
|
|
277
|
+
];
|
|
278
|
+
});
|
|
279
|
+
checkpoints.sort((a, b) => text(a.created_at).localeCompare(text(b.created_at)) || a.id.localeCompare(b.id));
|
|
280
|
+
const outlined = Array.isArray(outline?.sections) ? (outline.sections as Json[]) : [];
|
|
281
|
+
const sections: SectionRow[] = outlined.map((entry) => {
|
|
282
|
+
const id = text(entry.id, "section");
|
|
283
|
+
const base = `.planning/sections/${id}`;
|
|
284
|
+
const record = readRecord(workspace, `${base}/section.json`);
|
|
285
|
+
const artifacts = (record?.artifacts ?? {}) as Json;
|
|
286
|
+
const manuscript = manuscriptFile(workspace, artifacts.manuscript ?? `project://paper/${id}`);
|
|
287
|
+
const source = manuscript ? readText(workspace, manuscript) : null;
|
|
288
|
+
const words = source === null || manuscript === null ? 0 : bodyWords(source, manuscript);
|
|
289
|
+
const status = text(record?.status, "not-started");
|
|
290
|
+
const claims = Array.isArray(record?.claims) ? (record.claims as Json[]) : [];
|
|
291
|
+
const plans = Array.isArray(artifacts.plans) ? artifacts.plans : [];
|
|
292
|
+
const reviews = Array.isArray(artifacts.reviews) ? artifacts.reviews : [];
|
|
293
|
+
const research = (entry.research ?? {}) as Json;
|
|
294
|
+
return {
|
|
295
|
+
id,
|
|
296
|
+
title: text(entry.title, id),
|
|
297
|
+
wave: typeof entry.wave === "number" ? entry.wave : 1,
|
|
298
|
+
status,
|
|
299
|
+
target: typeof entry.word_target === "number" ? entry.word_target : Number(record?.word_target ?? 0),
|
|
300
|
+
words,
|
|
301
|
+
manuscript,
|
|
302
|
+
claims: {
|
|
303
|
+
total: claims.length,
|
|
304
|
+
verified: claims.filter((claim) => claim.status === "verified").length,
|
|
305
|
+
unsupported: claims.filter(
|
|
306
|
+
(claim) =>
|
|
307
|
+
claim.evidence_required === true &&
|
|
308
|
+
(!Array.isArray(claim.evidence_uris) || claim.evidence_uris.length === 0),
|
|
309
|
+
).length,
|
|
310
|
+
},
|
|
311
|
+
stages: {
|
|
312
|
+
discuss: existsSync(path.join(workspace, base, "context.md")),
|
|
313
|
+
plan: plans.length > 0 || ["planned", "writing", "reviewing", "complete"].includes(status),
|
|
314
|
+
write: words > 0 || ["reviewing", "complete"].includes(status),
|
|
315
|
+
review: reviews.length > 0 || status === "complete",
|
|
316
|
+
},
|
|
317
|
+
gates: sectionGates(checkpoints, id, manuscript),
|
|
318
|
+
research: { required: research.required === true, present: existsSync(path.join(workspace, base, "research.md")) },
|
|
319
|
+
dependsOn: Array.isArray(entry.depends_on) ? entry.depends_on.filter((dep): dep is string => typeof dep === "string") : [],
|
|
320
|
+
};
|
|
321
|
+
});
|
|
322
|
+
const sources: Record<string, number> = {};
|
|
323
|
+
for (const file of listJson(workspace, ".planning/sources")) {
|
|
324
|
+
const status = text(readRecord(workspace, file)?.status, "unknown");
|
|
325
|
+
sources[status] = (sources[status] ?? 0) + 1;
|
|
326
|
+
}
|
|
327
|
+
const issues = { blocker: 0, error: 0, warning: 0, info: 0 };
|
|
328
|
+
for (const file of listJson(workspace, ".planning/validations")) {
|
|
329
|
+
const record = readRecord(workspace, file);
|
|
330
|
+
for (const issue of Array.isArray(record?.issues) ? (record.issues as Json[]) : []) {
|
|
331
|
+
const severity = text(issue.severity) as keyof typeof issues;
|
|
332
|
+
if (severity in issues) issues[severity] += 1;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const gates = (config?.gates ?? {}) as Json;
|
|
336
|
+
return {
|
|
337
|
+
workspace,
|
|
338
|
+
present: manifest !== null,
|
|
339
|
+
title: text(manifest?.title, "Untitled paper"),
|
|
340
|
+
documentType: text(manifest?.document_type, "paper"),
|
|
341
|
+
venue: typeof target.venue === "string" ? target.venue : null,
|
|
342
|
+
deadline: typeof target.deadline === "string" ? target.deadline : null,
|
|
343
|
+
argument: text(manifest?.core_argument),
|
|
344
|
+
phase: text(state?.phase, manifest ? "initialized" : "none"),
|
|
345
|
+
status: text(state?.status, "active"),
|
|
346
|
+
confirmPlan: gates.confirm_plan !== false,
|
|
347
|
+
sections,
|
|
348
|
+
checkpoints,
|
|
349
|
+
sources,
|
|
350
|
+
evidence: listJson(workspace, ".planning/evidence").length,
|
|
351
|
+
issues,
|
|
352
|
+
invalid: [],
|
|
353
|
+
targetWords:
|
|
354
|
+
typeof outline?.target_words === "number"
|
|
355
|
+
? outline.target_words
|
|
356
|
+
: typeof progress.word_target === "number"
|
|
357
|
+
? progress.word_target
|
|
358
|
+
: typeof target.word_limit === "number"
|
|
359
|
+
? target.word_limit
|
|
360
|
+
: sections.reduce((sum, section) => sum + section.target, 0),
|
|
361
|
+
paperExists: existsSync(path.join(workspace, "paper")),
|
|
362
|
+
served: null,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// --- the rule ladder ---------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
interface Next {
|
|
369
|
+
command: string | null;
|
|
370
|
+
reason: string;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** One rung of the ladder; a step only the absent plugin can run says so instead of naming it. */
|
|
374
|
+
function step(served: ServedPlugin, action: string, args: string, reason: string): Next {
|
|
375
|
+
const command = commandFor(served, action, args);
|
|
376
|
+
return command ? { command, reason } : { command: null, reason: `${reason} Its next step, ${action}, needs the WTF-P plugin.` };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function nextAction(desk: Desk): Next {
|
|
380
|
+
const served = desk.served;
|
|
381
|
+
if (!desk.present)
|
|
382
|
+
return desk.paperExists
|
|
383
|
+
? step(served, "map-project", "", "A manuscript exists but no WTF-P project records do.")
|
|
384
|
+
: step(served, "new-paper", "", "Start a project: brief, decisions and outline.");
|
|
385
|
+
const gate = desk.checkpoints.find((checkpoint) => checkpoint.status === "pending" && checkpoint.blocking);
|
|
386
|
+
if (gate) return step(served, "check-todos", "", `Gate waiting on you: ${gate.request}`);
|
|
387
|
+
if (desk.sections.length === 0) return step(served, "create-outline", "", "No outline yet.");
|
|
388
|
+
const complete = new Set(desk.sections.filter((section) => section.status === "complete").map((section) => section.id));
|
|
389
|
+
const ordered = [...desk.sections].sort((a, b) => a.wave - b.wave);
|
|
390
|
+
for (const section of ordered) {
|
|
391
|
+
if (section.status === "complete") continue;
|
|
392
|
+
if (!section.dependsOn.every((dep) => complete.has(dep)) && section.status === "not-started") continue;
|
|
393
|
+
return sectionNext(served, section);
|
|
394
|
+
}
|
|
395
|
+
if (desk.phase === "delivered") return { command: null, reason: "Delivered." };
|
|
396
|
+
if (desk.phase === "ready") return step(served, "submit-milestone", "", "Every section is complete and audited.");
|
|
397
|
+
return step(served, "audit-milestone", "", "Every section is complete; audit before delivery.");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The next step for one section, by the same ladder the band follows. */
|
|
401
|
+
function sectionNext(served: ServedPlugin, section: SectionRow): Next {
|
|
402
|
+
const id = section.id;
|
|
403
|
+
if (section.gates.length > 0) return step(served, "check-todos", "", `${section.title} waits on a gate.`);
|
|
404
|
+
switch (section.status) {
|
|
405
|
+
case "complete":
|
|
406
|
+
return step(served, "review-section", id, `${section.title} is complete; review it again if it changed.`);
|
|
407
|
+
case "blocked":
|
|
408
|
+
return step(served, "check-todos", "", `${section.title} is blocked.`);
|
|
409
|
+
case "reviewing":
|
|
410
|
+
return step(served, "review-section", id, `${section.title} is drafted and waits for review.`);
|
|
411
|
+
case "writing":
|
|
412
|
+
case "planned":
|
|
413
|
+
return step(served, "write-section", id, `${section.title} has an approved plan.`);
|
|
414
|
+
default:
|
|
415
|
+
if (!section.stages.discuss) return step(served, "discuss-section", id, `Settle what ${section.title} must argue.`);
|
|
416
|
+
if (section.research.required && !section.research.present)
|
|
417
|
+
return step(served, "research-gap", id, `${section.title} needs verified evidence first.`);
|
|
418
|
+
return step(served, "plan-section", id, `${section.title} is ready to plan.`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// --- views -------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
function meter(words: number, target: number, cells = 8): string {
|
|
425
|
+
if (target <= 0) return "";
|
|
426
|
+
const filled = Math.max(0, Math.min(cells, Math.round((words / target) * cells)));
|
|
427
|
+
return `${"▰".repeat(filled)}${"▱".repeat(cells - filled)}`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Within 15 percent of the target, by the same count the meter draws. */
|
|
431
|
+
function tolerance(words: number, target: number): ViewTone {
|
|
432
|
+
if (target <= 0 || words === 0) return "muted";
|
|
433
|
+
const delta = Math.abs(words - target) / target;
|
|
434
|
+
return delta <= 0.15 ? "positive" : words > target ? "warning" : "neutral";
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function stageStrip(section: SectionRow): string {
|
|
438
|
+
const active = (stage: keyof SectionRow["stages"]): boolean =>
|
|
439
|
+
(stage === "discuss" && section.status === "researching") ||
|
|
440
|
+
(stage === "write" && section.status === "writing") ||
|
|
441
|
+
(stage === "review" && section.status === "reviewing");
|
|
442
|
+
const mark = (stage: keyof SectionRow["stages"], letter: string): string =>
|
|
443
|
+
`${letter}${section.stages[stage] && !active(stage) ? "✓" : active(stage) ? "▸" : "·"}`;
|
|
444
|
+
return `${mark("discuss", "D")} ${mark("plan", "P")} ${mark("write", "W")} ${mark("review", "R")}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function daysLeft(deadline: string | null): string | null {
|
|
448
|
+
if (!deadline) return null;
|
|
449
|
+
const due = Date.parse(`${deadline}T23:59:59Z`);
|
|
450
|
+
if (Number.isNaN(due)) return null;
|
|
451
|
+
const days = Math.ceil((due - Date.now()) / 86_400_000);
|
|
452
|
+
return days < 0 ? `${-days} days past ${deadline}` : days === 0 ? `due today (${deadline})` : `${days} days to ${deadline}`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function totalWords(desk: Desk): number {
|
|
456
|
+
return desk.sections.reduce((sum, section) => sum + section.words, 0);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function header(desk: Desk): View {
|
|
460
|
+
if (!desk.present)
|
|
461
|
+
return {
|
|
462
|
+
t: "box",
|
|
463
|
+
dir: "col",
|
|
464
|
+
children: [
|
|
465
|
+
{ t: "art", lines: WORDMARK, tone: "brand" },
|
|
466
|
+
{ t: "text", text: "Write The F***ing Paper · no project here yet", tone: "muted" },
|
|
467
|
+
],
|
|
468
|
+
};
|
|
469
|
+
const facts = [desk.documentType.replaceAll("-", " "), desk.venue, daysLeft(desk.deadline)].filter(
|
|
470
|
+
(fact): fact is string => typeof fact === "string",
|
|
471
|
+
);
|
|
472
|
+
const phaseIndex = PHASES.indexOf(desk.phase as (typeof PHASES)[number]);
|
|
473
|
+
return {
|
|
474
|
+
t: "box",
|
|
475
|
+
dir: "col",
|
|
476
|
+
children: [
|
|
477
|
+
{
|
|
478
|
+
t: "box",
|
|
479
|
+
dir: "row",
|
|
480
|
+
gap: 2,
|
|
481
|
+
children: [
|
|
482
|
+
{ t: "box", dir: "col", width: 42, children: [{ t: "art", lines: WORDMARK, tone: "brand" }] },
|
|
483
|
+
{
|
|
484
|
+
t: "box",
|
|
485
|
+
dir: "col",
|
|
486
|
+
grow: 1,
|
|
487
|
+
children: [
|
|
488
|
+
{ t: "text", text: desk.title, bold: true, wrap: "wrap" },
|
|
489
|
+
{ t: "text", text: facts.join(" · "), tone: "info" },
|
|
490
|
+
...(desk.argument ? [{ t: "text", text: desk.argument, tone: "muted", wrap: "wrap" } as View] : []),
|
|
491
|
+
],
|
|
492
|
+
},
|
|
493
|
+
],
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
t: "steps",
|
|
497
|
+
items: PHASES.map((phase, index) => ({
|
|
498
|
+
label: phase,
|
|
499
|
+
state:
|
|
500
|
+
desk.status === "blocked" && index === phaseIndex
|
|
501
|
+
? "blocked"
|
|
502
|
+
: index < phaseIndex || desk.phase === "delivered"
|
|
503
|
+
? "done"
|
|
504
|
+
: index === phaseIndex
|
|
505
|
+
? "active"
|
|
506
|
+
: "todo",
|
|
507
|
+
})),
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function board(desk: Desk): View {
|
|
514
|
+
if (desk.sections.length === 0)
|
|
515
|
+
return { t: "text", text: `No outline yet. ${nextAction(desk).command ?? ""}`.trim(), tone: "muted" };
|
|
516
|
+
const waves = [...new Set(desk.sections.map((section) => section.wave))].sort((a, b) => a - b).slice(0, 8);
|
|
517
|
+
return {
|
|
518
|
+
t: "board",
|
|
519
|
+
action: "section",
|
|
520
|
+
columns: waves.map((wave) => {
|
|
521
|
+
const cards = desk.sections.filter((section) => section.wave === wave);
|
|
522
|
+
const done = cards.filter((section) => section.status === "complete").length;
|
|
523
|
+
return {
|
|
524
|
+
title: `Wave ${wave} · ${done}/${cards.length}`,
|
|
525
|
+
tone: done === cards.length ? "positive" : "info",
|
|
526
|
+
cards: cards.slice(0, 60).map((section) => ({
|
|
527
|
+
key: section.id,
|
|
528
|
+
title: section.title,
|
|
529
|
+
detail: [
|
|
530
|
+
stageStrip(section),
|
|
531
|
+
section.target > 0 ? `${meter(section.words, section.target)} ${section.words}/${section.target}w` : `${section.words}w`,
|
|
532
|
+
section.claims.total > 0
|
|
533
|
+
? `${section.claims.verified}/${section.claims.total} claims${section.claims.unsupported > 0 ? `, ${section.claims.unsupported} unsupported` : ""}`
|
|
534
|
+
: "",
|
|
535
|
+
]
|
|
536
|
+
.filter(Boolean)
|
|
537
|
+
.join(" "),
|
|
538
|
+
badges: [
|
|
539
|
+
{
|
|
540
|
+
text: section.status,
|
|
541
|
+
tone: (section.status === "complete"
|
|
542
|
+
? "positive"
|
|
543
|
+
: section.status === "blocked"
|
|
544
|
+
? "error"
|
|
545
|
+
: section.status === "not-started"
|
|
546
|
+
? "muted"
|
|
547
|
+
: "info") as ViewTone,
|
|
548
|
+
},
|
|
549
|
+
...(section.gates.length > 0 ? [{ text: `GATE ×${section.gates.length}`, tone: "warning" as ViewTone }] : []),
|
|
550
|
+
...(section.words > 0 && section.target > 0
|
|
551
|
+
? [{ text: `${Math.round((section.words / section.target) * 100)}%`, tone: tolerance(section.words, section.target) }]
|
|
552
|
+
: []),
|
|
553
|
+
],
|
|
554
|
+
})),
|
|
555
|
+
};
|
|
556
|
+
}),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function agentLabel(agentId: string): string {
|
|
561
|
+
const agent = SKIN.agents?.[agentId];
|
|
562
|
+
return agent ? `${agent.glyph ?? ""} ${agent.label ?? agentId}`.trim() : agentId;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function islands(desk: Desk, running: Record<string, { agentId: string; at: number }>): ExtensionIsland[] {
|
|
566
|
+
const out: ExtensionIsland[] = [];
|
|
567
|
+
const gate = desk.checkpoints.find((checkpoint) => checkpoint.status === "pending" && checkpoint.blocking);
|
|
568
|
+
if (gate)
|
|
569
|
+
out.push({
|
|
570
|
+
key: "gate",
|
|
571
|
+
title: "⚑ Gate",
|
|
572
|
+
meta: gate.kind,
|
|
573
|
+
tone: "warning",
|
|
574
|
+
view: {
|
|
575
|
+
t: "box",
|
|
576
|
+
dir: "col",
|
|
577
|
+
children: [
|
|
578
|
+
{ t: "text", text: gate.request, wrap: "wrap", bold: true },
|
|
579
|
+
...(gate.options && gate.options.length > 0
|
|
580
|
+
? [
|
|
581
|
+
{
|
|
582
|
+
t: "list",
|
|
583
|
+
items: gate.options.slice(0, 6).map((option) => ({
|
|
584
|
+
key: option.id,
|
|
585
|
+
label: option.label,
|
|
586
|
+
...(option.implication ? { detail: option.implication } : {}),
|
|
587
|
+
mark: "◇",
|
|
588
|
+
})),
|
|
589
|
+
} as View,
|
|
590
|
+
]
|
|
591
|
+
: []),
|
|
592
|
+
{ t: "text", text: "Leader c answers it; only your answer resolves it.", tone: "muted" },
|
|
593
|
+
],
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
const verified = desk.sources.verified ?? 0;
|
|
597
|
+
const provisional = desk.sources.provisional ?? 0;
|
|
598
|
+
const lost = (desk.sources.unavailable ?? 0) + (desk.sources.retracted ?? 0);
|
|
599
|
+
const unsupported = desk.sections.reduce((sum, section) => sum + section.claims.unsupported, 0);
|
|
600
|
+
if (desk.present)
|
|
601
|
+
out.push({
|
|
602
|
+
key: "evidence",
|
|
603
|
+
title: "❝ Evidence ledger",
|
|
604
|
+
tone: unsupported > 0 || lost > 0 ? "warning" : "info",
|
|
605
|
+
view: {
|
|
606
|
+
t: "kv",
|
|
607
|
+
items: [
|
|
608
|
+
{ label: "sources verified", value: String(verified), tone: verified > 0 ? "positive" : "muted" },
|
|
609
|
+
{ label: "provisional", value: String(provisional), tone: provisional > 0 ? "warning" : "muted" },
|
|
610
|
+
...(lost > 0 ? [{ label: "unavailable or retracted", value: String(lost), tone: "error" as ViewTone }] : []),
|
|
611
|
+
{ label: "evidence items", value: String(desk.evidence) },
|
|
612
|
+
{ label: "claims without evidence", value: String(unsupported), tone: unsupported > 0 ? "warning" : "positive" },
|
|
613
|
+
],
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
for (const [runId, run] of Object.entries(running).slice(0, 2))
|
|
617
|
+
out.push({
|
|
618
|
+
key: `run-${runId}`,
|
|
619
|
+
title: agentLabel(run.agentId),
|
|
620
|
+
meta: `${Math.max(0, Math.round((Date.now() - run.at) / 1000))}s`,
|
|
621
|
+
tone: "info",
|
|
622
|
+
view: { t: "text", text: `working on ${desk.title}`, tone: "muted" },
|
|
623
|
+
});
|
|
624
|
+
if (desk.invalid.length > 0)
|
|
625
|
+
out.push({
|
|
626
|
+
key: "schema",
|
|
627
|
+
title: "✗ Schema",
|
|
628
|
+
meta: `${desk.invalid.length} record${desk.invalid.length === 1 ? "" : "s"}`,
|
|
629
|
+
tone: "error",
|
|
630
|
+
view: {
|
|
631
|
+
t: "list",
|
|
632
|
+
items: desk.invalid.slice(0, 4).map((record) => ({
|
|
633
|
+
key: record.file,
|
|
634
|
+
label: record.file.replace(/^\.planning\//u, ""),
|
|
635
|
+
detail: record.errors[0] ?? "",
|
|
636
|
+
tone: "error" as ViewTone,
|
|
637
|
+
})),
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
return out.slice(0, 4);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function footer(desk: Desk): View {
|
|
644
|
+
if (!desk.present) {
|
|
645
|
+
const start = commandFor(desk.served, "new-paper");
|
|
646
|
+
return {
|
|
647
|
+
t: "text",
|
|
648
|
+
text: `WTF-P · no .planning here · ${start ? `${start} starts one` : "the WTF-P plugin's new-paper starts one"}`,
|
|
649
|
+
tone: "muted",
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
const complete = desk.sections.filter((section) => section.status === "complete").length;
|
|
653
|
+
const issues = (["blocker", "error", "warning"] as const)
|
|
654
|
+
.filter((severity) => desk.issues[severity] > 0)
|
|
655
|
+
.map((severity) => `${desk.issues[severity]} ${severity}${desk.issues[severity] === 1 ? "" : "s"}`);
|
|
656
|
+
const next = nextAction(desk).command;
|
|
657
|
+
return {
|
|
658
|
+
t: "text",
|
|
659
|
+
tone: "muted",
|
|
660
|
+
text: [
|
|
661
|
+
`${totalWords(desk).toLocaleString("en-US")}/${desk.targetWords.toLocaleString("en-US")} words`,
|
|
662
|
+
`${complete}/${desk.sections.length} sections`,
|
|
663
|
+
issues.length > 0 ? `issues ${issues.join(", ")}` : "no open issues",
|
|
664
|
+
desk.invalid.length > 0 ? `schema ✗ ${desk.invalid.length}` : "schema ✓",
|
|
665
|
+
next ? `next ${next}` : "",
|
|
666
|
+
]
|
|
667
|
+
.filter(Boolean)
|
|
668
|
+
.join(" · "),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function rail(desk: Desk): View {
|
|
673
|
+
return {
|
|
674
|
+
t: "text",
|
|
675
|
+
text: desk.present
|
|
676
|
+
? `WTF-P ◆ ${desk.phase} ◆ ${totalWords(desk).toLocaleString("en-US")}/${desk.targetWords.toLocaleString("en-US")}`
|
|
677
|
+
: "WTF-P ◆ no project",
|
|
678
|
+
tone: "brand",
|
|
679
|
+
bold: true,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function band(desk: Desk): View {
|
|
684
|
+
const next = nextAction(desk);
|
|
685
|
+
return {
|
|
686
|
+
t: "box",
|
|
687
|
+
dir: "row",
|
|
688
|
+
gap: 2,
|
|
689
|
+
children: [
|
|
690
|
+
{ t: "box", dir: "col", grow: 1, children: [{ t: "text", text: `✎ ${next.reason}`, tone: "info" }] },
|
|
691
|
+
...(next.command
|
|
692
|
+
? [{ t: "actions", items: [{ id: "next", label: `Fill ${next.command}`, hotkey: "f", primary: true }] } as View]
|
|
693
|
+
: []),
|
|
694
|
+
],
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// --- session state -----------------------------------------------------------
|
|
699
|
+
|
|
700
|
+
type Running = Record<string, { agentId: string; at: number }>;
|
|
701
|
+
|
|
702
|
+
async function running(ctx: ExtensionContextV2): Promise<Running> {
|
|
703
|
+
return (await ctx.state.get<Running>("running")).value ?? {};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function deskFor(ctx: ExtensionContextV2): Promise<Desk> {
|
|
707
|
+
const desk = loadDesk(ctx.snapshot.workspace);
|
|
708
|
+
desk.invalid = (await ctx.state.get<Desk["invalid"]>("invalid")).value ?? [];
|
|
709
|
+
desk.served = ctx.snapshot.plugin ?? null;
|
|
710
|
+
return desk;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function picture(
|
|
714
|
+
ctx: ExtensionContextV2,
|
|
715
|
+
desk?: Desk,
|
|
716
|
+
active: string | null = ctx.snapshot.activeWorkspace,
|
|
717
|
+
): Promise<Omit<ExtensionOutputV2, "text">> {
|
|
718
|
+
const current = desk ?? (await deskFor(ctx));
|
|
719
|
+
if (!current.present && !current.paperExists && active === null) return { status: null, band: null, islands: null };
|
|
720
|
+
return {
|
|
721
|
+
status: current.present
|
|
722
|
+
? {
|
|
723
|
+
text: `WTF-P · ${current.phase} · ${totalWords(current).toLocaleString("en-US")}/${current.targetWords.toLocaleString("en-US")}w`,
|
|
724
|
+
tone: current.checkpoints.some((checkpoint) => checkpoint.status === "pending" && checkpoint.blocking) ? "warning" : "neutral",
|
|
725
|
+
}
|
|
726
|
+
: null,
|
|
727
|
+
band: band(current),
|
|
728
|
+
regions: { header: header(current), board: board(current), rail: rail(current), footer: footer(current) },
|
|
729
|
+
islands: islands(current, await running(ctx)),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Validate the named records; a newly invalid record gets one card naming it and the field. */
|
|
734
|
+
async function revalidate(ctx: ExtensionContextV2, files: string[]): Promise<View | undefined> {
|
|
735
|
+
const workspace = ctx.snapshot.workspace;
|
|
736
|
+
const before = (await ctx.state.get<Desk["invalid"]>("invalid")).value ?? [];
|
|
737
|
+
const current = new Map(before.map((record) => [record.file, record]));
|
|
738
|
+
const fresh: Desk["invalid"] = [];
|
|
739
|
+
for (const file of files) {
|
|
740
|
+
const errors = existsSync(path.join(workspace, file)) ? validateFile(workspace, file) : [];
|
|
741
|
+
const prior = current.get(file);
|
|
742
|
+
if (errors.length === 0) current.delete(file);
|
|
743
|
+
else {
|
|
744
|
+
current.set(file, { file, errors: errors.slice(0, 5) });
|
|
745
|
+
if (!prior || prior.errors[0] !== errors[0]) fresh.push({ file, errors });
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
await ctx.state.set("invalid", [...current.values()]);
|
|
749
|
+
if (fresh.length === 0) return undefined;
|
|
750
|
+
return {
|
|
751
|
+
t: "box",
|
|
752
|
+
dir: "col",
|
|
753
|
+
border: "round",
|
|
754
|
+
title: "WTF-P · schema violation",
|
|
755
|
+
children: fresh.slice(0, 4).flatMap((record) => [
|
|
756
|
+
{ t: "text", text: record.file, bold: true, tone: "error" } as View,
|
|
757
|
+
...record.errors.slice(0, 3).map((error) => ({ t: "text", text: ` ${error}`, tone: "muted", wrap: "wrap" }) as View),
|
|
758
|
+
]),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// --- gates -------------------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
const KEEP = "__keep";
|
|
765
|
+
|
|
766
|
+
function pendingGates(desk: Desk, skip: readonly string[]): Checkpoint[] {
|
|
767
|
+
return desk.checkpoints.filter(
|
|
768
|
+
(checkpoint) => checkpoint.status === "pending" && checkpoint.kind !== "state-snapshot" && !skip.includes(checkpoint.id),
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function gateStep(checkpoint: Checkpoint): InterviewStep {
|
|
773
|
+
const choices =
|
|
774
|
+
checkpoint.options && checkpoint.options.length > 0
|
|
775
|
+
? checkpoint.options.map((option) => ({
|
|
776
|
+
value: option.id,
|
|
777
|
+
label: option.label,
|
|
778
|
+
...(option.implication ? { detail: option.implication } : {}),
|
|
779
|
+
}))
|
|
780
|
+
: [
|
|
781
|
+
{ value: "__resolve", label: checkpoint.kind === "human-verify" ? "Verified" : "Done", detail: "Mark it resolved by you." },
|
|
782
|
+
{ value: "__waive", label: "Waive it", detail: "Record that you chose to skip it." },
|
|
783
|
+
];
|
|
784
|
+
return {
|
|
785
|
+
key: checkpoint.id,
|
|
786
|
+
title: checkpoint.blocking ? "Blocking gate" : "Pending task",
|
|
787
|
+
// The question carries the request; the intro adds only what it cannot.
|
|
788
|
+
intro: {
|
|
789
|
+
t: "box",
|
|
790
|
+
dir: "col",
|
|
791
|
+
children: [
|
|
792
|
+
...(checkpoint.context ? [{ t: "text", text: checkpoint.context, wrap: "wrap" } as View] : []),
|
|
793
|
+
{ t: "text", text: `${checkpoint.kind} · ${checkpoint.scope_uri.replace(/^project:\/\//u, "")}`, tone: "muted" },
|
|
794
|
+
],
|
|
795
|
+
},
|
|
796
|
+
questions: [
|
|
797
|
+
{
|
|
798
|
+
id: "choice",
|
|
799
|
+
label: checkpoint.request.length > 120 ? `${checkpoint.request.slice(0, 117)}...` : checkpoint.request,
|
|
800
|
+
kind: "single",
|
|
801
|
+
options: [...choices, { value: KEEP, label: "Keep it pending", detail: "Answer later; its writes stay blocked." }],
|
|
802
|
+
},
|
|
803
|
+
],
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function writeRecord(workspace: string, relative: string, value: Json): void {
|
|
808
|
+
const errors = validateRecord(value);
|
|
809
|
+
if (errors.length > 0) throw new Error(`${relative} would not validate: ${errors[0]}`);
|
|
810
|
+
const file = path.resolve(workspace, relative);
|
|
811
|
+
if (!inside(path.join(workspace, ".planning"), file)) throw new Error(`refusing to write outside .planning: ${relative}`);
|
|
812
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
813
|
+
const staging = `${file}.${randomUUID()}.partial`;
|
|
814
|
+
try {
|
|
815
|
+
writeFileSync(staging, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
|
816
|
+
renameSync(staging, file);
|
|
817
|
+
} finally {
|
|
818
|
+
if (existsSync(staging)) unlinkSync(staging);
|
|
819
|
+
}
|
|
820
|
+
const back = JSON.parse(readFileSync(file, "utf8")) as unknown;
|
|
821
|
+
if (JSON.stringify(back) !== JSON.stringify(value)) throw new Error(`readback of ${relative} differs from what was written`);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** The one place an author's answer is recorded: only from the interview the operator answered. */
|
|
825
|
+
function resolveGate(workspace: string, checkpoint: Checkpoint, choice: string): string {
|
|
826
|
+
const now = new Date().toISOString().replace(/\.\d{3}Z$/u, "Z");
|
|
827
|
+
const option = checkpoint.options?.find((entry) => entry.id === choice);
|
|
828
|
+
const waived = choice === "__waive";
|
|
829
|
+
const { file, ...stored } = checkpoint;
|
|
830
|
+
const record: Json = {
|
|
831
|
+
...stored,
|
|
832
|
+
status: waived ? "waived" : "resolved",
|
|
833
|
+
resolution: {
|
|
834
|
+
resolved_by: "author",
|
|
835
|
+
...(option ? { selected_option: option.id } : {}),
|
|
836
|
+
summary: option
|
|
837
|
+
? `The author chose "${option.label}" at the Clio WTF-P desk.`
|
|
838
|
+
: waived
|
|
839
|
+
? "The author waived this gate at the Clio WTF-P desk."
|
|
840
|
+
: "The author marked this done at the Clio WTF-P desk.",
|
|
841
|
+
resolved_at: now,
|
|
842
|
+
},
|
|
843
|
+
};
|
|
844
|
+
writeRecord(workspace, file, record);
|
|
845
|
+
const state = readRecord(workspace, ".planning/state.json");
|
|
846
|
+
const uri = `project://checkpoints/${checkpoint.id}`;
|
|
847
|
+
if (state && Array.isArray(state.active_checkpoint_uris) && state.active_checkpoint_uris.includes(uri)) {
|
|
848
|
+
writeRecord(workspace, ".planning/state.json", {
|
|
849
|
+
...state,
|
|
850
|
+
revision: typeof state.revision === "number" ? state.revision + 1 : 1,
|
|
851
|
+
active_checkpoint_uris: state.active_checkpoint_uris.filter((entry) => entry !== uri),
|
|
852
|
+
updated_at: now,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
return option ? `${checkpoint.id}: ${option.label}` : `${checkpoint.id}: ${waived ? "waived" : "done"}`;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function startGates(ctx: ExtensionContextV2): Promise<ExtensionOutputV2> {
|
|
859
|
+
const desk = await deskFor(ctx);
|
|
860
|
+
const pending = pendingGates(desk, []);
|
|
861
|
+
await ctx.state.set("gate-kept", []);
|
|
862
|
+
if (pending.length === 0)
|
|
863
|
+
return {
|
|
864
|
+
text: "No pending gates or author tasks.",
|
|
865
|
+
card: { t: "text", text: "✓ No pending gates or author tasks.", tone: "positive" },
|
|
866
|
+
};
|
|
867
|
+
return {
|
|
868
|
+
text: `${pending.length} pending gate${pending.length === 1 ? "" : "s"}.`,
|
|
869
|
+
interview: { id: "gate", title: "WTF-P gates", total: pending.length, step: gateStep(pending[0] as Checkpoint) },
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// --- runtime tools -----------------------------------------------------------
|
|
874
|
+
|
|
875
|
+
const IDENTIFIER = "[a-z][a-z0-9]*(?:-[a-z0-9]+)*";
|
|
876
|
+
/** The adapter mapping of project/README.md: logical URI to record type and project-relative file. */
|
|
877
|
+
const RECORD_URIS: ReadonlyArray<{ pattern: RegExp; type: string; file: (id: string) => string }> = [
|
|
878
|
+
{ pattern: /^project:\/\/manifest$/u, type: "manifest", file: () => ".planning/project.json" },
|
|
879
|
+
{ pattern: /^project:\/\/config$/u, type: "config", file: () => ".planning/config.json" },
|
|
880
|
+
{ pattern: /^project:\/\/state$/u, type: "state", file: () => ".planning/state.json" },
|
|
881
|
+
{ pattern: /^project:\/\/decisions$/u, type: "decisions", file: () => ".planning/decisions.json" },
|
|
882
|
+
{ pattern: /^project:\/\/structure\/outline$/u, type: "outline", file: () => ".planning/structure/outline.json" },
|
|
883
|
+
{ pattern: new RegExp(`^project://sections/(${IDENTIFIER})$`, "u"), type: "section", file: (id) => `.planning/sections/${id}/section.json` },
|
|
884
|
+
{ pattern: new RegExp(`^project://sources/(${IDENTIFIER})$`, "u"), type: "source", file: (id) => `.planning/sources/${id}.json` },
|
|
885
|
+
{ pattern: new RegExp(`^project://evidence/(${IDENTIFIER})$`, "u"), type: "evidence", file: (id) => `.planning/evidence/${id}.json` },
|
|
886
|
+
{ pattern: new RegExp(`^project://checkpoints/(${IDENTIFIER})$`, "u"), type: "checkpoint", file: (id) => `.planning/checkpoints/${id}.json` },
|
|
887
|
+
{ pattern: new RegExp(`^project://validations/(${IDENTIFIER})$`, "u"), type: "validation", file: (id) => `.planning/validations/${id}.json` },
|
|
888
|
+
];
|
|
889
|
+
const REVISIONED = new Set(["state", "outline", "decisions"]);
|
|
890
|
+
const STAMPED = new Set(["state", "outline", "decisions", "manifest", "section"]);
|
|
891
|
+
|
|
892
|
+
function timestamp(): string {
|
|
893
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/u, "Z");
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* The one writer of a portable record: the type and identifier must match the
|
|
898
|
+
* URI, the revision the caller read must still be current, and the record must
|
|
899
|
+
* validate before it replaces the file. The caller never chooses a revision or
|
|
900
|
+
* a timestamp; both follow from the write.
|
|
901
|
+
*/
|
|
902
|
+
function writePortableRecord(workspace: string, uri: string, input: Json, expectRevision?: number): { file: string; revision: number | null } {
|
|
903
|
+
const route = RECORD_URIS.find((entry) => entry.pattern.test(uri));
|
|
904
|
+
if (!route) throw new Error(`${uri} is not a logical record URI this adapter maps`);
|
|
905
|
+
const id = route.pattern.exec(uri)?.[1] ?? "";
|
|
906
|
+
if (input.schema !== `wtfp.project.${route.type}/v1`) throw new Error(`${uri} holds wtfp.project.${route.type}/v1 records`);
|
|
907
|
+
if (id && input.id !== id) throw new Error(`${uri} must hold the record whose id is ${id}`);
|
|
908
|
+
const file = route.file(id);
|
|
909
|
+
const manifest = readRecord(workspace, ".planning/project.json");
|
|
910
|
+
const projectId = route.type === "manifest" ? input.id : input.project_id;
|
|
911
|
+
if (manifest && typeof manifest.id === "string" && projectId !== manifest.id)
|
|
912
|
+
throw new Error(`${uri} belongs to project ${String(projectId)}, not ${manifest.id}`);
|
|
913
|
+
const existing = readRecord(workspace, file);
|
|
914
|
+
const record: Json = { ...input };
|
|
915
|
+
let revision: number | null = null;
|
|
916
|
+
if (REVISIONED.has(route.type)) {
|
|
917
|
+
const current = typeof existing?.revision === "number" ? existing.revision : null;
|
|
918
|
+
if (current !== null && expectRevision !== undefined && expectRevision !== current)
|
|
919
|
+
throw new Error(`${uri} is at revision ${current}, not ${expectRevision}; read it again before writing`);
|
|
920
|
+
revision = current === null ? (typeof input.revision === "number" ? input.revision : 0) : current + 1;
|
|
921
|
+
record.revision = revision;
|
|
922
|
+
}
|
|
923
|
+
if (STAMPED.has(route.type)) record.updated_at = timestamp();
|
|
924
|
+
writeRecord(workspace, file, record);
|
|
925
|
+
return { file, revision };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function measureSection(workspace: string, sectionId: string): { words: number; target: number; file: string | null; total: number } {
|
|
929
|
+
const desk = loadDesk(workspace);
|
|
930
|
+
const section = desk.sections.find((entry) => entry.id === sectionId);
|
|
931
|
+
if (!section) throw new Error(`no section ${sectionId} in the outline`);
|
|
932
|
+
const sectionFile = `.planning/sections/${sectionId}/section.json`;
|
|
933
|
+
const record = readRecord(workspace, sectionFile);
|
|
934
|
+
if (!record) throw new Error(`${sectionFile} does not exist yet`);
|
|
935
|
+
writePortableRecord(workspace, `project://sections/${sectionId}`, { ...record, word_count: section.words });
|
|
936
|
+
const total = totalWords(desk);
|
|
937
|
+
const state = readRecord(workspace, ".planning/state.json");
|
|
938
|
+
if (state && typeof state.progress === "object" && state.progress !== null)
|
|
939
|
+
writePortableRecord(workspace, "project://state", {
|
|
940
|
+
...state,
|
|
941
|
+
progress: { ...(state.progress as Json), word_count: total },
|
|
942
|
+
});
|
|
943
|
+
return { words: section.words, target: section.target, file: section.manuscript, total };
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// --- commands ----------------------------------------------------------------
|
|
947
|
+
|
|
948
|
+
function progressCard(desk: Desk): View {
|
|
949
|
+
const next = nextAction(desk);
|
|
950
|
+
const totals = totalWords(desk);
|
|
951
|
+
return {
|
|
952
|
+
t: "box",
|
|
953
|
+
dir: "col",
|
|
954
|
+
border: "round",
|
|
955
|
+
title: `WTF-P · ${desk.title}`,
|
|
956
|
+
meta: desk.phase,
|
|
957
|
+
children: [
|
|
958
|
+
{
|
|
959
|
+
t: "kv",
|
|
960
|
+
items: [
|
|
961
|
+
{ label: "words", value: `${totals.toLocaleString("en-US")} / ${desk.targetWords.toLocaleString("en-US")}`, tone: tolerance(totals, desk.targetWords) },
|
|
962
|
+
{ label: "sections", value: `${desk.sections.filter((section) => section.status === "complete").length} of ${desk.sections.length} complete` },
|
|
963
|
+
{
|
|
964
|
+
label: "pending gates and tasks",
|
|
965
|
+
value: String(pendingGates(desk, []).length),
|
|
966
|
+
tone: pendingGates(desk, []).length > 0 ? "warning" : "positive",
|
|
967
|
+
},
|
|
968
|
+
...(daysLeft(desk.deadline) ? [{ label: "deadline", value: daysLeft(desk.deadline) as string }] : []),
|
|
969
|
+
{ label: "next", value: next.command ? `${next.command} · ${next.reason}` : next.reason, tone: "info" },
|
|
970
|
+
],
|
|
971
|
+
},
|
|
972
|
+
...(desk.sections.length > 0
|
|
973
|
+
? [
|
|
974
|
+
{
|
|
975
|
+
t: "table",
|
|
976
|
+
columns: ["wave", "section", "status", "stages", "words", "claims", "gates"],
|
|
977
|
+
rows: desk.sections.map((section) => [
|
|
978
|
+
String(section.wave),
|
|
979
|
+
section.title,
|
|
980
|
+
section.status,
|
|
981
|
+
stageStrip(section),
|
|
982
|
+
section.target > 0 ? `${section.words}/${section.target}` : String(section.words),
|
|
983
|
+
`${section.claims.verified}/${section.claims.total}`,
|
|
984
|
+
String(section.gates.length),
|
|
985
|
+
]),
|
|
986
|
+
} as View,
|
|
987
|
+
]
|
|
988
|
+
: []),
|
|
989
|
+
{ t: "text", text: WORD_METHOD, tone: "muted", wrap: "wrap" },
|
|
990
|
+
],
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function progressText(desk: Desk): string {
|
|
995
|
+
const next = nextAction(desk);
|
|
996
|
+
if (!desk.present) return `No WTF-P project here. ${next.command ?? next.reason}`;
|
|
997
|
+
return [
|
|
998
|
+
`${desk.title} · ${desk.phase}`,
|
|
999
|
+
`${totalWords(desk)}/${desk.targetWords} words; ${desk.sections.filter((section) => section.status === "complete").length}/${desk.sections.length} sections complete`,
|
|
1000
|
+
...desk.sections.map((section) => ` w${section.wave} ${section.id}: ${section.status} ${stageStrip(section)} ${section.words}/${section.target}w`),
|
|
1001
|
+
next.command ? `Next: ${next.command} (${next.reason})` : `Next: ${next.reason}`,
|
|
1002
|
+
].join("\n");
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function helpCard(served: ServedPlugin): { text: string; card: View } {
|
|
1006
|
+
const availability = JSON.parse(
|
|
1007
|
+
readFileSync(path.join(PACKAGE_ROOT, "compatibility", "action-availability.json"), "utf8"),
|
|
1008
|
+
) as { actions: Array<{ id: string; status: string; unavailableCapabilities: string[] }> };
|
|
1009
|
+
const rows = availability.actions.map((entry) => {
|
|
1010
|
+
const contract = JSON.parse(readFileSync(path.join(PACKAGE_ROOT, "actions", `${entry.id}.json`), "utf8")) as {
|
|
1011
|
+
description?: string;
|
|
1012
|
+
};
|
|
1013
|
+
return [
|
|
1014
|
+
actionName(served, entry.id),
|
|
1015
|
+
entry.status === "available" ? text(contract.description) : `unavailable: ${entry.unavailableCapabilities.join(", ")}`,
|
|
1016
|
+
];
|
|
1017
|
+
});
|
|
1018
|
+
const guarded = Object.keys(GUARD.actions).map((id) => actionName(served, id));
|
|
1019
|
+
const local = [...DESK_COMMANDS].map((id) => actionName(served, id));
|
|
1020
|
+
return {
|
|
1021
|
+
text: rows.map((row) => `${row[0]} ${row[1]}`).join("\n"),
|
|
1022
|
+
card: {
|
|
1023
|
+
t: "box",
|
|
1024
|
+
dir: "col",
|
|
1025
|
+
border: "round",
|
|
1026
|
+
title: "WTF-P actions",
|
|
1027
|
+
meta: `${rows.length}`,
|
|
1028
|
+
children: [
|
|
1029
|
+
{ t: "table", columns: ["action", "what it does"], rows },
|
|
1030
|
+
{
|
|
1031
|
+
t: "text",
|
|
1032
|
+
wrap: "wrap",
|
|
1033
|
+
tone: "muted",
|
|
1034
|
+
text: `${served ? "" : "The WTF-P plugin is not installed and enabled, so the actions above are not commands here; install and enable it to run them. "}Answered locally with no model: ${local.join(", ")}, /ext:wtfp:checkpoints, /ext:wtfp:validate. Write guard: ${guarded.join(", ")} may write only under the roots each declares. A manuscript write waits for its section's blocking gates and approved plan. ${WORD_METHOD}`,
|
|
1035
|
+
},
|
|
1036
|
+
],
|
|
1037
|
+
},
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function checkpointsCard(desk: Desk): View {
|
|
1042
|
+
return desk.checkpoints.length === 0
|
|
1043
|
+
? { t: "text", text: "No checkpoints recorded.", tone: "muted" }
|
|
1044
|
+
: {
|
|
1045
|
+
t: "box",
|
|
1046
|
+
dir: "col",
|
|
1047
|
+
border: "round",
|
|
1048
|
+
title: "WTF-P checkpoints",
|
|
1049
|
+
meta: String(desk.checkpoints.length),
|
|
1050
|
+
children: [
|
|
1051
|
+
{
|
|
1052
|
+
t: "table",
|
|
1053
|
+
columns: ["id", "kind", "status", "blocking", "scope"],
|
|
1054
|
+
rows: desk.checkpoints.slice(0, 100).map((checkpoint) => [
|
|
1055
|
+
checkpoint.id,
|
|
1056
|
+
checkpoint.kind,
|
|
1057
|
+
checkpoint.status,
|
|
1058
|
+
checkpoint.blocking ? "yes" : "no",
|
|
1059
|
+
checkpoint.scope_uri.replace(/^project:\/\//u, ""),
|
|
1060
|
+
]),
|
|
1061
|
+
},
|
|
1062
|
+
],
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async function validateAll(ctx: ExtensionContextV2): Promise<ExtensionOutputV2> {
|
|
1067
|
+
const files = planningRecords(ctx.snapshot.workspace);
|
|
1068
|
+
const failures = files
|
|
1069
|
+
.map((file) => ({ file, errors: validateFile(ctx.snapshot.workspace, file) }))
|
|
1070
|
+
.filter((record) => record.errors.length > 0);
|
|
1071
|
+
await ctx.state.set(
|
|
1072
|
+
"invalid",
|
|
1073
|
+
failures.map((record) => ({ file: record.file, errors: record.errors.slice(0, 5) })),
|
|
1074
|
+
);
|
|
1075
|
+
const view: View =
|
|
1076
|
+
failures.length === 0
|
|
1077
|
+
? { t: "text", text: `✓ ${files.length} planning records valid against their schemas.`, tone: "positive" }
|
|
1078
|
+
: {
|
|
1079
|
+
t: "box",
|
|
1080
|
+
dir: "col",
|
|
1081
|
+
border: "round",
|
|
1082
|
+
title: "WTF-P · schema violations",
|
|
1083
|
+
meta: `${failures.length}/${files.length}`,
|
|
1084
|
+
children: failures.slice(0, 6).flatMap((record) => [
|
|
1085
|
+
{ t: "text", text: record.file, bold: true, tone: "error" } as View,
|
|
1086
|
+
...record.errors.slice(0, 3).map((error) => ({ t: "text", text: ` ${error}`, tone: "muted", wrap: "wrap" }) as View),
|
|
1087
|
+
]),
|
|
1088
|
+
};
|
|
1089
|
+
return {
|
|
1090
|
+
text: failures.length === 0 ? `${files.length} records valid.` : failures.map((record) => `${record.file}: ${record.errors[0]}`).join("\n"),
|
|
1091
|
+
card: view,
|
|
1092
|
+
...(await picture(ctx)),
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// --- the write guard ---------------------------------------------------------
|
|
1097
|
+
|
|
1098
|
+
interface Armed {
|
|
1099
|
+
action: string;
|
|
1100
|
+
roots: string[];
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function sectionForPaper(desk: Desk, relative: string): SectionRow | undefined {
|
|
1104
|
+
return desk.sections.find(
|
|
1105
|
+
(section) =>
|
|
1106
|
+
section.manuscript !== null &&
|
|
1107
|
+
(relative === section.manuscript || relative.startsWith(`${section.manuscript.replace(/\.(md|tex|typ)$/u, "")}.`)),
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
async function guard(ctx: ExtensionContextV2, args: unknown): Promise<ExtensionHookResult> {
|
|
1112
|
+
const target = args !== null && typeof args === "object" ? (args as Json).path : undefined;
|
|
1113
|
+
if (typeof target !== "string" || target.length === 0) return {};
|
|
1114
|
+
const workspace = ctx.snapshot.workspace;
|
|
1115
|
+
const served = ctx.snapshot.plugin ?? null;
|
|
1116
|
+
const absolute = path.resolve(workspace, target);
|
|
1117
|
+
const armed = (await ctx.state.get<Armed>("armed")).value;
|
|
1118
|
+
if (armed) {
|
|
1119
|
+
const roots = armed.roots.map((root) => path.resolve(workspace, root));
|
|
1120
|
+
if (!roots.some((root) => inside(root, absolute)))
|
|
1121
|
+
return {
|
|
1122
|
+
effects: [
|
|
1123
|
+
{
|
|
1124
|
+
kind: "block_tool",
|
|
1125
|
+
reason: `WTF-P write guard: ${actionName(served, armed.action)} may write only under ${armed.roots.join(", ")}; refused ${path.relative(workspace, absolute)}. Record other changes as a handoff instead.`,
|
|
1126
|
+
},
|
|
1127
|
+
],
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
if (!inside(path.join(workspace, "paper"), absolute)) return {};
|
|
1131
|
+
const desk = loadDesk(workspace);
|
|
1132
|
+
if (!desk.present) return {};
|
|
1133
|
+
const section = sectionForPaper(desk, path.relative(workspace, absolute).split(path.sep).join("/"));
|
|
1134
|
+
if (!section) return {};
|
|
1135
|
+
const gate = section.gates[0];
|
|
1136
|
+
if (gate)
|
|
1137
|
+
return {
|
|
1138
|
+
effects: [
|
|
1139
|
+
{
|
|
1140
|
+
kind: "block_tool",
|
|
1141
|
+
reason: `WTF-P gate: ${section.title} waits on the author's answer to "${gate.request}" (${gate.id}). Answer it with ${actionName(served, "check-todos")} before writing ${section.manuscript}.`,
|
|
1142
|
+
},
|
|
1143
|
+
],
|
|
1144
|
+
};
|
|
1145
|
+
if (desk.confirmPlan && (section.status === "not-started" || section.status === "researching"))
|
|
1146
|
+
return {
|
|
1147
|
+
effects: [
|
|
1148
|
+
{
|
|
1149
|
+
kind: "block_tool",
|
|
1150
|
+
reason: `WTF-P gate: ${section.title} has no approved plan, and confirm_plan is on. ${
|
|
1151
|
+
commandFor(served, "plan-section", section.id) !== null
|
|
1152
|
+
? `Run ${commandFor(served, "plan-section", section.id)} and approve it`
|
|
1153
|
+
: "Planning it needs the WTF-P plugin; approve a plan"
|
|
1154
|
+
} before writing ${section.manuscript}.`,
|
|
1155
|
+
},
|
|
1156
|
+
],
|
|
1157
|
+
};
|
|
1158
|
+
return {};
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// --- registration ------------------------------------------------------------
|
|
1162
|
+
|
|
1163
|
+
export default function extension(api: ExtensionApiV2): void {
|
|
1164
|
+
api.handle("desk", async (_args, ctx) => {
|
|
1165
|
+
const desk = await deskFor(ctx);
|
|
1166
|
+
return { text: progressText(desk), workspace: { enter: "desk" }, ...(await picture(ctx, desk, "desk")) };
|
|
1167
|
+
});
|
|
1168
|
+
api.handle("progress", async (_args, ctx) => {
|
|
1169
|
+
const desk = await deskFor(ctx);
|
|
1170
|
+
return { text: progressText(desk), card: progressCard(desk), ...(await picture(ctx, desk)) };
|
|
1171
|
+
});
|
|
1172
|
+
api.handle("help", async (_args, ctx) => helpCard(ctx.snapshot.plugin ?? null));
|
|
1173
|
+
api.handle("check-todos", async (_args, ctx) => startGates(ctx));
|
|
1174
|
+
api.handle("checkpoints", async (_args, ctx) => {
|
|
1175
|
+
const desk = await deskFor(ctx);
|
|
1176
|
+
return {
|
|
1177
|
+
text: desk.checkpoints.map((checkpoint) => `${checkpoint.id} ${checkpoint.kind} ${checkpoint.status}`).join("\n") || "No checkpoints.",
|
|
1178
|
+
card: checkpointsCard(desk),
|
|
1179
|
+
};
|
|
1180
|
+
});
|
|
1181
|
+
api.handle("validate", async (_args, ctx) => validateAll(ctx));
|
|
1182
|
+
|
|
1183
|
+
api.action("next", async (_event, ctx) => {
|
|
1184
|
+
const next = nextAction(await deskFor(ctx));
|
|
1185
|
+
return next.command ? { text: next.command, prompt: { fill: next.command } } : { text: next.reason, toast: { text: next.reason } };
|
|
1186
|
+
});
|
|
1187
|
+
api.action("gates", async (_event, ctx) => startGates(ctx));
|
|
1188
|
+
api.action("validate", async (_event, ctx) => validateAll(ctx));
|
|
1189
|
+
api.action("help", async (_event, ctx) => helpCard(ctx.snapshot.plugin ?? null));
|
|
1190
|
+
api.action("section", async (event, ctx) => {
|
|
1191
|
+
const desk = await deskFor(ctx);
|
|
1192
|
+
const section = desk.sections.find((entry) => entry.id === event.key);
|
|
1193
|
+
if (!section) return { text: "Unknown section." };
|
|
1194
|
+
const next = sectionNext(desk.served, section);
|
|
1195
|
+
return {
|
|
1196
|
+
text: `${section.title}: ${next.reason}`,
|
|
1197
|
+
toast: { text: `${section.title}: ${next.reason}` },
|
|
1198
|
+
...(next.command ? { prompt: { fill: next.command } } : {}),
|
|
1199
|
+
};
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
api.tool("record", async (input, ctx) => {
|
|
1203
|
+
const { uri, record, expect_revision } = input as { uri: string; record: Json; expect_revision?: number };
|
|
1204
|
+
try {
|
|
1205
|
+
const written = writePortableRecord(ctx.snapshot.workspace, uri, record, expect_revision);
|
|
1206
|
+
return {
|
|
1207
|
+
text: `Wrote ${uri}${written.revision === null ? "" : ` at revision ${written.revision}`}; it validated and read back.`,
|
|
1208
|
+
data: { uri, file: written.file, revision: written.revision },
|
|
1209
|
+
};
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
return { text: `Not written: ${error instanceof Error ? error.message : String(error)}`, isError: true };
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
api.tool("measure_section", async (input, ctx) => {
|
|
1215
|
+
try {
|
|
1216
|
+
const measured = measureSection(ctx.snapshot.workspace, (input as { section: string }).section);
|
|
1217
|
+
const share = measured.target > 0 ? Math.round((measured.words / measured.target) * 100) : null;
|
|
1218
|
+
return {
|
|
1219
|
+
text: `${measured.file ?? "the manuscript"} has ${measured.words} body words${share === null ? "" : ` (${share}% of ${measured.target}; within 15% counts as on target)`}. Recorded in the section and state records; the paper totals ${measured.total}. ${WORD_METHOD}`,
|
|
1220
|
+
data: measured,
|
|
1221
|
+
};
|
|
1222
|
+
} catch (error) {
|
|
1223
|
+
return { text: `Not measured: ${error instanceof Error ? error.message : String(error)}`, isError: true };
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
api.tool("gate", async (input, ctx) => {
|
|
1227
|
+
const id = (input as { checkpoint: string }).checkpoint;
|
|
1228
|
+
const checkpoint = loadDesk(ctx.snapshot.workspace).checkpoints.find((entry) => entry.id === id);
|
|
1229
|
+
if (!checkpoint) return { text: `No checkpoint ${id}.`, isError: true };
|
|
1230
|
+
if (checkpoint.status !== "pending") return { text: `${id} is already ${checkpoint.status}.` };
|
|
1231
|
+
return { text: `Asking the author: ${checkpoint.request}`, interview: { id: "gate-one", title: "WTF-P gate", total: 1, step: gateStep(checkpoint) } };
|
|
1232
|
+
});
|
|
1233
|
+
api.interview("gate-one", async (answer, ctx) => {
|
|
1234
|
+
if (answer.nav === "cancel") return { done: true, text: "The author did not answer; the gate stays pending." };
|
|
1235
|
+
const checkpoint = loadDesk(ctx.snapshot.workspace).checkpoints.find((entry) => entry.id === answer.step);
|
|
1236
|
+
const choice = answer.answers.choice;
|
|
1237
|
+
const picked = Array.isArray(choice) ? choice[0] : choice;
|
|
1238
|
+
if (!checkpoint || checkpoint.status !== "pending" || typeof picked !== "string")
|
|
1239
|
+
return { done: true, text: "The gate changed while it was asked; nothing was recorded." };
|
|
1240
|
+
if (picked === KEEP) return { done: true, text: `The author kept ${checkpoint.id} pending.` };
|
|
1241
|
+
const recorded = resolveGate(ctx.snapshot.workspace, checkpoint, picked);
|
|
1242
|
+
return { done: true, text: `Recorded the author's answer: ${recorded}.`, ...(await picture(ctx)) };
|
|
1243
|
+
});
|
|
1244
|
+
api.interview("gate", async (answer, ctx) => {
|
|
1245
|
+
if (answer.nav === "cancel")
|
|
1246
|
+
return { done: true, text: "Gates left as they were.", toast: { text: "WTF-P gates unchanged" }, ...(await picture(ctx)) };
|
|
1247
|
+
const kept = (await ctx.state.get<string[]>("gate-kept")).value ?? [];
|
|
1248
|
+
const desk = await deskFor(ctx);
|
|
1249
|
+
const checkpoint = desk.checkpoints.find((entry) => entry.id === answer.step);
|
|
1250
|
+
const choice = answer.answers.choice;
|
|
1251
|
+
const picked = Array.isArray(choice) ? choice[0] : choice;
|
|
1252
|
+
const recorded: string[] = (await ctx.state.get<string[]>("gate-recorded")).value ?? [];
|
|
1253
|
+
if (checkpoint && checkpoint.status === "pending" && typeof picked === "string") {
|
|
1254
|
+
if (picked === KEEP) kept.push(checkpoint.id);
|
|
1255
|
+
else recorded.push(resolveGate(ctx.snapshot.workspace, checkpoint, picked));
|
|
1256
|
+
}
|
|
1257
|
+
await ctx.state.set("gate-kept", kept);
|
|
1258
|
+
await ctx.state.set("gate-recorded", recorded);
|
|
1259
|
+
const remaining = pendingGates(loadDesk(ctx.snapshot.workspace), kept);
|
|
1260
|
+
const next = remaining[0];
|
|
1261
|
+
if (next) return { step: gateStep(next), total: remaining.length + recorded.length + kept.length };
|
|
1262
|
+
await ctx.state.set("gate-recorded", []);
|
|
1263
|
+
const summary = recorded.length > 0 ? `Recorded: ${recorded.join("; ")}.` : "Nothing recorded.";
|
|
1264
|
+
return {
|
|
1265
|
+
done: true,
|
|
1266
|
+
text: summary,
|
|
1267
|
+
card: { t: "text", text: `✓ ${summary}`, tone: recorded.length > 0 ? "positive" : "muted", wrap: "wrap" },
|
|
1268
|
+
...(await picture(ctx)),
|
|
1269
|
+
};
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
api.hook("prompt_submit", async (event, ctx) => {
|
|
1273
|
+
const typed = event.point === "prompt_submit" ? (event.text ?? "") : "";
|
|
1274
|
+
const match = /^\s*\/wtfp:([a-z][a-z0-9-]*)\b/u.exec(typed);
|
|
1275
|
+
const roots = match ? GUARD.actions[match[1] as string] : undefined;
|
|
1276
|
+
if (!match || !roots) {
|
|
1277
|
+
await ctx.state.delete("armed");
|
|
1278
|
+
return {};
|
|
1279
|
+
}
|
|
1280
|
+
await ctx.state.set("armed", { action: match[1], roots } satisfies Armed);
|
|
1281
|
+
return {
|
|
1282
|
+
effects: [
|
|
1283
|
+
{
|
|
1284
|
+
kind: "notify_operator",
|
|
1285
|
+
key: "wtfp-guard",
|
|
1286
|
+
message: `WTF-P write guard armed for ${actionName(ctx.snapshot.plugin ?? null, match[1] as string)}: write and edit stay under ${roots.join(", ")}.`,
|
|
1287
|
+
},
|
|
1288
|
+
],
|
|
1289
|
+
};
|
|
1290
|
+
});
|
|
1291
|
+
api.hook("before_tool", async (event, ctx) =>
|
|
1292
|
+
event.point === "before_tool" ? guard(ctx, event.args) : {},
|
|
1293
|
+
);
|
|
1294
|
+
|
|
1295
|
+
api.on("session_open", async (_event, ctx) => ({ text: "", ...(await picture(ctx)) }));
|
|
1296
|
+
api.on("workspace_enter", async (_event, ctx) => ({ text: "", ...(await picture(ctx)) }));
|
|
1297
|
+
// Leaving means the desk no longer holds the screen, whatever the snapshot says yet.
|
|
1298
|
+
api.on("workspace_leave", async (_event, ctx) => ({ text: "", ...(await picture(ctx, undefined, null)) }));
|
|
1299
|
+
api.on("fs_changed", async (event, ctx) => {
|
|
1300
|
+
if (event.event !== "fs_changed") return undefined;
|
|
1301
|
+
const records = event.paths.filter((file) => file.startsWith(".planning/") && file.endsWith(".json") && !file.includes("/archives/"));
|
|
1302
|
+
const card = records.length > 0 ? await revalidate(ctx, records) : undefined;
|
|
1303
|
+
return { text: "", ...(card ? { card } : {}), ...(await picture(ctx)) };
|
|
1304
|
+
});
|
|
1305
|
+
api.on("dispatch_started", async (event, ctx) => {
|
|
1306
|
+
if (event.event !== "dispatch_started" || !event.agentId.startsWith("wtfp-")) return undefined;
|
|
1307
|
+
const runs = await running(ctx);
|
|
1308
|
+
runs[event.runId] = { agentId: event.agentId, at: Date.now() };
|
|
1309
|
+
await ctx.state.set("running", runs);
|
|
1310
|
+
return { text: "", ...(await picture(ctx)) };
|
|
1311
|
+
});
|
|
1312
|
+
const finished = async (runId: string, ctx: ExtensionContextV2): Promise<ExtensionOutputV2 | undefined> => {
|
|
1313
|
+
const runs = await running(ctx);
|
|
1314
|
+
if (!(runId in runs)) return undefined;
|
|
1315
|
+
delete runs[runId];
|
|
1316
|
+
await ctx.state.set("running", runs);
|
|
1317
|
+
return { text: "", ...(await picture(ctx)) };
|
|
1318
|
+
};
|
|
1319
|
+
api.on("dispatch_completed", async (event, ctx) => (event.event === "dispatch_completed" ? finished(event.runId, ctx) : undefined));
|
|
1320
|
+
api.on("dispatch_failed", async (event, ctx) => (event.event === "dispatch_failed" ? finished(event.runId, ctx) : undefined));
|
|
1321
|
+
}
|