@blackbelt-technology/pi-agent-dashboard 0.4.1 → 0.4.2
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/AGENTS.md +79 -32
- package/README.md +7 -3
- package/docs/architecture.md +361 -12
- package/package.json +7 -7
- package/packages/extension/package.json +7 -2
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
- package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
- package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
- package/packages/extension/src/ask-user-tool.ts +165 -57
- package/packages/extension/src/bridge.ts +97 -4
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-polyfill.ts +38 -8
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +9 -3
- package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
- package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
- package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
- package/packages/server/src/__tests__/directory-service.test.ts +174 -0
- package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
- package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
- package/packages/server/src/__tests__/package-routes.test.ts +136 -3
- package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
- package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
- package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
- package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
- package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
- package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
- package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
- package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
- package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
- package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
- package/packages/server/src/browse.ts +118 -13
- package/packages/server/src/browser-gateway.ts +19 -0
- package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
- package/packages/server/src/browser-handlers/handler-context.ts +15 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
- package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
- package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
- package/packages/server/src/cli.ts +5 -6
- package/packages/server/src/directory-service.ts +156 -15
- package/packages/server/src/event-wiring.ts +111 -10
- package/packages/server/src/installed-package-enricher.ts +143 -0
- package/packages/server/src/package-manager-wrapper.ts +305 -8
- package/packages/server/src/package-source-helpers.ts +104 -0
- package/packages/server/src/pending-attach-registry.ts +112 -0
- package/packages/server/src/pending-resume-intent-registry.ts +107 -0
- package/packages/server/src/pi-core-checker.ts +9 -14
- package/packages/server/src/pi-gateway.ts +14 -0
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/routes/file-routes.ts +29 -3
- package/packages/server/src/routes/package-routes.ts +72 -3
- package/packages/server/src/routes/plugin-config-routes.ts +129 -0
- package/packages/server/src/routes/system-routes.ts +2 -0
- package/packages/server/src/server.ts +339 -10
- package/packages/server/src/session-api.ts +30 -5
- package/packages/server/src/session-order-manager.ts +22 -0
- package/packages/server/src/session-scanner.ts +10 -1
- package/packages/shared/package.json +9 -2
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
- package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
- package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
- package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
- package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
- package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
- package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
- package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
- package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
- package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
- package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
- package/packages/shared/src/browser-protocol.ts +110 -4
- package/packages/shared/src/config.ts +45 -0
- package/packages/shared/src/dashboard-plugin/index.ts +11 -0
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
- package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
- package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
- package/packages/shared/src/openspec-activity-detector.ts +18 -22
- package/packages/shared/src/openspec-design-evidence.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +117 -3
- package/packages/shared/src/openspec-specs-evidence.ts +79 -0
- package/packages/shared/src/platform/binary-lookup.ts +96 -1
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +56 -2
- package/packages/shared/src/recommended-extensions.ts +7 -1
- package/packages/shared/src/rest-api.ts +68 -3
- package/packages/shared/src/state-replay.ts +11 -1
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- package/packages/shared/src/types.ts +160 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin status types used in /api/health.plugins[] and WebSocket broadcasts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Status of a single discovered plugin, reported by /api/health. */
|
|
6
|
+
export interface PluginStatus {
|
|
7
|
+
id: string;
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
loaded: boolean;
|
|
10
|
+
/** Error message if the plugin failed to load or has a conflict. */
|
|
11
|
+
error?: string;
|
|
12
|
+
/** Number of slot claims declared in the plugin's manifest. */
|
|
13
|
+
claims: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** WebSocket broadcast sent to all browsers when a plugin's config changes. */
|
|
17
|
+
export interface PluginConfigUpdate {
|
|
18
|
+
type: "plugin_config_update";
|
|
19
|
+
/** Plugin id that was updated. */
|
|
20
|
+
id: string;
|
|
21
|
+
/**
|
|
22
|
+
* Only this plugin's namespace config (plugins.<id>.*).
|
|
23
|
+
* Never contains other plugins' config.
|
|
24
|
+
*/
|
|
25
|
+
config: unknown;
|
|
26
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed prop contracts for each slot id.
|
|
3
|
+
*
|
|
4
|
+
* Every slot consumer passes exactly the props defined here to each contribution
|
|
5
|
+
* component. Plugins receive only the props for the slot they claim.
|
|
6
|
+
*
|
|
7
|
+
* NOTE: PluginContext is imported as a type-only forward reference so this
|
|
8
|
+
* shared package doesn't depend on the runtime package. The runtime package
|
|
9
|
+
* will re-export this map with the concrete PluginContext type filled in.
|
|
10
|
+
*/
|
|
11
|
+
import type { DashboardSession } from "../types.js";
|
|
12
|
+
import type { SlotId } from "./slot-types.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Opaque marker type for PluginContext.
|
|
16
|
+
* The concrete type is defined in @blackbelt-technology/dashboard-plugin-runtime/context.
|
|
17
|
+
* Using `unknown` here keeps this shared types-only package free of runtime deps.
|
|
18
|
+
*/
|
|
19
|
+
export type AnyPluginContext = unknown;
|
|
20
|
+
|
|
21
|
+
/** Folder descriptor passed to sidebar-folder-section slot. */
|
|
22
|
+
export interface FolderDescriptor {
|
|
23
|
+
cwd: string;
|
|
24
|
+
label?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Map of slot id → props type for that slot's contributions. */
|
|
28
|
+
export interface SlotPropsMap {
|
|
29
|
+
"sidebar-folder-section": {
|
|
30
|
+
folder: FolderDescriptor;
|
|
31
|
+
pluginContext: AnyPluginContext;
|
|
32
|
+
};
|
|
33
|
+
"session-card-badge": {
|
|
34
|
+
session: DashboardSession;
|
|
35
|
+
pluginContext: AnyPluginContext;
|
|
36
|
+
};
|
|
37
|
+
"session-card-action-bar": {
|
|
38
|
+
session: DashboardSession;
|
|
39
|
+
pluginContext: AnyPluginContext;
|
|
40
|
+
};
|
|
41
|
+
"content-view": {
|
|
42
|
+
session: DashboardSession;
|
|
43
|
+
routeParams: Record<string, string>;
|
|
44
|
+
onClose: () => void;
|
|
45
|
+
pluginContext: AnyPluginContext;
|
|
46
|
+
};
|
|
47
|
+
"content-header-sticky": {
|
|
48
|
+
session: DashboardSession;
|
|
49
|
+
pluginContext: AnyPluginContext;
|
|
50
|
+
};
|
|
51
|
+
"content-inline-footer": {
|
|
52
|
+
session: DashboardSession;
|
|
53
|
+
pluginContext: AnyPluginContext;
|
|
54
|
+
};
|
|
55
|
+
"anchored-popover": {
|
|
56
|
+
anchorEl: HTMLElement;
|
|
57
|
+
onDismiss: () => void;
|
|
58
|
+
pluginContext: AnyPluginContext;
|
|
59
|
+
};
|
|
60
|
+
"command-route": {
|
|
61
|
+
session: DashboardSession;
|
|
62
|
+
routeParams: Record<string, string>;
|
|
63
|
+
onClose: () => void;
|
|
64
|
+
pluginContext: AnyPluginContext;
|
|
65
|
+
};
|
|
66
|
+
"settings-section": {
|
|
67
|
+
pluginContext: AnyPluginContext;
|
|
68
|
+
};
|
|
69
|
+
"tool-renderer": {
|
|
70
|
+
toolName: string;
|
|
71
|
+
toolInput: Record<string, unknown>;
|
|
72
|
+
sessionId: string;
|
|
73
|
+
pluginContext: AnyPluginContext;
|
|
74
|
+
};
|
|
75
|
+
// Descriptor-only slots don't have React props (consumed by extension-ui-system)
|
|
76
|
+
"management-modal": Record<string, unknown>;
|
|
77
|
+
"footer-segment": Record<string, unknown>;
|
|
78
|
+
"agent-metric": Record<string, unknown>;
|
|
79
|
+
"breadcrumb": Record<string, unknown>;
|
|
80
|
+
"gate": Record<string, unknown>;
|
|
81
|
+
"toast": Record<string, unknown>;
|
|
82
|
+
"rjsf-form": Record<string, unknown>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Get the props type for a specific slot id. */
|
|
86
|
+
export type SlotProps<S extends SlotId> = SlotPropsMap[S];
|
|
87
|
+
|
|
88
|
+
// Type-level test: assert SlotPropsMap covers every SlotId.
|
|
89
|
+
// This will produce a TS error if any SlotId is not in SlotPropsMap.
|
|
90
|
+
type _AssertAllSlotsCovered = {
|
|
91
|
+
[K in SlotId]: SlotPropsMap[K];
|
|
92
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frozen slot taxonomy for the dashboard plugin system.
|
|
3
|
+
* These ids and their payload contracts are versioned via
|
|
4
|
+
* @blackbelt-technology/pi-dashboard-shared.
|
|
5
|
+
*
|
|
6
|
+
* Adding a slot: minor (non-breaking).
|
|
7
|
+
* Removing or renaming a slot: major (breaking).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** All valid slot ids (frozen for v0.x). */
|
|
11
|
+
export type SlotId =
|
|
12
|
+
// React-only slots
|
|
13
|
+
| "sidebar-folder-section"
|
|
14
|
+
| "session-card-action-bar"
|
|
15
|
+
| "content-inline-footer"
|
|
16
|
+
| "anchored-popover"
|
|
17
|
+
| "command-route"
|
|
18
|
+
| "tool-renderer"
|
|
19
|
+
// React-or-descriptor slots
|
|
20
|
+
| "session-card-badge"
|
|
21
|
+
| "content-view"
|
|
22
|
+
| "content-header-sticky"
|
|
23
|
+
| "settings-section"
|
|
24
|
+
// Descriptor-only slots (from extension-ui-system)
|
|
25
|
+
| "management-modal"
|
|
26
|
+
| "footer-segment"
|
|
27
|
+
| "agent-metric"
|
|
28
|
+
| "breadcrumb"
|
|
29
|
+
| "gate"
|
|
30
|
+
| "toast"
|
|
31
|
+
| "rjsf-form";
|
|
32
|
+
|
|
33
|
+
/** How many contributions a slot allows. */
|
|
34
|
+
export type Multiplicity = "one" | "many" | "one-active";
|
|
35
|
+
|
|
36
|
+
/** Which payload types the slot accepts. */
|
|
37
|
+
export type PayloadTier = "react-only" | "descriptor-only" | "react-or-descriptor";
|
|
38
|
+
|
|
39
|
+
export interface SlotDefinition {
|
|
40
|
+
multiplicity: Multiplicity;
|
|
41
|
+
payloadTier: PayloadTier;
|
|
42
|
+
description: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Frozen slot definitions map for v0.x. */
|
|
46
|
+
export const SLOT_DEFINITIONS: Record<SlotId, SlotDefinition> = {
|
|
47
|
+
"sidebar-folder-section": {
|
|
48
|
+
multiplicity: "many",
|
|
49
|
+
payloadTier: "react-only",
|
|
50
|
+
description: "Collapsible block above session list per workspace folder",
|
|
51
|
+
},
|
|
52
|
+
"session-card-badge": {
|
|
53
|
+
multiplicity: "many",
|
|
54
|
+
payloadTier: "react-or-descriptor",
|
|
55
|
+
description: "Compact info chip on a session card",
|
|
56
|
+
},
|
|
57
|
+
"session-card-action-bar": {
|
|
58
|
+
multiplicity: "many",
|
|
59
|
+
payloadTier: "react-only",
|
|
60
|
+
description: "Action buttons on a session card",
|
|
61
|
+
},
|
|
62
|
+
"content-view": {
|
|
63
|
+
multiplicity: "one-active",
|
|
64
|
+
payloadTier: "react-or-descriptor",
|
|
65
|
+
description: "Full-screen content area view for a session",
|
|
66
|
+
},
|
|
67
|
+
"content-header-sticky": {
|
|
68
|
+
multiplicity: "many",
|
|
69
|
+
payloadTier: "react-or-descriptor",
|
|
70
|
+
description: "Sticky header above the content view",
|
|
71
|
+
},
|
|
72
|
+
"content-inline-footer": {
|
|
73
|
+
multiplicity: "many",
|
|
74
|
+
payloadTier: "react-only",
|
|
75
|
+
description: "Inline footer below the content view (React-only)",
|
|
76
|
+
},
|
|
77
|
+
"anchored-popover": {
|
|
78
|
+
multiplicity: "one",
|
|
79
|
+
payloadTier: "react-only",
|
|
80
|
+
description: "Popover anchored to a UI trigger element",
|
|
81
|
+
},
|
|
82
|
+
"command-route": {
|
|
83
|
+
multiplicity: "many",
|
|
84
|
+
payloadTier: "react-only",
|
|
85
|
+
description: "Maps a slash command or URL route to a content view",
|
|
86
|
+
},
|
|
87
|
+
"settings-section": {
|
|
88
|
+
multiplicity: "many",
|
|
89
|
+
payloadTier: "react-or-descriptor",
|
|
90
|
+
description: "A section in the Settings page",
|
|
91
|
+
},
|
|
92
|
+
"tool-renderer": {
|
|
93
|
+
multiplicity: "many",
|
|
94
|
+
payloadTier: "react-only",
|
|
95
|
+
description: "Custom React renderer for a specific tool call by toolName",
|
|
96
|
+
},
|
|
97
|
+
// Descriptor-only (extension-ui-system)
|
|
98
|
+
"management-modal": {
|
|
99
|
+
multiplicity: "many",
|
|
100
|
+
payloadTier: "descriptor-only",
|
|
101
|
+
description: "Full-screen management modal (extension-ui-system)",
|
|
102
|
+
},
|
|
103
|
+
"footer-segment": {
|
|
104
|
+
multiplicity: "many",
|
|
105
|
+
payloadTier: "descriptor-only",
|
|
106
|
+
description: "Segment in the session footer bar (extension-ui-system)",
|
|
107
|
+
},
|
|
108
|
+
"agent-metric": {
|
|
109
|
+
multiplicity: "one",
|
|
110
|
+
payloadTier: "descriptor-only",
|
|
111
|
+
description: "Metric chip on an agent card (extension-ui-system)",
|
|
112
|
+
},
|
|
113
|
+
"breadcrumb": {
|
|
114
|
+
multiplicity: "many",
|
|
115
|
+
payloadTier: "descriptor-only",
|
|
116
|
+
description: "Breadcrumb item in the content header (extension-ui-system)",
|
|
117
|
+
},
|
|
118
|
+
"gate": {
|
|
119
|
+
multiplicity: "many",
|
|
120
|
+
payloadTier: "descriptor-only",
|
|
121
|
+
description: "Flow gate/checkpoint UI (extension-ui-system)",
|
|
122
|
+
},
|
|
123
|
+
"toast": {
|
|
124
|
+
multiplicity: "many",
|
|
125
|
+
payloadTier: "descriptor-only",
|
|
126
|
+
description: "Transient notification toast (extension-ui-system)",
|
|
127
|
+
},
|
|
128
|
+
"rjsf-form": {
|
|
129
|
+
multiplicity: "many",
|
|
130
|
+
payloadTier: "descriptor-only",
|
|
131
|
+
description: "JSON-Schema-driven form (extension-ui-system Phase 4)",
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Valid settings tab ids in SettingsPanel. */
|
|
136
|
+
export type SettingsTab =
|
|
137
|
+
| "general"
|
|
138
|
+
| "servers"
|
|
139
|
+
| "packages"
|
|
140
|
+
| "providers"
|
|
141
|
+
| "security"
|
|
142
|
+
| "advanced";
|
|
143
|
+
|
|
144
|
+
export const VALID_SETTINGS_TABS: SettingsTab[] = [
|
|
145
|
+
"general",
|
|
146
|
+
"servers",
|
|
147
|
+
"packages",
|
|
148
|
+
"providers",
|
|
149
|
+
"security",
|
|
150
|
+
"advanced",
|
|
151
|
+
];
|
|
@@ -83,29 +83,25 @@ export function detectOpenSpecActivity(
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
if (tool === "bash") {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return { changeName: newChangeMatch[1], isActive: true };
|
|
86
|
+
const command = args.command as string | undefined;
|
|
87
|
+
if (!command || !command.includes("openspec")) return null;
|
|
88
|
+
|
|
89
|
+
// Try each CLI regex in order; first match wins.
|
|
90
|
+
const match =
|
|
91
|
+
command.match(CLI_CHANGE_FLAG_RE) ??
|
|
92
|
+
command.match(CLI_ARCHIVE_RE) ??
|
|
93
|
+
command.match(CLI_NEW_CHANGE_RE);
|
|
94
|
+
if (!match) return null;
|
|
95
|
+
|
|
96
|
+
const name = match[1];
|
|
97
|
+
// Reject flag-shaped tokens (e.g. `--help`, `-h`). The CLI regex capture
|
|
98
|
+
// groups use `[^\s"']+` which would otherwise treat `--help` as a change
|
|
99
|
+
// name and trigger downstream auto-attach + auto-rename.
|
|
100
|
+
// See change: fix-openspec-flag-rename-bug.
|
|
101
|
+
if (name.startsWith("-")) return null;
|
|
102
|
+
|
|
103
|
+
return { changeName: name, isActive: true };
|
|
105
104
|
}
|
|
106
105
|
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
106
|
return null;
|
|
111
107
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-evidence override for the OpenSpec `design` artifact.
|
|
3
|
+
*
|
|
4
|
+
* The upstream `spec-driven` schema requires `design.md` as a hard,
|
|
5
|
+
* single-file dependency of `tasks`. Two real-world workflows fight that:
|
|
6
|
+
*
|
|
7
|
+
* • **Split design** — users put design content in `design-rendering.md`
|
|
8
|
+
* + `design-state.md`. The CLI doesn't see them; status reports
|
|
9
|
+
* `design: ready` forever; dashboard shows `[Continue] [FF]` instead of
|
|
10
|
+
* `[Apply]`.
|
|
11
|
+
* • **No-design changes** — trivial fixes that don't need a design doc.
|
|
12
|
+
* User writes `tasks.md`, starts implementing; CLI still says
|
|
13
|
+
* `design: ready`; dashboard buttons are wrong.
|
|
14
|
+
*
|
|
15
|
+
* This module computes a boolean "is design satisfied locally?" from
|
|
16
|
+
* file-system evidence the CLI ignores. It is consumed by:
|
|
17
|
+
*
|
|
18
|
+
* 1. `buildOpenSpecData` in `openspec-poller.ts` — promotes
|
|
19
|
+
* `artifacts[design].status` from "ready" to "done" when the rules fire.
|
|
20
|
+
* Promote-only; design-only; never demotes; never touches other artifacts.
|
|
21
|
+
*
|
|
22
|
+
* 2. `.pi/skills/openspec-shared/scripts/effective-status.sh` — the
|
|
23
|
+
* OpenSpec workflow skills invoke this wrapper instead of
|
|
24
|
+
* `openspec status --json` so skill-driven prompts and dashboard buttons
|
|
25
|
+
* cannot disagree.
|
|
26
|
+
*
|
|
27
|
+
* Three rules, evaluated in order with short-circuit:
|
|
28
|
+
*
|
|
29
|
+
* R1 any file matching ^design.*\.md$ exists in the change folder
|
|
30
|
+
* R2 a design/ subdirectory exists with at least one *.md inside
|
|
31
|
+
* R3 tasks.md exists AND contains at least one Markdown checkbox
|
|
32
|
+
* (^\s*-\s+\[[ xX]\]\s)
|
|
33
|
+
*
|
|
34
|
+
* R3 is heuristic but defensible: a user who wrote actionable tasks has
|
|
35
|
+
* already made the design decisions. The schema's hard dependency is
|
|
36
|
+
* paperwork we don't believe in for trivial changes.
|
|
37
|
+
*
|
|
38
|
+
* See change: fix-openspec-design-detection.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
42
|
+
import path from "node:path";
|
|
43
|
+
|
|
44
|
+
/** Probe surface — kept tiny so unit tests can pass an in-memory stub. */
|
|
45
|
+
export interface DesignEvidenceProbe {
|
|
46
|
+
/** R1: any file matching `^design.*\.md$` in `changeDir`. */
|
|
47
|
+
hasDesignFile(changeDir: string): boolean;
|
|
48
|
+
/** R2: `<changeDir>/design/` exists and contains at least one `*.md`. */
|
|
49
|
+
hasDesignDirWithMd(changeDir: string): boolean;
|
|
50
|
+
/** R3: `<changeDir>/tasks.md` contains at least one Markdown checkbox. */
|
|
51
|
+
tasksHasCheckboxes(changeDir: string): boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Pure rule evaluator. R1 → R2 → R3, short-circuits on first match. */
|
|
55
|
+
export function evaluateLocalDesignSatisfaction(
|
|
56
|
+
changeDir: string,
|
|
57
|
+
probe: DesignEvidenceProbe,
|
|
58
|
+
): boolean {
|
|
59
|
+
if (probe.hasDesignFile(changeDir)) return true;
|
|
60
|
+
if (probe.hasDesignDirWithMd(changeDir)) return true;
|
|
61
|
+
if (probe.tasksHasCheckboxes(changeDir)) return true;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const DESIGN_FILE_RE = /^design.*\.md$/;
|
|
66
|
+
const CHECKBOX_RE = /^\s*-\s+\[[ xX]\]\s/m;
|
|
67
|
+
|
|
68
|
+
/** Production probe — backed by the real filesystem. Sync, defensive. */
|
|
69
|
+
export function createFsDesignEvidenceProbe(): DesignEvidenceProbe {
|
|
70
|
+
return {
|
|
71
|
+
hasDesignFile(changeDir) {
|
|
72
|
+
try {
|
|
73
|
+
const entries = readdirSync(changeDir, { withFileTypes: true });
|
|
74
|
+
for (const e of entries) {
|
|
75
|
+
if (e.isFile() && DESIGN_FILE_RE.test(e.name)) return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
hasDesignDirWithMd(changeDir) {
|
|
84
|
+
const dir = path.join(changeDir, "design");
|
|
85
|
+
try {
|
|
86
|
+
const st = statSync(dir);
|
|
87
|
+
if (!st.isDirectory()) return false;
|
|
88
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
89
|
+
for (const e of entries) {
|
|
90
|
+
if (e.isFile() && e.name.endsWith(".md")) return true;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
tasksHasCheckboxes(changeDir) {
|
|
99
|
+
const tasks = path.join(changeDir, "tasks.md");
|
|
100
|
+
if (!existsSync(tasks)) return false;
|
|
101
|
+
try {
|
|
102
|
+
const text = readFileSync(tasks, "utf8");
|
|
103
|
+
return CHECKBOX_RE.test(text);
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -23,12 +23,47 @@
|
|
|
23
23
|
import { listOr, statusOr, OPENSPEC_LIST, OPENSPEC_STATUS } from "./platform/openspec.js";
|
|
24
24
|
import { runAsync, unwrap } from "./platform/runner.js";
|
|
25
25
|
import type { OpenSpecData, OpenSpecChange, OpenSpecArtifact } from "./types.js";
|
|
26
|
+
import {
|
|
27
|
+
evaluateLocalDesignSatisfaction,
|
|
28
|
+
createFsDesignEvidenceProbe,
|
|
29
|
+
type DesignEvidenceProbe,
|
|
30
|
+
} from "./openspec-design-evidence.js";
|
|
31
|
+
import {
|
|
32
|
+
evaluateLocalSpecsSatisfaction,
|
|
33
|
+
createFsSpecsEvidenceProbe,
|
|
34
|
+
type SpecsEvidenceProbe,
|
|
35
|
+
} from "./openspec-specs-evidence.js";
|
|
36
|
+
import path from "node:path";
|
|
26
37
|
|
|
27
38
|
const EMPTY_DATA: OpenSpecData = { initialized: false, changes: [] };
|
|
28
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Factory that returns a probe for a given change name. Production callers
|
|
42
|
+
* pass a closure rooted at `<cwd>/openspec/changes/<name>`. Tests pass an
|
|
43
|
+
* in-memory factory. When omitted, the design override does NOT fire and
|
|
44
|
+
* `buildOpenSpecData` matches today's behavior verbatim.
|
|
45
|
+
*
|
|
46
|
+
* See change: fix-openspec-design-detection.
|
|
47
|
+
*/
|
|
48
|
+
export type DesignProbeFactory = (changeName: string) => DesignEvidenceProbe;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Factory that returns a specs-evidence probe for a given change name.
|
|
52
|
+
* Parallel to `DesignProbeFactory` — production callers pass a closure
|
|
53
|
+
* rooted at `<cwd>/openspec/changes/<name>`; tests pass an in-memory
|
|
54
|
+
* factory. When omitted, the specs override does NOT fire and
|
|
55
|
+
* `buildOpenSpecData` matches today's behavior verbatim for the specs
|
|
56
|
+
* artifact.
|
|
57
|
+
*
|
|
58
|
+
* See change: fix-openspec-specs-mtime-gate-blind-spot.
|
|
59
|
+
*/
|
|
60
|
+
export type SpecsProbeFactory = (changeName: string) => SpecsEvidenceProbe;
|
|
61
|
+
|
|
29
62
|
export function buildOpenSpecData(
|
|
30
63
|
listResult: { changes?: Array<{ name: string; status: string; completedTasks: number; totalTasks: number }> } | null,
|
|
31
64
|
statusResults: Map<string, { artifacts?: Array<{ id: string; status: string }>; isComplete?: boolean } | null>,
|
|
65
|
+
probeFactory?: DesignProbeFactory,
|
|
66
|
+
specsProbeFactory?: SpecsProbeFactory,
|
|
32
67
|
): OpenSpecData {
|
|
33
68
|
if (!listResult || !Array.isArray(listResult.changes)) {
|
|
34
69
|
return EMPTY_DATA;
|
|
@@ -41,9 +76,40 @@ export function buildOpenSpecData(
|
|
|
41
76
|
status: (a.status === "done" ? "done" : a.status === "ready" ? "ready" : "blocked") as OpenSpecArtifact["status"],
|
|
42
77
|
}));
|
|
43
78
|
|
|
44
|
-
|
|
79
|
+
// Design-artifact override: promote-only, design-only. See change:
|
|
80
|
+
// fix-openspec-design-detection.
|
|
81
|
+
if (probeFactory) {
|
|
82
|
+
const designIdx = artifacts.findIndex((a) => a.id === "design");
|
|
83
|
+
if (designIdx !== -1 && artifacts[designIdx].status === "ready") {
|
|
84
|
+
const probe = probeFactory(c.name);
|
|
85
|
+
if (evaluateLocalDesignSatisfaction("", probe)) {
|
|
86
|
+
artifacts[designIdx] = { ...artifacts[designIdx], status: "done" };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Specs-artifact override: promote-only, specs-only. See change:
|
|
92
|
+
// fix-openspec-specs-mtime-gate-blind-spot.
|
|
93
|
+
if (specsProbeFactory) {
|
|
94
|
+
const specsIdx = artifacts.findIndex((a) => a.id === "specs");
|
|
95
|
+
if (specsIdx !== -1 && artifacts[specsIdx].status === "ready") {
|
|
96
|
+
const probe = specsProbeFactory(c.name);
|
|
97
|
+
if (evaluateLocalSpecsSatisfaction("", probe)) {
|
|
98
|
+
artifacts[specsIdx] = { ...artifacts[specsIdx], status: "done" };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const cliIsComplete =
|
|
45
104
|
typeof statusResult?.isComplete === "boolean" ? statusResult.isComplete : undefined;
|
|
46
105
|
|
|
106
|
+
// Re-derive isComplete from post-override artifacts. Promote false→true
|
|
107
|
+
// only when every artifact is done; never demote CLI true.
|
|
108
|
+
let isComplete = cliIsComplete;
|
|
109
|
+
if (artifacts.length > 0 && artifacts.every((a) => a.status === "done")) {
|
|
110
|
+
isComplete = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
47
113
|
const change: OpenSpecChange = {
|
|
48
114
|
name: c.name,
|
|
49
115
|
status: (c.status === "complete" ? "complete" : c.status === "in-progress" ? "in-progress" : "no-tasks") as OpenSpecChange["status"],
|
|
@@ -58,6 +124,44 @@ export function buildOpenSpecData(
|
|
|
58
124
|
return { initialized: true, changes };
|
|
59
125
|
}
|
|
60
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Build a real-fs probe factory rooted at `<cwd>/openspec/changes/<name>`.
|
|
129
|
+
* Production callers (`pollOpenSpec`, `pollOpenSpecAsync`,
|
|
130
|
+
* `directory-service.ts`) use this to wire the override. Tests inject
|
|
131
|
+
* their own factory.
|
|
132
|
+
*/
|
|
133
|
+
export function createFsProbeFactory(cwd: string): DesignProbeFactory {
|
|
134
|
+
const probe = createFsDesignEvidenceProbe();
|
|
135
|
+
const changesRoot = path.join(cwd, "openspec", "changes");
|
|
136
|
+
return (changeName) => {
|
|
137
|
+
const changeDir = path.join(changesRoot, changeName);
|
|
138
|
+
return {
|
|
139
|
+
hasDesignFile: () => probe.hasDesignFile(changeDir),
|
|
140
|
+
hasDesignDirWithMd: () => probe.hasDesignDirWithMd(changeDir),
|
|
141
|
+
tasksHasCheckboxes: () => probe.tasksHasCheckboxes(changeDir),
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build a real-fs specs-probe factory rooted at `<cwd>/openspec/changes/<name>`.
|
|
148
|
+
* Parallel to `createFsProbeFactory` — production callers (`pollOpenSpec`,
|
|
149
|
+
* `pollOpenSpecAsync`, `directory-service.ts`) use this to wire the specs
|
|
150
|
+
* override. Tests inject their own factory.
|
|
151
|
+
*
|
|
152
|
+
* See change: fix-openspec-specs-mtime-gate-blind-spot.
|
|
153
|
+
*/
|
|
154
|
+
export function createFsSpecsProbeFactory(cwd: string): SpecsProbeFactory {
|
|
155
|
+
const probe = createFsSpecsEvidenceProbe();
|
|
156
|
+
const changesRoot = path.join(cwd, "openspec", "changes");
|
|
157
|
+
return (changeName) => {
|
|
158
|
+
const changeDir = path.join(changesRoot, changeName);
|
|
159
|
+
return {
|
|
160
|
+
hasAnySpecFile: () => probe.hasAnySpecFile(changeDir),
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
61
165
|
/**
|
|
62
166
|
* Synchronous poll — blocks the event loop. Used by the bridge extension
|
|
63
167
|
* where async isn't practical (some pi extension hooks are sync).
|
|
@@ -70,7 +174,12 @@ export function pollOpenSpec(cwd: string): OpenSpecData {
|
|
|
70
174
|
for (const c of listResult.changes) {
|
|
71
175
|
statusResults.set(c.name, statusOr({ cwd, change: c.name }));
|
|
72
176
|
}
|
|
73
|
-
return buildOpenSpecData(
|
|
177
|
+
return buildOpenSpecData(
|
|
178
|
+
listResult,
|
|
179
|
+
statusResults,
|
|
180
|
+
createFsProbeFactory(cwd),
|
|
181
|
+
createFsSpecsProbeFactory(cwd),
|
|
182
|
+
);
|
|
74
183
|
}
|
|
75
184
|
|
|
76
185
|
/**
|
|
@@ -114,5 +223,10 @@ export async function pollOpenSpecAsync(cwd: string): Promise<OpenSpecData> {
|
|
|
114
223
|
}),
|
|
115
224
|
);
|
|
116
225
|
const statusResults = new Map<string, any>(statusEntries);
|
|
117
|
-
return buildOpenSpecData(
|
|
226
|
+
return buildOpenSpecData(
|
|
227
|
+
listResult,
|
|
228
|
+
statusResults,
|
|
229
|
+
createFsProbeFactory(cwd),
|
|
230
|
+
createFsSpecsProbeFactory(cwd),
|
|
231
|
+
);
|
|
118
232
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-evidence override for the OpenSpec `specs` artifact.
|
|
3
|
+
*
|
|
4
|
+
* The `spec-driven` schema declares `specs/**\/*.md` as the `generates`
|
|
5
|
+
* pattern for the `specs` artifact, and the openspec CLI marks the
|
|
6
|
+
* artifact `done` whenever that glob matches anything. The dashboard's
|
|
7
|
+
* mtime-gated cache, however, can momentarily stale on `specs: ready`
|
|
8
|
+
* for multi-spec changes (see change:
|
|
9
|
+
* fix-openspec-specs-mtime-gate-blind-spot — the watch set is now
|
|
10
|
+
* extended to cover `specs/**`, but this override is the second line
|
|
11
|
+
* of defence).
|
|
12
|
+
*
|
|
13
|
+
* This module computes a boolean "is specs satisfied locally?" from
|
|
14
|
+
* file-system evidence the dashboard's cache might miss between polls.
|
|
15
|
+
* It is consumed by:
|
|
16
|
+
*
|
|
17
|
+
* 1. `buildOpenSpecData` in `openspec-poller.ts` — promotes
|
|
18
|
+
* `artifacts[specs].status` from "ready" to "done" when at least
|
|
19
|
+
* one `specs/**\/*.md` file exists. Promote-only; specs-only;
|
|
20
|
+
* never demotes; never touches other artifacts.
|
|
21
|
+
*
|
|
22
|
+
* One rule:
|
|
23
|
+
*
|
|
24
|
+
* any file matching `specs/**\/*.md` exists in the change folder
|
|
25
|
+
*
|
|
26
|
+
* The probe walks the `specs/` subtree once and short-circuits on the
|
|
27
|
+
* first `*.md` it finds. Defensive: every fs call is wrapped in
|
|
28
|
+
* try/catch and treated as "no match" on error.
|
|
29
|
+
*
|
|
30
|
+
* See change: fix-openspec-specs-mtime-gate-blind-spot.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readdirSync } from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
|
|
36
|
+
/** Probe surface — kept tiny so unit tests can pass an in-memory stub. */
|
|
37
|
+
export interface SpecsEvidenceProbe {
|
|
38
|
+
/** Returns true iff at least one `*.md` file exists under `<changeDir>/specs/`. */
|
|
39
|
+
hasAnySpecFile(changeDir: string): boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pure rule evaluator. Single rule; short-circuits on first match. */
|
|
43
|
+
export function evaluateLocalSpecsSatisfaction(
|
|
44
|
+
changeDir: string,
|
|
45
|
+
probe: SpecsEvidenceProbe,
|
|
46
|
+
): boolean {
|
|
47
|
+
return probe.hasAnySpecFile(changeDir);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Production probe — backed by the real filesystem. Walks `<changeDir>/specs/`
|
|
52
|
+
* iteratively, short-circuits on the first `*.md` file encountered. Every
|
|
53
|
+
* `readdirSync` is wrapped in try/catch (handles ENOENT, permission errors,
|
|
54
|
+
* symlink loops, and any unexpected fs error) and treated as "no match".
|
|
55
|
+
*/
|
|
56
|
+
export function createFsSpecsEvidenceProbe(): SpecsEvidenceProbe {
|
|
57
|
+
return {
|
|
58
|
+
hasAnySpecFile(changeDir: string): boolean {
|
|
59
|
+
const root = path.join(changeDir, "specs");
|
|
60
|
+
// Iterative DFS — no recursion to avoid stack overflow on pathological trees.
|
|
61
|
+
const stack: string[] = [root];
|
|
62
|
+
while (stack.length > 0) {
|
|
63
|
+
const dir = stack.pop()!;
|
|
64
|
+
let entries: import("node:fs").Dirent[];
|
|
65
|
+
try {
|
|
66
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
67
|
+
} catch {
|
|
68
|
+
// Missing dir, permission denied, or any other fs error — skip.
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
for (const e of entries) {
|
|
72
|
+
if (e.isFile() && e.name.endsWith(".md")) return true;
|
|
73
|
+
if (e.isDirectory()) stack.push(path.join(dir, e.name));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|