supipowers 1.5.3 → 2.0.1
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 +14 -8
- package/bin/install.mjs +20 -5
- package/bin/install.ts +95 -0
- package/package.json +8 -4
- package/skills/context-mode/SKILL.md +17 -10
- package/skills/harness/SKILL.md +94 -0
- package/skills/ui-design/SKILL.md +63 -0
- package/skills/ui-design/sub-agent-templates/component-builder.md +29 -0
- package/skills/ui-design/sub-agent-templates/design-critic.md +46 -0
- package/skills/ui-design/sub-agent-templates/pencil/component-builder.md +29 -0
- package/skills/ui-design/sub-agent-templates/pencil/design-critic.md +42 -0
- package/skills/ui-design/sub-agent-templates/pencil/section-assembler.md +27 -0
- package/skills/ui-design/sub-agent-templates/section-assembler.md +27 -0
- package/skills/ultraplan-discover/SKILL.md +96 -0
- package/skills/ultraplan-intake/SKILL.md +89 -0
- package/skills/ultraplan-research/SKILL.md +129 -0
- package/skills/ultraplan-review/SKILL.md +86 -0
- package/skills/ultraplan-review-scope/SKILL.md +111 -0
- package/skills/ultraplan-review-structure/SKILL.md +120 -0
- package/skills/ultraplan-review-tdd/SKILL.md +142 -0
- package/skills/ultraplan-scout/SKILL.md +110 -0
- package/skills/ultraplan-synthesize/SKILL.md +124 -0
- package/src/{quality/ai-session.ts → ai/final-message.ts} +27 -0
- package/src/ai/schema-text.ts +129 -0
- package/src/ai/structured-output.ts +274 -0
- package/src/ai/template.ts +27 -0
- package/src/bootstrap.ts +63 -28
- package/src/commands/agents.ts +131 -42
- package/src/commands/ai-review.ts +251 -30
- package/src/commands/clear.ts +434 -0
- package/src/commands/commit.ts +1 -0
- package/src/commands/config.ts +242 -44
- package/src/commands/context.ts +55 -28
- package/src/commands/doctor.ts +234 -6
- package/src/commands/fix-pr.ts +306 -131
- package/src/commands/generate.ts +111 -21
- package/src/commands/memory.ts +192 -0
- package/src/commands/model-picker.ts +28 -21
- package/src/commands/model.ts +18 -8
- package/src/commands/optimize-context.ts +408 -29
- package/src/commands/plan.ts +2 -0
- package/src/commands/qa.ts +312 -137
- package/src/commands/release.ts +259 -76
- package/src/commands/review.ts +293 -59
- package/src/commands/status.ts +200 -13
- package/src/commands/supi.ts +3 -35
- package/src/commands/ui-design.ts +394 -0
- package/src/commands/ultraplan.ts +1518 -0
- package/src/commands/update.ts +86 -0
- package/src/config/defaults.ts +62 -0
- package/src/config/loader.ts +448 -60
- package/src/config/schema.ts +108 -2
- package/src/context/optimizer.ts +25 -33
- package/src/context/rule-renderer.ts +223 -0
- package/src/context/savings.ts +258 -0
- package/src/context/startup-check.ts +380 -0
- package/src/context/startup-optimizer.ts +355 -0
- package/src/context/tokenignore.ts +146 -0
- package/src/context-mode/cache-handle.ts +49 -0
- package/src/context-mode/cache-preview.ts +71 -0
- package/src/context-mode/cache-store.ts +738 -0
- package/src/context-mode/compressor.ts +131 -26
- package/src/context-mode/dedup.ts +108 -0
- package/src/context-mode/detector.ts +35 -4
- package/src/context-mode/event-extractor.ts +14 -12
- package/src/context-mode/event-store.ts +91 -36
- package/src/context-mode/hooks.ts +798 -56
- package/src/context-mode/knowledge/store.ts +255 -11
- package/src/context-mode/memory-store.ts +325 -0
- package/src/context-mode/metrics-recorder.ts +158 -0
- package/src/context-mode/metrics-store.ts +765 -0
- package/src/context-mode/model.ts +24 -0
- package/src/context-mode/processor-keys.ts +29 -0
- package/src/context-mode/processors/build.ts +66 -0
- package/src/context-mode/processors/docker.ts +57 -0
- package/src/context-mode/processors/git.ts +111 -0
- package/src/context-mode/processors/json.ts +112 -0
- package/src/context-mode/processors/k8s.ts +67 -0
- package/src/context-mode/processors/lint.ts +67 -0
- package/src/context-mode/processors/log.ts +86 -0
- package/src/context-mode/processors/registry.ts +116 -0
- package/src/context-mode/processors/test-runner.ts +102 -0
- package/src/context-mode/processors/types.ts +20 -0
- package/src/context-mode/repomap.ts +400 -0
- package/src/context-mode/routing.ts +97 -24
- package/src/context-mode/sandbox/runners.ts +5 -1
- package/src/context-mode/snapshot-builder.ts +106 -11
- package/src/context-mode/source-hash.ts +173 -0
- package/src/context-mode/tool-name.ts +11 -0
- package/src/context-mode/tools.ts +654 -22
- package/src/context-mode/web/fetcher.ts +31 -12
- package/src/debug/logger.ts +2 -1
- package/src/deps/registry.ts +1 -1
- package/src/discipline/failure-summarizer.ts +170 -0
- package/src/discipline/failure-taxonomy.ts +131 -0
- package/src/discipline/workflow-invariants.ts +125 -0
- package/src/discovery/index.ts +31 -0
- package/src/discovery/lsp.ts +87 -0
- package/src/discovery/rank.ts +144 -0
- package/src/discovery/sources.ts +89 -0
- package/src/discovery/workflow.ts +87 -0
- package/src/docs/contracts.ts +39 -0
- package/src/docs/drift.ts +117 -87
- package/src/fix-pr/assessment.ts +200 -0
- package/src/fix-pr/contracts.ts +47 -0
- package/src/fix-pr/fetch-comments.ts +80 -0
- package/src/fix-pr/prompt-builder.ts +58 -40
- package/src/fix-pr/scripts/exec.ts +34 -0
- package/src/fix-pr/scripts/trigger-review.ts +106 -0
- package/src/fix-pr/scripts/wait-and-check.ts +108 -0
- package/src/fix-pr/types.ts +4 -0
- package/src/git/branch-finish.ts +5 -0
- package/src/git/commit-contract.ts +83 -0
- package/src/git/commit.ts +121 -184
- package/src/git/status.ts +62 -8
- package/src/harness/anti_slop/architecture-parser.ts +210 -0
- package/src/harness/anti_slop/backend-factory.ts +30 -0
- package/src/harness/anti_slop/backend.ts +140 -0
- package/src/harness/anti_slop/desloppify-adapter.ts +319 -0
- package/src/harness/anti_slop/fallow-adapter.ts +305 -0
- package/src/harness/anti_slop/installer.ts +227 -0
- package/src/harness/anti_slop/queue.ts +216 -0
- package/src/harness/anti_slop/recommend.ts +84 -0
- package/src/harness/anti_slop/score.ts +180 -0
- package/src/harness/anti_slop/synthetic-edit-test.ts +128 -0
- package/src/harness/artifacts/agents-md.ts +88 -0
- package/src/harness/artifacts/checks-wiring.ts +57 -0
- package/src/harness/artifacts/docs-tree.ts +79 -0
- package/src/harness/artifacts/lint-configs.ts +136 -0
- package/src/harness/artifacts/review-agents.ts +67 -0
- package/src/harness/bare-entry.ts +108 -0
- package/src/harness/command.ts +1010 -0
- package/src/harness/default-agents/design.md +23 -0
- package/src/harness/default-agents/discover.md +18 -0
- package/src/harness/default-agents/implement.md +24 -0
- package/src/harness/default-agents/plan.md +19 -0
- package/src/harness/default-agents/research.md +21 -0
- package/src/harness/default-agents/validate.md +22 -0
- package/src/harness/gc/reporter.ts +28 -0
- package/src/harness/gc/runner.ts +136 -0
- package/src/harness/hooks/layer-context-inject.ts +155 -0
- package/src/harness/hooks/post-session-sweep.ts +130 -0
- package/src/harness/hooks/pre-edit-dupe-probe.ts +224 -0
- package/src/harness/hooks/register.ts +118 -0
- package/src/harness/model.ts +117 -0
- package/src/harness/pipeline.ts +348 -0
- package/src/harness/project-paths.ts +235 -0
- package/src/harness/stage-runner.ts +107 -0
- package/src/harness/stages/design.ts +386 -0
- package/src/harness/stages/discover.ts +454 -0
- package/src/harness/stages/implement.ts +162 -0
- package/src/harness/stages/plan.ts +335 -0
- package/src/harness/stages/research.ts +263 -0
- package/src/harness/stages/validate.ts +684 -0
- package/src/harness/storage.ts +467 -0
- package/src/harness/tools.ts +426 -0
- package/src/lsp/bridge.ts +56 -95
- package/src/lsp/capabilities.ts +108 -0
- package/src/lsp/contracts.ts +35 -0
- package/src/lsp/detector.ts +8 -12
- package/src/markdown-frontmatter.ts +68 -0
- package/src/mempalace/bridge.ts +135 -0
- package/src/mempalace/config.ts +75 -0
- package/src/mempalace/format.ts +163 -0
- package/src/mempalace/hooks.ts +370 -0
- package/src/mempalace/installer-helper.ts +194 -0
- package/src/mempalace/python/mempalace_bridge.py +440 -0
- package/src/mempalace/runtime.ts +565 -0
- package/src/mempalace/schema.ts +268 -0
- package/src/mempalace/session-summary.ts +198 -0
- package/src/mempalace/tool.ts +186 -0
- package/src/mempalace/uv.ts +256 -0
- package/src/migrate/runner.ts +354 -0
- package/src/planning/approval-flow.ts +206 -9
- package/src/planning/plan-writer-prompt.ts +4 -3
- package/src/planning/planning-ask-tool.ts +39 -0
- package/src/planning/render-markdown.ts +74 -0
- package/src/planning/spec.ts +42 -0
- package/src/planning/system-prompt.ts +11 -8
- package/src/planning/validate.ts +84 -0
- package/src/platform/omp.ts +15 -2
- package/src/platform/system-prompt.ts +37 -0
- package/src/platform/test-utils.ts +3 -0
- package/src/platform/types.ts +6 -1
- package/src/qa/config.ts +12 -6
- package/src/qa/detect-app-type.ts +13 -6
- package/src/qa/matrix.ts +12 -6
- package/src/qa/prompt-builder.ts +28 -30
- package/src/qa/scripts/dev-server-utils.ts +72 -0
- package/src/qa/scripts/run-e2e-tests.ts +226 -0
- package/src/qa/scripts/start-dev-server.ts +138 -0
- package/src/qa/scripts/stop-dev-server.ts +77 -0
- package/src/qa/session.ts +13 -7
- package/src/quality/ai-setup.ts +27 -25
- package/src/quality/contracts.ts +34 -0
- package/src/quality/gates/ai-review.ts +20 -58
- package/src/quality/gates/command.ts +249 -46
- package/src/quality/review-gates.ts +18 -2
- package/src/quality/runner.ts +63 -22
- package/src/quality/schemas.ts +37 -2
- package/src/quality/setup.ts +96 -16
- package/src/release/changelog.ts +1 -1
- package/src/release/channels/custom.ts +13 -3
- package/src/release/channels/types.ts +5 -0
- package/src/release/contracts.ts +90 -0
- package/src/release/executor.ts +122 -45
- package/src/release/prompt.ts +18 -2
- package/src/release/targets.ts +86 -0
- package/src/release/version.ts +96 -71
- package/src/review/agent-loader.ts +221 -109
- package/src/review/fixer.ts +10 -6
- package/src/review/multi-agent-runner.ts +114 -13
- package/src/review/output.ts +12 -139
- package/src/review/runner.ts +12 -6
- package/src/review/scope.ts +144 -24
- package/src/review/types.ts +1 -20
- package/src/review/validator.ts +12 -6
- package/src/storage/fix-pr-sessions.ts +21 -14
- package/src/storage/plans.ts +14 -5
- package/src/storage/qa-sessions.ts +25 -19
- package/src/storage/reliability-metrics.ts +180 -0
- package/src/storage/reports.ts +8 -7
- package/src/storage/review-sessions.ts +55 -20
- package/src/tool-catalog/active-tool-controller.ts +164 -0
- package/src/tool-catalog/active-tool-planner.ts +212 -0
- package/src/tool-catalog/tool-groups.ts +102 -0
- package/src/types.ts +1399 -5
- package/src/ui-design/backend-adapter.ts +78 -0
- package/src/ui-design/backends/local-html.ts +82 -0
- package/src/ui-design/backends/pencil-mcp.ts +111 -0
- package/src/ui-design/components-scanner.ts +124 -0
- package/src/ui-design/config.ts +55 -0
- package/src/ui-design/pen-scanner.ts +95 -0
- package/src/ui-design/pen-selector.ts +72 -0
- package/src/ui-design/prompt-builder.ts +73 -0
- package/src/ui-design/scanner.ts +136 -0
- package/src/ui-design/session.ts +974 -0
- package/src/ui-design/system-prompt.ts +312 -0
- package/src/ui-design/tokens-scanner.ts +181 -0
- package/src/ui-design/types.ts +96 -0
- package/src/ultraplan/agent-catalog.ts +522 -0
- package/src/ultraplan/authoring/agent-catalog.ts +310 -0
- package/src/ultraplan/authoring/authoring-tools.ts +552 -0
- package/src/ultraplan/authoring/command-handlers.ts +339 -0
- package/src/ultraplan/authoring/markdown.ts +510 -0
- package/src/ultraplan/authoring/model.ts +162 -0
- package/src/ultraplan/authoring/pipeline.ts +319 -0
- package/src/ultraplan/authoring/stage-runner.ts +141 -0
- package/src/ultraplan/authoring/stages/approve.ts +249 -0
- package/src/ultraplan/authoring/stages/discover.ts +289 -0
- package/src/ultraplan/authoring/stages/intake.ts +203 -0
- package/src/ultraplan/authoring/stages/research.ts +399 -0
- package/src/ultraplan/authoring/stages/review.ts +333 -0
- package/src/ultraplan/authoring/stages/scout.ts +188 -0
- package/src/ultraplan/authoring/stages/synthesize.ts +348 -0
- package/src/ultraplan/authoring/storage.ts +594 -0
- package/src/ultraplan/authoring/synth-gate.ts +165 -0
- package/src/ultraplan/authoring-draft.ts +653 -0
- package/src/ultraplan/authoring-persist.ts +180 -0
- package/src/ultraplan/authoring-tool.ts +608 -0
- package/src/ultraplan/authoring-wizard.ts +587 -0
- package/src/ultraplan/batch/merge.ts +98 -0
- package/src/ultraplan/batch/planner.ts +150 -0
- package/src/ultraplan/batch/presenter.ts +97 -0
- package/src/ultraplan/batch/storage.ts +420 -0
- package/src/ultraplan/batch/supervisor.ts +317 -0
- package/src/ultraplan/batch/worker.ts +26 -0
- package/src/ultraplan/batch/worktree.ts +110 -0
- package/src/ultraplan/contracts.ts +1593 -0
- package/src/ultraplan/default-agents/authoring/discoverer.md +12 -0
- package/src/ultraplan/default-agents/authoring/intake.md +12 -0
- package/src/ultraplan/default-agents/authoring/planner.md +12 -0
- package/src/ultraplan/default-agents/authoring/researcher.md +12 -0
- package/src/ultraplan/default-agents/authoring/scope-checker.md +12 -0
- package/src/ultraplan/default-agents/authoring/scout.md +12 -0
- package/src/ultraplan/default-agents/authoring/structure-checker.md +12 -0
- package/src/ultraplan/default-agents/authoring/tdd-checker.md +12 -0
- package/src/ultraplan/default-agents/backend-domain-reviewer.md +10 -0
- package/src/ultraplan/default-agents/backend-executor.md +10 -0
- package/src/ultraplan/default-agents/backend-stack-reviewer.md +10 -0
- package/src/ultraplan/default-agents/backend-tester.md +10 -0
- package/src/ultraplan/default-agents/frontend-domain-reviewer.md +10 -0
- package/src/ultraplan/default-agents/frontend-executor.md +10 -0
- package/src/ultraplan/default-agents/frontend-stack-reviewer.md +10 -0
- package/src/ultraplan/default-agents/frontend-tester.md +10 -0
- package/src/ultraplan/default-agents/infrastructure-domain-reviewer.md +10 -0
- package/src/ultraplan/default-agents/infrastructure-executor.md +10 -0
- package/src/ultraplan/default-agents/infrastructure-stack-reviewer.md +10 -0
- package/src/ultraplan/default-agents/infrastructure-tester.md +10 -0
- package/src/ultraplan/execution/contract.ts +71 -0
- package/src/ultraplan/execution/policy.ts +217 -0
- package/src/ultraplan/execution/runtime-tools.ts +107 -0
- package/src/ultraplan/execution/session-runner.ts +281 -0
- package/src/ultraplan/next-router.ts +85 -0
- package/src/ultraplan/presenter.ts +359 -0
- package/src/ultraplan/project-paths.ts +342 -0
- package/src/ultraplan/runtime/active-execution.ts +72 -0
- package/src/ultraplan/runtime/apply-mutation.ts +416 -0
- package/src/ultraplan/runtime/blockers.ts +243 -0
- package/src/ultraplan/runtime/hook-bridge.ts +486 -0
- package/src/ultraplan/runtime/launch-context.ts +207 -0
- package/src/ultraplan/runtime/migration.ts +524 -0
- package/src/ultraplan/runtime/normalize.ts +281 -0
- package/src/ultraplan/runtime/proof.ts +260 -0
- package/src/ultraplan/runtime/reducer.ts +416 -0
- package/src/ultraplan/runtime/repair.ts +251 -0
- package/src/ultraplan/runtime/tracker-storage.ts +368 -0
- package/src/ultraplan/session-selection.ts +291 -0
- package/src/ultraplan/storage.ts +374 -0
- package/src/utils/editor.ts +38 -0
- package/src/utils/executable.ts +80 -0
- package/src/utils/paths.ts +1 -20
- package/src/utils/shell.ts +31 -0
- package/src/visual/companion.ts +2 -1
- package/src/visual/scripts/frame-template.html +60 -0
- package/src/visual/scripts/index.js +59 -13
- package/src/visual/scripts/package.json +3 -0
- package/src/visual/start-server.ts +2 -1
- package/src/workspace/git-scope.ts +64 -0
- package/src/workspace/locks.ts +23 -0
- package/src/workspace/package-manager.ts +117 -0
- package/src/workspace/path-mapping.ts +75 -0
- package/src/workspace/project-slug.ts +92 -0
- package/src/workspace/repo-root.ts +137 -0
- package/src/workspace/selector.ts +115 -0
- package/src/workspace/state-paths.ts +118 -0
- package/src/workspace/targets.ts +313 -0
- package/src/fix-pr/scripts/diff-comments.sh +0 -33
- package/src/fix-pr/scripts/fetch-pr-comments.sh +0 -25
- package/src/fix-pr/scripts/trigger-review.sh +0 -36
- package/src/fix-pr/scripts/wait-and-check.sh +0 -37
- package/src/qa/scripts/detect-app-type.sh +0 -68
- package/src/qa/scripts/discover-routes.sh +0 -143
- package/src/qa/scripts/run-e2e-tests.sh +0 -131
- package/src/qa/scripts/start-dev-server.sh +0 -46
- package/src/qa/scripts/stop-dev-server.sh +0 -36
- package/src/review/prompts/fix-output-schema.md +0 -18
- package/src/review/prompts/review-output-schema.md +0 -38
- package/src/review/template.ts +0 -15
- /package/src/{review → ai}/prompts/invalid-output-retry.md +0 -0
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { applyModelOverride } from "../config/model-resolver.js";
|
|
5
|
+
import type { Platform, PlatformPaths } from "../platform/types.js";
|
|
6
|
+
import { getProjectStatePath } from "../workspace/state-paths.js";
|
|
7
|
+
import type { Manifest, ManifestStatus, UiDesignSession } from "./types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Session-lifecycle state for `/supi:ui-design`.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the planning approval-flow pattern: one active session at a time,
|
|
13
|
+
* tracked via module-level state; swapped cleanly on new invocations; cleaned
|
|
14
|
+
* up by the `agent_end` hook when the director terminates.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
let activeSession: UiDesignSession | null = null;
|
|
18
|
+
let activeCleanup: (() => Promise<void>) | null = null;
|
|
19
|
+
let lastProgressFingerprint: string | null = null;
|
|
20
|
+
|
|
21
|
+
const MANIFEST_STATUSES = new Set<ManifestStatus>([
|
|
22
|
+
"in-progress",
|
|
23
|
+
"critiquing",
|
|
24
|
+
"awaiting-review",
|
|
25
|
+
"complete",
|
|
26
|
+
"discarded",
|
|
27
|
+
]);
|
|
28
|
+
const COMPLETION_PROOF_FILE = "completion-proof.json";
|
|
29
|
+
const REVIEW_APPROVAL_FILE = "review-approval.json";
|
|
30
|
+
const PATH_URI_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
31
|
+
const GLOB_META_RE = /[*?\[\]{}]/;
|
|
32
|
+
|
|
33
|
+
type UiDesignReviewDecision = "approve" | "request-changes" | "discard";
|
|
34
|
+
|
|
35
|
+
interface UiDesignReviewApprovalRecord {
|
|
36
|
+
question: string;
|
|
37
|
+
options: string[];
|
|
38
|
+
selected: UiDesignReviewDecision;
|
|
39
|
+
selectedLabel: string;
|
|
40
|
+
recordedAt: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface CompletionProof {
|
|
44
|
+
valid: boolean;
|
|
45
|
+
validatedAt: string;
|
|
46
|
+
issues: string[];
|
|
47
|
+
page: string;
|
|
48
|
+
critiquePath: string;
|
|
49
|
+
reviewPath: string;
|
|
50
|
+
approvalRecordPath: string;
|
|
51
|
+
approvalDecision: UiDesignReviewDecision | null;
|
|
52
|
+
critique: NonNullable<Manifest["critique"]>;
|
|
53
|
+
/** Set only for pencil-mcp sessions; records the absolute .pen path audited. */
|
|
54
|
+
penFilePath?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const RESUME_STEER_TEMPLATE = (sessionDir: string): string =>
|
|
58
|
+
[
|
|
59
|
+
"Continue the /supi:ui-design run.",
|
|
60
|
+
`Read \`${path.join(sessionDir, "manifest.json")}\` and \`${path.join(sessionDir, "decomposition.json")}\` for state;`,
|
|
61
|
+
"resume the first phase whose precondition output is missing.",
|
|
62
|
+
].join(" ");
|
|
63
|
+
|
|
64
|
+
const REPAIR_COMPLETE_STEER_TEMPLATE = (sessionDir: string, completionIssues: string[]): string =>
|
|
65
|
+
[
|
|
66
|
+
"Continue the /supi:ui-design run.",
|
|
67
|
+
`The manifest at \`${path.join(sessionDir, "manifest.json")}\` claims \`status: \"complete\"\` but completion validation failed: ${completionIssues.join(", ")}.`,
|
|
68
|
+
`Read \`${path.join(sessionDir, "manifest.json")}\`, \`${path.join(sessionDir, "page.html")}\`, \`${path.join(sessionDir, "critique.md")}\`, and \`${path.join(sessionDir, "screen-review.html")}\` if present, then resume the first incomplete review/finalization phase and rewrite the manifest truthfully.`,
|
|
69
|
+
].join(" ");
|
|
70
|
+
|
|
71
|
+
const REPAIR_COMPLETE_STEER_TEMPLATE_PENCIL = (
|
|
72
|
+
sessionDir: string,
|
|
73
|
+
penFilePath: string | undefined,
|
|
74
|
+
completionIssues: string[],
|
|
75
|
+
): string => {
|
|
76
|
+
const penInstruction = penFilePath
|
|
77
|
+
? `Re-open \`${penFilePath}\` via \`mcp__pencil_open_document\` and inspect`
|
|
78
|
+
: "The manifest is missing `penFilePath` — read `manifest.json`, restore the absolute .pen path, then inspect";
|
|
79
|
+
return [
|
|
80
|
+
"Continue the /supi:ui-design run.",
|
|
81
|
+
`The manifest at \`${path.join(sessionDir, "manifest.json")}\` claims \`status: \"complete\"\` but completion validation failed: ${completionIssues.join(", ")}.`,
|
|
82
|
+
`${penInstruction} \`${path.join(sessionDir, "node-manifest.json")}\`, \`${path.join(sessionDir, "critique.md")}\`, and \`${path.join(sessionDir, "screen-review.png")}\` if present, then resume the first incomplete review/finalization phase and rewrite the manifest truthfully.`,
|
|
83
|
+
].join(" ");
|
|
84
|
+
};
|
|
85
|
+
export function generateUiDesignSessionId(): string {
|
|
86
|
+
const now = new Date();
|
|
87
|
+
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
|
|
88
|
+
const time = now.toISOString().slice(11, 19).replace(/:/g, "");
|
|
89
|
+
const suffix = Math.random().toString(36).slice(2, 6).padEnd(4, "0");
|
|
90
|
+
return `uidesign-${date}-${time}-${suffix}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createSessionDir(paths: PlatformPaths, cwd: string, sessionId: string): string {
|
|
94
|
+
const dir = getProjectStatePath(paths, cwd, "ui-design", sessionId);
|
|
95
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
96
|
+
return dir;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function snapshotSessionProgress(sessionDir: string): string | null {
|
|
100
|
+
if (!fs.existsSync(sessionDir)) return null;
|
|
101
|
+
|
|
102
|
+
const hash = createHash("sha256");
|
|
103
|
+
|
|
104
|
+
const visit = (currentDir: string): void => {
|
|
105
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
|
|
106
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
107
|
+
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
const absolutePath = path.join(currentDir, entry.name);
|
|
110
|
+
const relativePath = path.relative(sessionDir, absolutePath);
|
|
111
|
+
const stats = fs.statSync(absolutePath);
|
|
112
|
+
|
|
113
|
+
if (entry.isDirectory()) {
|
|
114
|
+
hash.update(`dir:${relativePath}:${stats.mtimeMs}\n`);
|
|
115
|
+
visit(absolutePath);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
hash.update(`file:${relativePath}:${stats.size}:${stats.mtimeMs}\n`);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
visit(sessionDir);
|
|
125
|
+
return hash.digest("hex");
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function markSessionProgress(sessionDir: string): void {
|
|
132
|
+
lastProgressFingerprint = snapshotSessionProgress(sessionDir);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sessionMadeProgress(sessionDir: string): boolean {
|
|
136
|
+
const nextFingerprint = snapshotSessionProgress(sessionDir);
|
|
137
|
+
if (nextFingerprint === null || nextFingerprint === lastProgressFingerprint) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
lastProgressFingerprint = nextFingerprint;
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
export function startUiDesignTracking(
|
|
147
|
+
session: UiDesignSession,
|
|
148
|
+
cleanup: () => Promise<void>,
|
|
149
|
+
): void {
|
|
150
|
+
// Swap: run previous cleanup, never block startup on its resolution.
|
|
151
|
+
const previousCleanup = activeCleanup;
|
|
152
|
+
activeSession = session;
|
|
153
|
+
activeCleanup = cleanup;
|
|
154
|
+
markSessionProgress(session.dir);
|
|
155
|
+
if (previousCleanup) {
|
|
156
|
+
// Fire-and-forget: we already replaced the active references, so a
|
|
157
|
+
// failing previous cleanup cannot leak state into the new session.
|
|
158
|
+
previousCleanup().catch(() => {});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function cancelUiDesignTracking(_reason: string): void {
|
|
163
|
+
activeSession = null;
|
|
164
|
+
activeCleanup = null;
|
|
165
|
+
lastProgressFingerprint = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function isUiDesignActive(): boolean {
|
|
169
|
+
return activeSession !== null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function getActiveUiDesignSession(): UiDesignSession | null {
|
|
173
|
+
return activeSession;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Idempotent cleanup used from session_start / session_shutdown hooks.
|
|
178
|
+
* Runs the active cleanup (if any) and clears tracking state.
|
|
179
|
+
*/
|
|
180
|
+
export async function stopActiveUiDesignSession(): Promise<void> {
|
|
181
|
+
await runCleanup();
|
|
182
|
+
activeSession = null;
|
|
183
|
+
activeCleanup = null;
|
|
184
|
+
lastProgressFingerprint = null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isStringArray(value: unknown): value is string[] {
|
|
188
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isCritiqueSummary(value: unknown): value is NonNullable<Manifest["critique"]> {
|
|
192
|
+
if (!value || typeof value !== "object") return false;
|
|
193
|
+
const record = value as Record<string, unknown>;
|
|
194
|
+
return (
|
|
195
|
+
typeof record.fixableCount === "number" &&
|
|
196
|
+
typeof record.advisoryCount === "number" &&
|
|
197
|
+
typeof record.fixIterations === "number"
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isManifest(value: unknown): value is Manifest {
|
|
202
|
+
if (!value || typeof value !== "object") return false;
|
|
203
|
+
|
|
204
|
+
const record = value as Record<string, unknown>;
|
|
205
|
+
return (
|
|
206
|
+
typeof record.id === "string" &&
|
|
207
|
+
typeof record.backend === "string" &&
|
|
208
|
+
typeof record.status === "string" &&
|
|
209
|
+
MANIFEST_STATUSES.has(record.status as ManifestStatus) &&
|
|
210
|
+
typeof record.acknowledged === "boolean" &&
|
|
211
|
+
typeof record.createdAt === "string" &&
|
|
212
|
+
typeof record.page === "string" &&
|
|
213
|
+
isStringArray(record.components) &&
|
|
214
|
+
isStringArray(record.sections) &&
|
|
215
|
+
(record.scope === undefined || record.scope === "page" || record.scope === "flow" || record.scope === "component") &&
|
|
216
|
+
(record.topic === undefined || typeof record.topic === "string") &&
|
|
217
|
+
(record.approvedAt === undefined || typeof record.approvedAt === "string") &&
|
|
218
|
+
(record.critique === undefined || isCritiqueSummary(record.critique)) &&
|
|
219
|
+
(record.penFilePath === undefined || typeof record.penFilePath === "string")
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function readManifest(sessionDir: string): Manifest | null {
|
|
224
|
+
const manifestPath = path.join(sessionDir, "manifest.json");
|
|
225
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
226
|
+
try {
|
|
227
|
+
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
228
|
+
return isManifest(parsed) ? parsed : null;
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function resolvePathWithinDir(
|
|
235
|
+
rootDir: string,
|
|
236
|
+
candidatePath: string,
|
|
237
|
+
baseDirs: string[] = [rootDir],
|
|
238
|
+
): string | null {
|
|
239
|
+
if (candidatePath.trim().length === 0 || PATH_URI_RE.test(candidatePath)) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const root = path.resolve(rootDir);
|
|
244
|
+
for (const baseDir of baseDirs) {
|
|
245
|
+
const resolved = path.resolve(baseDir, candidatePath);
|
|
246
|
+
const relative = path.relative(root, resolved);
|
|
247
|
+
if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
248
|
+
return resolved;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function resolveSessionArtifactPath(sessionDir: string, relativePath: string): string | null {
|
|
256
|
+
return resolvePathWithinDir(sessionDir, relativePath);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function readTextFile(filePath: string): string | null {
|
|
260
|
+
try {
|
|
261
|
+
return fs.readFileSync(filePath, "utf-8");
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function looksLikeHtmlDocument(html: string): boolean {
|
|
268
|
+
const normalized = html.toLowerCase();
|
|
269
|
+
return (
|
|
270
|
+
normalized.includes("<html") &&
|
|
271
|
+
normalized.includes("</html>") &&
|
|
272
|
+
normalized.includes("<body") &&
|
|
273
|
+
normalized.includes("</body>")
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function validateHtmlArtifact(filePath: string, label: string, issues: string[]): void {
|
|
278
|
+
const html = readTextFile(filePath);
|
|
279
|
+
if (html === null) {
|
|
280
|
+
issues.push(`${label} unreadable`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!looksLikeHtmlDocument(html)) {
|
|
284
|
+
issues.push(`${label} is not a full HTML document`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function normalizeReviewDecision(label: string): UiDesignReviewDecision | null {
|
|
289
|
+
const normalized = label.trim().toLowerCase().replace(/[^a-z]+/g, "");
|
|
290
|
+
switch (normalized) {
|
|
291
|
+
case "approve":
|
|
292
|
+
case "approved":
|
|
293
|
+
return "approve";
|
|
294
|
+
case "requestchanges":
|
|
295
|
+
case "requestchange":
|
|
296
|
+
return "request-changes";
|
|
297
|
+
case "discard":
|
|
298
|
+
case "discarded":
|
|
299
|
+
return "discard";
|
|
300
|
+
default:
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function hasReviewDecisionSet(options: string[]): boolean {
|
|
306
|
+
const decisions = new Set(
|
|
307
|
+
options
|
|
308
|
+
.map((option) => normalizeReviewDecision(option))
|
|
309
|
+
.filter((decision): decision is UiDesignReviewDecision => decision !== null),
|
|
310
|
+
);
|
|
311
|
+
return (
|
|
312
|
+
decisions.has("approve") &&
|
|
313
|
+
decisions.has("request-changes") &&
|
|
314
|
+
decisions.has("discard")
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function isReviewApprovalRecord(value: unknown): value is UiDesignReviewApprovalRecord {
|
|
319
|
+
if (!value || typeof value !== "object") return false;
|
|
320
|
+
const record = value as Record<string, unknown>;
|
|
321
|
+
return (
|
|
322
|
+
typeof record.question === "string" &&
|
|
323
|
+
isStringArray(record.options) &&
|
|
324
|
+
(record.selected === "approve" ||
|
|
325
|
+
record.selected === "request-changes" ||
|
|
326
|
+
record.selected === "discard") &&
|
|
327
|
+
typeof record.selectedLabel === "string" &&
|
|
328
|
+
typeof record.recordedAt === "string"
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function recordUiDesignReviewApproval(
|
|
333
|
+
question: string,
|
|
334
|
+
options: string[],
|
|
335
|
+
selectedLabel: string,
|
|
336
|
+
): void {
|
|
337
|
+
const session = activeSession;
|
|
338
|
+
if (!session) return;
|
|
339
|
+
|
|
340
|
+
const selected = normalizeReviewDecision(selectedLabel);
|
|
341
|
+
if (!selected || !hasReviewDecisionSet(options)) return;
|
|
342
|
+
// Require a review artifact matching the active backend. Pencil sessions
|
|
343
|
+
// produce a PNG exported from the .pen file; local-html produces an HTML
|
|
344
|
+
// companion page. Either satisfies the completion gate.
|
|
345
|
+
const reviewArtifact = session.backend === "pencil-mcp"
|
|
346
|
+
? "screen-review.png"
|
|
347
|
+
: "screen-review.html";
|
|
348
|
+
if (!fs.existsSync(path.join(session.dir, reviewArtifact))) return;
|
|
349
|
+
|
|
350
|
+
const record: UiDesignReviewApprovalRecord = {
|
|
351
|
+
question,
|
|
352
|
+
options: [...options],
|
|
353
|
+
selected,
|
|
354
|
+
selectedLabel,
|
|
355
|
+
recordedAt: new Date().toISOString(),
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
fs.writeFileSync(
|
|
360
|
+
path.join(session.dir, REVIEW_APPROVAL_FILE),
|
|
361
|
+
JSON.stringify(record, null, 2),
|
|
362
|
+
);
|
|
363
|
+
} catch {
|
|
364
|
+
// Non-fatal: completion validation will surface the missing audit artifact.
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function readReviewApprovalRecord(sessionDir: string): UiDesignReviewApprovalRecord | null {
|
|
369
|
+
const approvalPath = path.join(sessionDir, REVIEW_APPROVAL_FILE);
|
|
370
|
+
if (!fs.existsSync(approvalPath)) return null;
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
const parsed = JSON.parse(fs.readFileSync(approvalPath, "utf-8"));
|
|
374
|
+
return isReviewApprovalRecord(parsed) ? parsed : null;
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function requireSessionArtifact(
|
|
381
|
+
sessionDir: string,
|
|
382
|
+
relativePath: string,
|
|
383
|
+
issues: string[],
|
|
384
|
+
): string | null {
|
|
385
|
+
const resolved = resolveSessionArtifactPath(sessionDir, relativePath);
|
|
386
|
+
if (!resolved) {
|
|
387
|
+
issues.push(`${relativePath} escapes the session dir`);
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
if (!fs.existsSync(resolved)) {
|
|
391
|
+
issues.push(relativePath);
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
return resolved;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function validateTextArtifact(filePath: string, label: string, issues: string[]): void {
|
|
398
|
+
const text = readTextFile(filePath);
|
|
399
|
+
if (text === null) {
|
|
400
|
+
issues.push(`${label} unreadable`);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (text.trim().length === 0) {
|
|
404
|
+
issues.push(`${label} is empty`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function validateJsonArtifact(filePath: string, label: string, issues: string[]): void {
|
|
409
|
+
const text = readTextFile(filePath);
|
|
410
|
+
if (text === null) {
|
|
411
|
+
issues.push(`${label} unreadable`);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
JSON.parse(text);
|
|
417
|
+
} catch {
|
|
418
|
+
issues.push(`${label} is not valid JSON`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function validateSessionTextArtifact(
|
|
423
|
+
sessionDir: string,
|
|
424
|
+
relativePath: string,
|
|
425
|
+
issues: string[],
|
|
426
|
+
): void {
|
|
427
|
+
const filePath = requireSessionArtifact(sessionDir, relativePath, issues);
|
|
428
|
+
if (!filePath) return;
|
|
429
|
+
validateTextArtifact(filePath, relativePath, issues);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function validateSessionHtmlArtifact(
|
|
433
|
+
sessionDir: string,
|
|
434
|
+
relativePath: string,
|
|
435
|
+
issues: string[],
|
|
436
|
+
): void {
|
|
437
|
+
const filePath = requireSessionArtifact(sessionDir, relativePath, issues);
|
|
438
|
+
if (!filePath) return;
|
|
439
|
+
validateHtmlArtifact(filePath, relativePath, issues);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function validateSessionJsonArtifact(
|
|
443
|
+
sessionDir: string,
|
|
444
|
+
relativePath: string,
|
|
445
|
+
issues: string[],
|
|
446
|
+
): void {
|
|
447
|
+
const filePath = requireSessionArtifact(sessionDir, relativePath, issues);
|
|
448
|
+
if (!filePath) return;
|
|
449
|
+
validateJsonArtifact(filePath, relativePath, issues);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function validateTrackedComponentArtifacts(
|
|
453
|
+
sessionDir: string,
|
|
454
|
+
manifest: Manifest,
|
|
455
|
+
issues: string[],
|
|
456
|
+
): void {
|
|
457
|
+
for (const component of manifest.components) {
|
|
458
|
+
validateSessionTextArtifact(sessionDir, path.join("components", `${component}.html`), issues);
|
|
459
|
+
validateSessionJsonArtifact(
|
|
460
|
+
sessionDir,
|
|
461
|
+
path.join("components", `${component}.tokens.json`),
|
|
462
|
+
issues,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
for (const section of manifest.sections) {
|
|
467
|
+
validateSessionTextArtifact(sessionDir, path.join("sections", `${section}.html`), issues);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function extractMarkdownSection(markdown: string, heading: string): string | null {
|
|
472
|
+
const lines = markdown.split(/\r?\n/);
|
|
473
|
+
const target = `## ${heading}`.toLowerCase();
|
|
474
|
+
const startIndex = lines.findIndex((line) => line.trim().toLowerCase() === target);
|
|
475
|
+
if (startIndex === -1) return null;
|
|
476
|
+
|
|
477
|
+
const sectionLines: string[] = [];
|
|
478
|
+
for (let index = startIndex + 1; index < lines.length; index++) {
|
|
479
|
+
const line = lines[index];
|
|
480
|
+
if (/^##\s+/.test(line.trim())) break;
|
|
481
|
+
sectionLines.push(line);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return sectionLines.join("\n").trim();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function countCritiqueItems(section: string, label: string, issues: string[]): number {
|
|
488
|
+
const lines = section
|
|
489
|
+
.split(/\r?\n/)
|
|
490
|
+
.map((line) => line.trim())
|
|
491
|
+
.filter(Boolean);
|
|
492
|
+
|
|
493
|
+
if (lines.length === 0) {
|
|
494
|
+
issues.push(`critique.md ${label} section is empty`);
|
|
495
|
+
return 0;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (lines.every((line) => /^(?:[-*+]\s+)?(?:none|n\/a)$/i.test(line))) {
|
|
499
|
+
return 0;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const bulletLines = lines.filter((line) => /^[-*+]\s+/.test(line) || /^\d+\.\s+/.test(line));
|
|
503
|
+
if (bulletLines.length !== lines.length) {
|
|
504
|
+
issues.push(`critique.md ${label} section must be a bullet list or 'none'`);
|
|
505
|
+
}
|
|
506
|
+
return bulletLines.length;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function parseCritiqueSummary(markdown: string, issues: string[]): { fixableCount: number; advisoryCount: number } {
|
|
510
|
+
const fixableSection = extractMarkdownSection(markdown, "Fixable");
|
|
511
|
+
const advisorySection = extractMarkdownSection(markdown, "Advisory");
|
|
512
|
+
|
|
513
|
+
if (!fixableSection) {
|
|
514
|
+
issues.push("critique.md missing `## Fixable` section");
|
|
515
|
+
}
|
|
516
|
+
if (!advisorySection) {
|
|
517
|
+
issues.push("critique.md missing `## Advisory` section");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const fixableCount = fixableSection ? countCritiqueItems(fixableSection, "Fixable", issues) : 0;
|
|
521
|
+
const advisoryCount = advisorySection ? countCritiqueItems(advisorySection, "Advisory", issues) : 0;
|
|
522
|
+
|
|
523
|
+
if (fixableCount > 0) {
|
|
524
|
+
issues.push(`critique.md lists ${fixableCount} unresolved fixable item(s)`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return { fixableCount, advisoryCount };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function writeCompletionProof(sessionDir: string, proof: CompletionProof): void {
|
|
531
|
+
try {
|
|
532
|
+
fs.writeFileSync(
|
|
533
|
+
path.join(sessionDir, COMPLETION_PROOF_FILE),
|
|
534
|
+
JSON.stringify(proof, null, 2),
|
|
535
|
+
);
|
|
536
|
+
} catch {
|
|
537
|
+
// non-fatal: validation still gates completion in-memory
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function sameCritiqueSummary(
|
|
542
|
+
left: Manifest["critique"] | undefined,
|
|
543
|
+
right: Manifest["critique"] | undefined,
|
|
544
|
+
): boolean {
|
|
545
|
+
if (!left && !right) return true;
|
|
546
|
+
if (!left || !right) return false;
|
|
547
|
+
return (
|
|
548
|
+
left.fixableCount === right.fixableCount &&
|
|
549
|
+
left.advisoryCount === right.advisoryCount &&
|
|
550
|
+
left.fixIterations === right.fixIterations
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
interface NodeManifest {
|
|
555
|
+
pageNodeId: string;
|
|
556
|
+
sectionNodeIds: string[];
|
|
557
|
+
componentNodeIds: string[];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function isNodeManifest(value: unknown): value is NodeManifest {
|
|
561
|
+
if (!value || typeof value !== "object") return false;
|
|
562
|
+
const record = value as Record<string, unknown>;
|
|
563
|
+
if (typeof record.pageNodeId !== "string" || record.pageNodeId.length === 0) return false;
|
|
564
|
+
if (!isStringArray(record.sectionNodeIds)) return false;
|
|
565
|
+
if (!isStringArray(record.componentNodeIds)) return false;
|
|
566
|
+
return (
|
|
567
|
+
record.sectionNodeIds.every((id) => typeof id === "string" && id.length > 0) &&
|
|
568
|
+
record.componentNodeIds.every((id) => typeof id === "string" && id.length > 0)
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function validateCritiqueFromFile(
|
|
573
|
+
critiquePath: string,
|
|
574
|
+
manifestCritique: Manifest["critique"],
|
|
575
|
+
issues: string[],
|
|
576
|
+
): { fixableCount: number; advisoryCount: number } {
|
|
577
|
+
let critique = {
|
|
578
|
+
fixableCount: manifestCritique?.fixableCount ?? 0,
|
|
579
|
+
advisoryCount: manifestCritique?.advisoryCount ?? 0,
|
|
580
|
+
};
|
|
581
|
+
if (!fs.existsSync(critiquePath)) {
|
|
582
|
+
issues.push("critique.md");
|
|
583
|
+
return critique;
|
|
584
|
+
}
|
|
585
|
+
const critiqueContent = readTextFile(critiquePath);
|
|
586
|
+
if (critiqueContent === null) {
|
|
587
|
+
issues.push("critique.md unreadable");
|
|
588
|
+
return critique;
|
|
589
|
+
}
|
|
590
|
+
critique = parseCritiqueSummary(critiqueContent, issues);
|
|
591
|
+
return critique;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function validateLocalHtmlCompletion(
|
|
595
|
+
sessionDir: string,
|
|
596
|
+
manifest: Manifest,
|
|
597
|
+
issues: string[],
|
|
598
|
+
): { fixableCount: number; advisoryCount: number; approval: UiDesignReviewApprovalRecord | null } {
|
|
599
|
+
validateSessionTextArtifact(sessionDir, "context.md", issues);
|
|
600
|
+
validateSessionHtmlArtifact(sessionDir, "screen-decomposition.html", issues);
|
|
601
|
+
validateSessionJsonArtifact(sessionDir, "decomposition.json", issues);
|
|
602
|
+
|
|
603
|
+
const pagePath = resolveSessionArtifactPath(sessionDir, manifest.page);
|
|
604
|
+
if (!pagePath) {
|
|
605
|
+
issues.push(`${manifest.page || "page.html"} escapes the session dir`);
|
|
606
|
+
} else if (!fs.existsSync(pagePath)) {
|
|
607
|
+
issues.push(manifest.page || "page.html");
|
|
608
|
+
} else {
|
|
609
|
+
validateHtmlArtifact(pagePath, manifest.page, issues);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
validateTrackedComponentArtifacts(sessionDir, manifest, issues);
|
|
613
|
+
|
|
614
|
+
const critique = validateCritiqueFromFile(
|
|
615
|
+
path.join(sessionDir, "critique.md"),
|
|
616
|
+
manifest.critique,
|
|
617
|
+
issues,
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
validateSessionHtmlArtifact(sessionDir, "screen-review.html", issues);
|
|
621
|
+
|
|
622
|
+
const approval = readReviewApprovalRecord(sessionDir);
|
|
623
|
+
if (!approval) {
|
|
624
|
+
issues.push(REVIEW_APPROVAL_FILE);
|
|
625
|
+
} else if (approval.selected !== "approve") {
|
|
626
|
+
issues.push(`${REVIEW_APPROVAL_FILE} selected ${approval.selected}`);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return { ...critique, approval };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function validatePencilMcpCompletion(
|
|
633
|
+
sessionDir: string,
|
|
634
|
+
manifest: Manifest,
|
|
635
|
+
issues: string[],
|
|
636
|
+
): { fixableCount: number; advisoryCount: number; approval: UiDesignReviewApprovalRecord | null } {
|
|
637
|
+
validateSessionTextArtifact(sessionDir, "context.md", issues);
|
|
638
|
+
validateSessionJsonArtifact(sessionDir, "decomposition.json", issues);
|
|
639
|
+
|
|
640
|
+
// penFilePath may live outside sessionDir; validate the raw absolute path.
|
|
641
|
+
if (!manifest.penFilePath || typeof manifest.penFilePath !== "string") {
|
|
642
|
+
issues.push("manifest.penFilePath missing");
|
|
643
|
+
} else if (!fs.existsSync(manifest.penFilePath)) {
|
|
644
|
+
issues.push(`penFilePath missing on disk: ${manifest.penFilePath}`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const nodeManifestPath = requireSessionArtifact(sessionDir, "node-manifest.json", issues);
|
|
648
|
+
if (nodeManifestPath) {
|
|
649
|
+
const raw = readTextFile(nodeManifestPath);
|
|
650
|
+
if (raw === null) {
|
|
651
|
+
issues.push("node-manifest.json unreadable");
|
|
652
|
+
} else {
|
|
653
|
+
try {
|
|
654
|
+
const parsed = JSON.parse(raw);
|
|
655
|
+
if (!isNodeManifest(parsed)) {
|
|
656
|
+
issues.push("node-manifest.json is malformed (expected pageNodeId + sectionNodeIds + componentNodeIds)");
|
|
657
|
+
}
|
|
658
|
+
} catch {
|
|
659
|
+
issues.push("node-manifest.json is not valid JSON");
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const critique = validateCritiqueFromFile(
|
|
665
|
+
path.join(sessionDir, "critique.md"),
|
|
666
|
+
manifest.critique,
|
|
667
|
+
issues,
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
const screenReviewPath = requireSessionArtifact(sessionDir, "screen-review.png", issues);
|
|
671
|
+
// requireSessionArtifact already pushes the missing-artifact issue; no further check.
|
|
672
|
+
void screenReviewPath;
|
|
673
|
+
|
|
674
|
+
const approval = readReviewApprovalRecord(sessionDir);
|
|
675
|
+
if (!approval) {
|
|
676
|
+
issues.push(REVIEW_APPROVAL_FILE);
|
|
677
|
+
} else if (approval.selected !== "approve") {
|
|
678
|
+
issues.push(`${REVIEW_APPROVAL_FILE} selected ${approval.selected}`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return { ...critique, approval };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function validateCompletionProof(
|
|
685
|
+
sessionDir: string,
|
|
686
|
+
manifest: Manifest,
|
|
687
|
+
): { issues: string[]; validatedManifest: Manifest } {
|
|
688
|
+
const issues: string[] = [];
|
|
689
|
+
|
|
690
|
+
const result = manifest.backend === "pencil-mcp"
|
|
691
|
+
? validatePencilMcpCompletion(sessionDir, manifest, issues)
|
|
692
|
+
: validateLocalHtmlCompletion(sessionDir, manifest, issues);
|
|
693
|
+
const { approval } = result;
|
|
694
|
+
|
|
695
|
+
const critiqueSummary = {
|
|
696
|
+
fixableCount: result.fixableCount,
|
|
697
|
+
advisoryCount: result.advisoryCount,
|
|
698
|
+
fixIterations: manifest.critique?.fixIterations ?? 0,
|
|
699
|
+
};
|
|
700
|
+
const validatedManifest: Manifest = {
|
|
701
|
+
...manifest,
|
|
702
|
+
approvedAt: approval?.selected === "approve" ? approval.recordedAt : manifest.approvedAt,
|
|
703
|
+
critique: critiqueSummary,
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
const isPencil = manifest.backend === "pencil-mcp";
|
|
707
|
+
writeCompletionProof(sessionDir, {
|
|
708
|
+
valid: issues.length === 0,
|
|
709
|
+
validatedAt: new Date().toISOString(),
|
|
710
|
+
issues: [...issues],
|
|
711
|
+
page: isPencil ? "<pen node page>" : manifest.page,
|
|
712
|
+
critiquePath: "critique.md",
|
|
713
|
+
reviewPath: isPencil ? "screen-review.png" : "screen-review.html",
|
|
714
|
+
approvalRecordPath: REVIEW_APPROVAL_FILE,
|
|
715
|
+
approvalDecision: approval?.selected ?? null,
|
|
716
|
+
critique: critiqueSummary,
|
|
717
|
+
...(isPencil && manifest.penFilePath ? { penFilePath: manifest.penFilePath } : {}),
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
return { issues, validatedManifest };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function writeManifest(sessionDir: string, manifest: Manifest): void {
|
|
724
|
+
fs.writeFileSync(
|
|
725
|
+
path.join(sessionDir, "manifest.json"),
|
|
726
|
+
JSON.stringify(manifest, null, 2),
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function runCleanup(): Promise<void> {
|
|
731
|
+
const cleanup = activeCleanup;
|
|
732
|
+
if (!cleanup) return;
|
|
733
|
+
try {
|
|
734
|
+
await cleanup();
|
|
735
|
+
} catch {
|
|
736
|
+
// swallow — never block agent_end
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function resumeSession(
|
|
741
|
+
platform: Platform,
|
|
742
|
+
ctx: any,
|
|
743
|
+
session: UiDesignSession,
|
|
744
|
+
steerMessage: string,
|
|
745
|
+
): Promise<void> {
|
|
746
|
+
markSessionProgress(session.dir);
|
|
747
|
+
if (session.resolvedModel) {
|
|
748
|
+
await applyModelOverride(platform, ctx, "ui-design", session.resolvedModel);
|
|
749
|
+
}
|
|
750
|
+
platform.sendMessage(
|
|
751
|
+
{
|
|
752
|
+
customType: "supi-ui-design-resume",
|
|
753
|
+
content: [{ type: "text", text: steerMessage }],
|
|
754
|
+
display: "none",
|
|
755
|
+
},
|
|
756
|
+
{ deliverAs: "steer", triggerTurn: true },
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function discardSessionDir(sessionDir: string): void {
|
|
761
|
+
try {
|
|
762
|
+
fs.rmSync(sessionDir, { recursive: true, force: true });
|
|
763
|
+
} catch {
|
|
764
|
+
// ignore — user can clean up manually
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function getUiDesignWritePaths(toolName: string, input: Record<string, unknown>): string[] | undefined {
|
|
769
|
+
switch (toolName) {
|
|
770
|
+
case "write":
|
|
771
|
+
return [typeof input.path === "string" ? input.path : ""];
|
|
772
|
+
case "ast_edit": {
|
|
773
|
+
const arr = Array.isArray(input.paths)
|
|
774
|
+
? (input.paths as unknown[]).filter((p): p is string => typeof p === "string")
|
|
775
|
+
: [];
|
|
776
|
+
return arr.length === 0 ? [""] : arr;
|
|
777
|
+
}
|
|
778
|
+
case "edit": {
|
|
779
|
+
const edits = Array.isArray(input.edits) ? input.edits : [];
|
|
780
|
+
if (edits.length === 0) return [""];
|
|
781
|
+
return edits.map((edit) =>
|
|
782
|
+
edit && typeof edit === "object" && !Array.isArray(edit) && typeof (edit as { path?: unknown }).path === "string"
|
|
783
|
+
? (edit as { path: string }).path
|
|
784
|
+
: "",
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
case "notebook":
|
|
788
|
+
return [typeof input.notebook_path === "string" ? input.notebook_path : ""];
|
|
789
|
+
default:
|
|
790
|
+
return undefined;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
export function registerUiDesignToolGuard(platform: Platform): void {
|
|
795
|
+
platform.on("tool_call", (event: any) => {
|
|
796
|
+
const session = activeSession;
|
|
797
|
+
if (!session) return;
|
|
798
|
+
|
|
799
|
+
if (event.toolName === "exit_plan_mode") {
|
|
800
|
+
return {
|
|
801
|
+
block: true,
|
|
802
|
+
reason: "UI-design mode: completion is driven by the agent_end approval hook; do not call exit_plan_mode.",
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (event.toolName === "bash") {
|
|
807
|
+
return {
|
|
808
|
+
block: true,
|
|
809
|
+
reason: `UI-design mode: bash is not allowed. Write artifacts with write/edit inside \`${session.dir}\` and use task for delegated work.`,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const candidatePaths = getUiDesignWritePaths(
|
|
814
|
+
event.toolName,
|
|
815
|
+
(event.input ?? {}) as Record<string, unknown>,
|
|
816
|
+
);
|
|
817
|
+
if (candidatePaths === undefined) return;
|
|
818
|
+
|
|
819
|
+
for (const candidatePath of candidatePaths) {
|
|
820
|
+
if (candidatePath.trim().length === 0) {
|
|
821
|
+
return {
|
|
822
|
+
block: true,
|
|
823
|
+
reason: `UI-design mode: cannot verify ${event.toolName} without a path under \`${session.dir}\`.`,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (GLOB_META_RE.test(candidatePath)) {
|
|
828
|
+
return {
|
|
829
|
+
block: true,
|
|
830
|
+
reason: `UI-design mode: ${event.toolName} cannot use glob pattern \`${candidatePath}\`; use a literal path under \`${session.dir}\`.`,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (!resolvePathWithinDir(session.dir, candidatePath, [session.dir, process.cwd()])) {
|
|
835
|
+
return {
|
|
836
|
+
block: true,
|
|
837
|
+
reason: `UI-design mode: ${event.toolName} may only write inside \`${session.dir}\`.`,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Register the `agent_end` hook that drives the ui-design approval UI.
|
|
846
|
+
*
|
|
847
|
+
* Terminal statuses (`complete`, `discarded`, missing-manifest) tear down the
|
|
848
|
+
* companion and cancel tracking. Resume statuses (`in-progress`, `critiquing`,
|
|
849
|
+
* `awaiting-review`) offer the user a choice: resume the session or discard.
|
|
850
|
+
* On resume we send a steer message and keep tracking active.
|
|
851
|
+
*/
|
|
852
|
+
export function registerUiDesignApprovalHook(platform: Platform): void {
|
|
853
|
+
platform.on("agent_end", async (_event: any, ctx: any) => {
|
|
854
|
+
const session = activeSession;
|
|
855
|
+
if (!session || !ctx?.hasUI) return;
|
|
856
|
+
|
|
857
|
+
const sessionDir = session.dir;
|
|
858
|
+
const manifest = readManifest(sessionDir);
|
|
859
|
+
|
|
860
|
+
// Missing / unparseable manifest — unsafe to resume
|
|
861
|
+
if (!manifest) {
|
|
862
|
+
const choice = await ctx.ui.select(
|
|
863
|
+
"ui-design session is in an unknown state — what next?",
|
|
864
|
+
["Discard session"],
|
|
865
|
+
);
|
|
866
|
+
if (choice) {
|
|
867
|
+
await runCleanup();
|
|
868
|
+
discardSessionDir(sessionDir);
|
|
869
|
+
}
|
|
870
|
+
cancelUiDesignTracking("manifest_missing");
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (manifest.status === "complete") {
|
|
875
|
+
const completion = validateCompletionProof(sessionDir, manifest);
|
|
876
|
+
const validatedManifest = completion.validatedManifest;
|
|
877
|
+
if (
|
|
878
|
+
!sameCritiqueSummary(manifest.critique, validatedManifest.critique) ||
|
|
879
|
+
manifest.approvedAt !== validatedManifest.approvedAt
|
|
880
|
+
) {
|
|
881
|
+
writeManifest(sessionDir, validatedManifest);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (completion.issues.length > 0) {
|
|
885
|
+
const choice = await ctx.ui.select(
|
|
886
|
+
`ui-design session claims completion but validation failed (${completion.issues.join(", ")}) — what next?`,
|
|
887
|
+
["Resume session", "Discard session"],
|
|
888
|
+
);
|
|
889
|
+
if (choice === "Resume session") {
|
|
890
|
+
// Pencil manifests always cite pencil artifacts in their repair steer,
|
|
891
|
+
// even when `penFilePath` is missing — the HTML template would point
|
|
892
|
+
// at files pencil sessions never produce.
|
|
893
|
+
const repairSteer = manifest.backend === "pencil-mcp"
|
|
894
|
+
? REPAIR_COMPLETE_STEER_TEMPLATE_PENCIL(sessionDir, manifest.penFilePath, completion.issues)
|
|
895
|
+
: REPAIR_COMPLETE_STEER_TEMPLATE(sessionDir, completion.issues);
|
|
896
|
+
await resumeSession(
|
|
897
|
+
platform,
|
|
898
|
+
ctx,
|
|
899
|
+
session,
|
|
900
|
+
repairSteer,
|
|
901
|
+
);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (choice === "Discard session") {
|
|
905
|
+
await runCleanup();
|
|
906
|
+
discardSessionDir(sessionDir);
|
|
907
|
+
cancelUiDesignTracking("invalid_complete_discarded");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (validatedManifest.acknowledged) return;
|
|
914
|
+
const choice = await ctx.ui.select(
|
|
915
|
+
"Design complete — what next?",
|
|
916
|
+
["Keep artifacts and exit", "Open session dir", "Discard session"],
|
|
917
|
+
);
|
|
918
|
+
if (choice === "Keep artifacts and exit") {
|
|
919
|
+
writeManifest(sessionDir, { ...validatedManifest, acknowledged: true });
|
|
920
|
+
await runCleanup();
|
|
921
|
+
} else if (choice === "Discard session") {
|
|
922
|
+
await runCleanup();
|
|
923
|
+
discardSessionDir(sessionDir);
|
|
924
|
+
} else if (choice === "Open session dir") {
|
|
925
|
+
writeManifest(sessionDir, { ...validatedManifest, acknowledged: true });
|
|
926
|
+
await runCleanup();
|
|
927
|
+
try {
|
|
928
|
+
await platform.exec("open", [sessionDir]);
|
|
929
|
+
} catch {
|
|
930
|
+
// non-fatal
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
cancelUiDesignTracking("complete");
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (manifest.status === "discarded") {
|
|
938
|
+
await runCleanup();
|
|
939
|
+
discardSessionDir(sessionDir);
|
|
940
|
+
cancelUiDesignTracking("discarded");
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Auto-continue while the director is still producing new artifacts. Only
|
|
945
|
+
// interrupt the user once a turn ends without any session-local progress.
|
|
946
|
+
if (
|
|
947
|
+
manifest.status === "in-progress" ||
|
|
948
|
+
manifest.status === "critiquing" ||
|
|
949
|
+
manifest.status === "awaiting-review"
|
|
950
|
+
) {
|
|
951
|
+
if (sessionMadeProgress(sessionDir)) {
|
|
952
|
+
await resumeSession(platform, ctx, session, RESUME_STEER_TEMPLATE(sessionDir));
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const choice = await ctx.ui.select(
|
|
957
|
+
`ui-design session paused (${manifest.status}) — what next?`,
|
|
958
|
+
["Resume session", "Discard session"],
|
|
959
|
+
);
|
|
960
|
+
if (choice === "Resume session") {
|
|
961
|
+
await resumeSession(platform, ctx, session, RESUME_STEER_TEMPLATE(sessionDir));
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
if (choice === "Discard session") {
|
|
965
|
+
await runCleanup();
|
|
966
|
+
discardSessionDir(sessionDir);
|
|
967
|
+
cancelUiDesignTracking("user_discard");
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
// Cancelled prompt — leave state as-is
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
}
|