@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.
Files changed (108) hide show
  1. package/AGENTS.md +79 -32
  2. package/README.md +7 -3
  3. package/docs/architecture.md +361 -12
  4. package/package.json +7 -7
  5. package/packages/extension/package.json +7 -2
  6. package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
  7. package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
  8. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  9. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  10. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  11. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  12. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  13. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  14. package/packages/extension/src/ask-user-tool.ts +165 -57
  15. package/packages/extension/src/bridge.ts +97 -4
  16. package/packages/extension/src/multiselect-decode.ts +40 -0
  17. package/packages/extension/src/multiselect-polyfill.ts +38 -8
  18. package/packages/extension/src/ui-modules.ts +272 -0
  19. package/packages/server/package.json +9 -3
  20. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  21. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  22. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  23. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  24. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  25. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  26. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  27. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  28. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  29. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  30. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  31. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  32. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  33. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  34. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  35. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  36. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  37. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  38. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  39. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  40. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  41. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  42. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  43. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  44. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  45. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  46. package/packages/server/src/browse.ts +118 -13
  47. package/packages/server/src/browser-gateway.ts +19 -0
  48. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  49. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  50. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  51. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  52. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  53. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  54. package/packages/server/src/cli.ts +5 -6
  55. package/packages/server/src/directory-service.ts +156 -15
  56. package/packages/server/src/event-wiring.ts +111 -10
  57. package/packages/server/src/installed-package-enricher.ts +143 -0
  58. package/packages/server/src/package-manager-wrapper.ts +305 -8
  59. package/packages/server/src/package-source-helpers.ts +104 -0
  60. package/packages/server/src/pending-attach-registry.ts +112 -0
  61. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  62. package/packages/server/src/pi-core-checker.ts +9 -14
  63. package/packages/server/src/pi-gateway.ts +14 -0
  64. package/packages/server/src/proposal-attach-naming.ts +47 -0
  65. package/packages/server/src/routes/file-routes.ts +29 -3
  66. package/packages/server/src/routes/package-routes.ts +72 -3
  67. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  68. package/packages/server/src/routes/system-routes.ts +2 -0
  69. package/packages/server/src/server.ts +339 -10
  70. package/packages/server/src/session-api.ts +30 -5
  71. package/packages/server/src/session-order-manager.ts +22 -0
  72. package/packages/server/src/session-scanner.ts +10 -1
  73. package/packages/shared/package.json +9 -2
  74. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  75. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  76. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  77. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  78. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  79. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  80. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  81. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  82. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  83. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  84. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  85. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  86. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  87. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  88. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  89. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  90. package/packages/shared/src/browser-protocol.ts +110 -4
  91. package/packages/shared/src/config.ts +45 -0
  92. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  93. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  94. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  95. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  96. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  97. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  98. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  99. package/packages/shared/src/openspec-poller.ts +117 -3
  100. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  101. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  102. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  103. package/packages/shared/src/protocol.ts +56 -2
  104. package/packages/shared/src/recommended-extensions.ts +7 -1
  105. package/packages/shared/src/rest-api.ts +68 -3
  106. package/packages/shared/src/state-replay.ts +11 -1
  107. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  108. 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
- const command = args.command as string | undefined;
87
- if (!command || !command.includes("openspec")) return null;
88
-
89
- // Check for --change flag
90
- const flagMatch = command.match(CLI_CHANGE_FLAG_RE);
91
- if (flagMatch) {
92
- return { changeName: flagMatch[1], isActive: true };
93
- }
94
-
95
- // Check for openspec archive <name>
96
- const archiveMatch = command.match(CLI_ARCHIVE_RE);
97
- if (archiveMatch) {
98
- return { changeName: archiveMatch[1], isActive: true };
99
- }
100
-
101
- // Check for openspec new change "name"
102
- const newChangeMatch = command.match(CLI_NEW_CHANGE_RE);
103
- if (newChangeMatch) {
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
- const isComplete =
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(listResult, statusResults);
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(listResult, statusResults);
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
+ }