@adhdev/daemon-core 0.9.82-rc.328 → 0.9.82-rc.329

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.
@@ -31,7 +31,7 @@ export declare function reconcileDirectDispatchCompletionFromTranscript(args: {
31
31
  ledgerEntryId?: string;
32
32
  reason?: string;
33
33
  };
34
- export declare function buildLongGeneratingCompletionReconciliation(args: {
34
+ export declare function buildNoProgressCompletionReconciliation(args: {
35
35
  meshId: string;
36
36
  nodeId?: string;
37
37
  nodeLabel: string;
@@ -3,16 +3,16 @@
3
3
  *
4
4
  * Common across all Provider categories (IDE/Extension/CLI/ACP).
5
5
  * - Approval waiting (waiting_approval) notification
6
- * - Notification when generating persists for extended duration
6
+ * - No-progress watchdog: alert when an active turn makes no progress for a while
7
7
  * - All config toggleable via Provider Settings
8
8
  */
9
9
  export interface MonitorConfig {
10
10
  /** Enable awaiting-approval notification */
11
11
  approvalAlert: boolean;
12
- /** Prolonged generating notification enabled */
13
- longGeneratingAlert: boolean;
14
- /** Prolonged threshold (seconds) */
15
- longGeneratingThresholdSec: number;
12
+ /** No-progress watchdog notification enabled */
13
+ noProgressAlert: boolean;
14
+ /** No-progress threshold (seconds) */
15
+ noProgressThresholdSec: number;
16
16
  /** Repeat notification cooldown (seconds) */
17
17
  alertCooldownSec: number;
18
18
  }
@@ -28,7 +28,7 @@ export declare class StatusMonitor {
28
28
  private config;
29
29
  private lastAlertTime;
30
30
  private generatingStartTimes;
31
- private longGeneratingAlerted;
31
+ private noProgressAlerted;
32
32
  private lastProgressFingerprint;
33
33
  private lastProgressChangeAt;
34
34
  constructor(config?: Partial<MonitorConfig>);
@@ -40,7 +40,7 @@ export declare class StatusMonitor {
40
40
  * Check status transition → return notification event array.
41
41
  * Called from each onTick() or detectStatusTransition().
42
42
  */
43
- check(agentKey: string, status: string, now: number, progressFingerprint?: string): MonitorEvent[];
43
+ check(agentKey: string, status: string, now: number, progressFingerprint?: string, approvalPending?: boolean): MonitorEvent[];
44
44
  /** Cooldown check — prevent sending the same notification too frequently */
45
45
  private shouldAlert;
46
46
  /** Reset (on agent terminate/restart) */
@@ -619,7 +619,7 @@ export interface DashboardBootstrapDaemonEntry extends Partial<CloudDaemonSummar
619
619
  p2p?: StatusReportPayload['p2p'];
620
620
  timestamp?: number;
621
621
  }
622
- export type DaemonStatusEventName = 'agent:generating_started' | 'agent:waiting_approval' | 'agent:generating_completed' | 'agent:stopped' | 'monitor:long_generating';
622
+ export type DaemonStatusEventName = 'agent:generating_started' | 'agent:waiting_approval' | 'agent:generating_completed' | 'agent:stopped' | 'monitor:no_progress' | 'monitor:long_generating';
623
623
  /** Minimal daemon-originated event payload relayed through the server. */
624
624
  export interface DaemonStatusEventPayload {
625
625
  event: DaemonStatusEventName;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.328",
3
+ "version": "0.9.82-rc.329",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.328",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.329",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -256,7 +256,7 @@ export class ProviderStreamAdapter implements IAgentStreamAdapter {
256
256
  }
257
257
  const validated = validateReadChatResultPayload(data, `${this.agentType} readChat`);
258
258
  const validatedStatus = (validated as any).status as string;
259
- const streamStatus = validatedStatus === 'generating' || validatedStatus === 'long_generating'
259
+ const streamStatus = validatedStatus === 'generating' || validatedStatus === 'no_progress' || validatedStatus === 'long_generating'
260
260
  ? 'streaming'
261
261
  : validatedStatus;
262
262
  const state: AgentStreamState = {
@@ -1321,7 +1321,7 @@ export class ProviderCliAdapter implements CliAdapter {
1321
1321
  };
1322
1322
  if (parsedSessionStatus === 'idle' && hasFinalAssistant(parsedStatusBeforeSend)) return null;
1323
1323
  if (this.engine.currentStatus === 'generating') return 'current_status_generating';
1324
- if (parsedSessionStatus === 'generating' || parsedSessionStatus === 'long_generating') {
1324
+ if (parsedSessionStatus === 'generating' || parsedSessionStatus === 'no_progress' || parsedSessionStatus === 'long_generating') {
1325
1325
  const parsedModal = parsedStatusBeforeSend?.activeModal ?? parsedStatusBeforeSend?.modal ?? null;
1326
1326
  const parsedHasActionableModal = Boolean(
1327
1327
  parsedModal
@@ -1414,7 +1414,7 @@ export class ProviderCliAdapter implements CliAdapter {
1414
1414
  const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
1415
1415
  ? String(parsedStatusBeforeSend.status)
1416
1416
  : '';
1417
- if (!allowInputDuringGeneration && (parsedSessionStatus === 'generating' || parsedSessionStatus === 'long_generating')) {
1417
+ if (!allowInputDuringGeneration && (parsedSessionStatus === 'generating' || parsedSessionStatus === 'no_progress' || parsedSessionStatus === 'long_generating')) {
1418
1418
  const parsedModal = parsedStatusBeforeSend?.activeModal ?? parsedStatusBeforeSend?.modal ?? null;
1419
1419
  const parsedHasActionableModal = Boolean(
1420
1420
  parsedModal
@@ -62,7 +62,7 @@ export interface CliSessionStatus {
62
62
  * Timestamp (ms) of the most recent *visible* terminal screen change.
63
63
  * Stricter than lastOutputAt — only advances when the rendered screen
64
64
  * content actually differs, so repeated keepalive bytes do not register as
65
- * progress. Preferred liveness signal for the long-generating watchdog.
65
+ * progress. Preferred liveness signal for the no-progress watchdog.
66
66
  */
67
67
  lastScreenChangeAt?: number;
68
68
  bufferState?: {
@@ -1372,7 +1372,7 @@ function normalizeReadChatCommandStatus(status: unknown, activeModal: unknown):
1372
1372
  }
1373
1373
 
1374
1374
  function isGeneratingLikeStatus(status: unknown): boolean {
1375
- return status === 'generating' || status === 'streaming' || status === 'long_generating' || status === 'starting';
1375
+ return status === 'generating' || status === 'streaming' || status === 'no_progress' || status === 'long_generating' || status === 'starting';
1376
1376
  }
1377
1377
 
1378
1378
  function hasVisibleAssistantMessage(messages: unknown[] | undefined): boolean {
@@ -81,7 +81,7 @@ export interface CliManagerDeps {
81
81
 
82
82
  type CommandResult = { success: boolean;[key: string]: unknown };
83
83
 
84
- const BUSY_AGENT_STATUSES = new Set(['generating', 'running', 'streaming', 'starting', 'busy', 'waiting', 'waiting_approval', 'long_generating']);
84
+ const BUSY_AGENT_STATUSES = new Set(['generating', 'running', 'streaming', 'starting', 'busy', 'waiting', 'waiting_approval', 'no_progress', 'long_generating']);
85
85
  const ZERO_MESSAGE_STARTING_SEND_WAIT_MS = 2_000;
86
86
 
87
87
  function normalizeAgentStatus(value: unknown): string {
@@ -27,6 +27,13 @@ import { listProviderHistorySessions } from '../config/chat-history.js';
27
27
  import { detectIDEs } from '../detection/ide-detector.js';
28
28
  import { detectCLI, detectCLIs } from '../detection/cli-detector.js';
29
29
  import { getGitRepoStatus } from '../git/git-status.js';
30
+ import {
31
+ CHANGE_IMPACT_CONFIG_LOCATIONS,
32
+ CHANGE_IMPACT_CONFIG_SCHEMA,
33
+ loadChangeImpactConfig,
34
+ suggestChangeImpactConfig,
35
+ validateChangeImpactConfig,
36
+ } from '../git/change-impact-config.js';
30
37
  import {
31
38
  normalizeGitStatus as sharedNormalizeGitStatus,
32
39
  pickBestTransitGitStatus as sharedPickBestTransitGitStatus,
@@ -7693,6 +7700,45 @@ export class DaemonCommandRouter {
7693
7700
  };
7694
7701
  }
7695
7702
 
7703
+ case 'get_mesh_change_impact_config_schema': {
7704
+ return {
7705
+ success: true,
7706
+ schema: CHANGE_IMPACT_CONFIG_SCHEMA,
7707
+ locations: CHANGE_IMPACT_CONFIG_LOCATIONS,
7708
+ sourceOfTruth: 'repo change-impact config',
7709
+ heuristicRole: 'suggestions_only_not_execution_path',
7710
+ note: 'Declarative config only — JSON/YAML are parsed but never executed. Defines which package/file changes require a daemon rebuild/restart vs. a web-only redeploy vs. nothing.',
7711
+ };
7712
+ }
7713
+
7714
+ case 'validate_mesh_change_impact_config': {
7715
+ const workspace = typeof args?.workspace === 'string' ? args.workspace : process.cwd();
7716
+ if (args?.config !== undefined) {
7717
+ const validation = validateChangeImpactConfig(args.config, 'inline');
7718
+ return { success: validation.valid, source: 'inline', sourceType: 'mesh_policy', ...validation };
7719
+ }
7720
+ const loaded = loadChangeImpactConfig(workspace);
7721
+ if (loaded.sourceType === 'repo_file') {
7722
+ const validation = validateChangeImpactConfig(loaded.config, loaded.source);
7723
+ return { success: validation.valid, ...loaded, ...validation };
7724
+ }
7725
+ return {
7726
+ success: false,
7727
+ ...loaded,
7728
+ valid: false,
7729
+ errors: [loaded.error || 'repo change-impact config unavailable'],
7730
+ };
7731
+ }
7732
+
7733
+ case 'suggest_mesh_change_impact_config': {
7734
+ const workspace = typeof args?.workspace === 'string' ? args.workspace : process.cwd();
7735
+ return {
7736
+ success: true,
7737
+ ...suggestChangeImpactConfig(workspace),
7738
+ note: 'Suggestions are heuristic scaffold only; the draft must be reviewed and saved into repo change-impact config before it takes effect. Nothing is executed.',
7739
+ };
7740
+ }
7741
+
7696
7742
  case 'mesh_init': {
7697
7743
  const workspace = typeof args?.workspace === 'string' && args.workspace.trim() ? args.workspace.trim() : process.cwd();
7698
7744
  const mesh = args?.inlineMesh || {};
@@ -0,0 +1,354 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
2
+ import { join } from 'path';
3
+ import * as yaml from 'js-yaml';
4
+
5
+ /**
6
+ * "Change Impact" — config-driven classification of which git changes between the
7
+ * live daemon's build commit and the workspace HEAD actually require a daemon
8
+ * rebuild/restart (vs. a web-only redeploy, vs. nothing).
9
+ *
10
+ * The daemon-core git surface only knows the FACTS (which files / packages changed
11
+ * between buildCommit..HEAD). The POLICY — which packages are daemon-runtime, which
12
+ * are web-only, which root files are non-runtime, and what command to recommend —
13
+ * is declarative config injected from outside. This keeps git-status.ts repo-agnostic
14
+ * while preserving ADHDev's exact built-in behavior when no config is present.
15
+ *
16
+ * SECURITY: config is declarative only. JSON/YAML are parsed; arbitrary JS config
17
+ * (.js) is intentionally NOT supported, so loading a config can never execute code.
18
+ */
19
+
20
+ /** A change-impact classification bucket. */
21
+ export type ChangeImpactKind = 'daemon' | 'web' | 'none';
22
+
23
+ /**
24
+ * Recommended action for a given impact classification. `recommendedAction` is the
25
+ * stable classification key (always one of daemon|web|none, NOT config-overridable);
26
+ * config customizes only the human-facing `recommendedCommand` recipe.
27
+ */
28
+ export interface ChangeImpactTarget {
29
+ /** Human-facing command/recipe to act on the impact (e.g. deploy + restart). */
30
+ recommendedCommand: string;
31
+ }
32
+
33
+ export interface ChangeImpactConfig {
34
+ /**
35
+ * Package names (the `<name>` in `packages/<name>/`) whose change means the
36
+ * daemon runtime is stale and must be rebuilt/redeployed + restarted. When
37
+ * provided, this REPLACES the built-in daemon-runtime set.
38
+ */
39
+ daemonRuntimePackages?: string[];
40
+ /**
41
+ * Package names that are web-only — a change to one does not require a daemon
42
+ * restart, only a web redeploy. When provided, this REPLACES the built-in
43
+ * web-only set.
44
+ */
45
+ webOnlyPackages?: string[];
46
+ /**
47
+ * Glob patterns for root-level (non-package) files that demonstrably cannot
48
+ * change what the daemon runtime executes (markers, docs, license text). These
49
+ * are added on top of the built-in non-runtime root-file detection.
50
+ */
51
+ nonRuntimeRootFilePatterns?: string[];
52
+ /** Recommended action/command per impact classification. Missing keys fall back to built-in defaults. */
53
+ impactTargets?: Partial<Record<ChangeImpactKind, ChangeImpactTarget>>;
54
+ }
55
+
56
+ export interface ChangeImpactConfigLoadResult {
57
+ config?: ChangeImpactConfig;
58
+ source: string;
59
+ sourceType: 'repo_file' | 'unavailable' | 'invalid';
60
+ path?: string;
61
+ error?: string;
62
+ /**
63
+ * Stable identity of the config SOURCE, used as part of a cache key so a config
64
+ * edit invalidates any cached impact evaluation. Combines the resolved path and
65
+ * its mtime (or the path for the unavailable/invalid case).
66
+ */
67
+ sourceKey: string;
68
+ }
69
+
70
+ export const CHANGE_IMPACT_CONFIG_LOCATIONS = [
71
+ '.adhdev/change-impact.json',
72
+ '.adhdev/change-impact.yaml',
73
+ '.adhdev/change-impact.yml',
74
+ '.adhdev/repo-mesh-change-impact.json',
75
+ '.adhdev/repo-mesh-change-impact.yaml',
76
+ '.adhdev/repo-mesh-change-impact.yml',
77
+ ];
78
+
79
+ export const CHANGE_IMPACT_CONFIG_SCHEMA = {
80
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
81
+ title: 'ADHDev Change Impact Config',
82
+ type: 'object',
83
+ additionalProperties: false,
84
+ properties: {
85
+ daemonRuntimePackages: { type: 'array', items: { type: 'string', minLength: 1 } },
86
+ webOnlyPackages: { type: 'array', items: { type: 'string', minLength: 1 } },
87
+ nonRuntimeRootFilePatterns: { type: 'array', items: { type: 'string', minLength: 1 } },
88
+ impactTargets: {
89
+ type: 'object',
90
+ additionalProperties: false,
91
+ properties: {
92
+ daemon: { $ref: '#/$defs/target' },
93
+ web: { $ref: '#/$defs/target' },
94
+ none: { $ref: '#/$defs/target' },
95
+ },
96
+ },
97
+ },
98
+ $defs: {
99
+ target: {
100
+ type: 'object',
101
+ additionalProperties: false,
102
+ required: ['recommendedCommand'],
103
+ properties: {
104
+ recommendedCommand: { type: 'string', minLength: 1 },
105
+ },
106
+ },
107
+ },
108
+ } as const;
109
+
110
+ function isRecord(value: unknown): value is Record<string, unknown> {
111
+ return !!value && typeof value === 'object' && !Array.isArray(value);
112
+ }
113
+
114
+ function isStringArray(value: unknown): value is string[] {
115
+ return Array.isArray(value) && value.every(v => typeof v === 'string' && v.length > 0);
116
+ }
117
+
118
+ function validateTarget(value: unknown, key: string, errors: string[]): ChangeImpactTarget | undefined {
119
+ if (!isRecord(value)) {
120
+ errors.push(`impactTargets.${key} must be an object`);
121
+ return undefined;
122
+ }
123
+ const { recommendedCommand } = value;
124
+ if (typeof recommendedCommand !== 'string' || !recommendedCommand.length) {
125
+ errors.push(`impactTargets.${key}.recommendedCommand must be a non-empty string`);
126
+ return undefined;
127
+ }
128
+ // recommendedAction is the fixed classification key; config supplies only the command.
129
+ for (const k of Object.keys(value)) {
130
+ if (k !== 'recommendedCommand') errors.push(`impactTargets.${key}.${k} is not a recognized field (only recommendedCommand)`);
131
+ }
132
+ return { recommendedCommand };
133
+ }
134
+
135
+ export function validateChangeImpactConfig(raw: unknown, source = 'inline'): { valid: boolean; errors: string[]; config?: ChangeImpactConfig } {
136
+ const errors: string[] = [];
137
+ if (!isRecord(raw)) {
138
+ return { valid: false, errors: [`${source}: config must be an object`] };
139
+ }
140
+ const config: ChangeImpactConfig = {};
141
+
142
+ if (raw.daemonRuntimePackages !== undefined) {
143
+ if (isStringArray(raw.daemonRuntimePackages)) config.daemonRuntimePackages = [...raw.daemonRuntimePackages];
144
+ else errors.push('daemonRuntimePackages must be an array of non-empty strings');
145
+ }
146
+ if (raw.webOnlyPackages !== undefined) {
147
+ if (isStringArray(raw.webOnlyPackages)) config.webOnlyPackages = [...raw.webOnlyPackages];
148
+ else errors.push('webOnlyPackages must be an array of non-empty strings');
149
+ }
150
+ if (raw.nonRuntimeRootFilePatterns !== undefined) {
151
+ if (isStringArray(raw.nonRuntimeRootFilePatterns)) config.nonRuntimeRootFilePatterns = [...raw.nonRuntimeRootFilePatterns];
152
+ else errors.push('nonRuntimeRootFilePatterns must be an array of non-empty strings');
153
+ }
154
+ if (raw.impactTargets !== undefined) {
155
+ if (!isRecord(raw.impactTargets)) {
156
+ errors.push('impactTargets must be an object');
157
+ } else {
158
+ const targets: Partial<Record<ChangeImpactKind, ChangeImpactTarget>> = {};
159
+ for (const key of Object.keys(raw.impactTargets)) {
160
+ if (key !== 'daemon' && key !== 'web' && key !== 'none') {
161
+ errors.push(`impactTargets.${key} is not a recognized impact kind (daemon|web|none)`);
162
+ continue;
163
+ }
164
+ const target = validateTarget(raw.impactTargets[key], key, errors);
165
+ if (target) targets[key as ChangeImpactKind] = target;
166
+ }
167
+ if (Object.keys(targets).length) config.impactTargets = targets;
168
+ }
169
+ }
170
+ // Reject unknown top-level keys to catch typos early (mirrors additionalProperties:false).
171
+ for (const key of Object.keys(raw)) {
172
+ if (!['daemonRuntimePackages', 'webOnlyPackages', 'nonRuntimeRootFilePatterns', 'impactTargets'].includes(key)) {
173
+ errors.push(`unknown config key '${key}'`);
174
+ }
175
+ }
176
+
177
+ return { valid: errors.length === 0, errors, config: errors.length === 0 ? config : undefined };
178
+ }
179
+
180
+ function parseConfigText(path: string, text: string): unknown {
181
+ if (/\.json$/i.test(path)) return JSON.parse(text);
182
+ return yaml.load(text);
183
+ }
184
+
185
+ /**
186
+ * Locate and load the Change Impact config for a repo root. Declarative only —
187
+ * never executes config. Returns a sourceKey suitable for cache invalidation:
188
+ * config path + mtime, or the sentinel for the missing/invalid case.
189
+ */
190
+ export function loadChangeImpactConfig(repoRoot: string): ChangeImpactConfigLoadResult {
191
+ for (const relative of CHANGE_IMPACT_CONFIG_LOCATIONS) {
192
+ const configPath = join(repoRoot, relative);
193
+ if (!existsSync(configPath)) continue;
194
+ try {
195
+ const text = readFileSync(configPath, 'utf-8');
196
+ let mtimeMs = 0;
197
+ try {
198
+ mtimeMs = statSync(configPath).mtimeMs;
199
+ } catch {
200
+ // Best-effort; fall back to content length so edits still perturb the key.
201
+ mtimeMs = text.length;
202
+ }
203
+ const parsed = parseConfigText(configPath, text);
204
+ const validation = validateChangeImpactConfig(parsed, relative);
205
+ if (!validation.valid) {
206
+ return {
207
+ source: relative,
208
+ sourceType: 'invalid',
209
+ path: configPath,
210
+ error: validation.errors.join('; '),
211
+ sourceKey: `invalid:${configPath}:${mtimeMs}`,
212
+ };
213
+ }
214
+ return {
215
+ config: validation.config,
216
+ source: relative,
217
+ sourceType: 'repo_file',
218
+ path: configPath,
219
+ sourceKey: `file:${configPath}:${mtimeMs}`,
220
+ };
221
+ } catch (error: any) {
222
+ return {
223
+ source: relative,
224
+ sourceType: 'invalid',
225
+ path: configPath,
226
+ error: error?.message || String(error),
227
+ sourceKey: `error:${configPath}`,
228
+ };
229
+ }
230
+ }
231
+ return {
232
+ source: 'unavailable',
233
+ sourceType: 'unavailable',
234
+ error: `No change-impact config found. Checked: ${CHANGE_IMPACT_CONFIG_LOCATIONS.join(', ')}`,
235
+ sourceKey: 'unavailable',
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Translate a config glob pattern into a RegExp matched against a forward-slash
241
+ * file path. Supports `**` (any path segments), `*` (any chars except `/`), and
242
+ * `?` (single non-slash char). No brace/character-class expansion — declarative,
243
+ * minimal, predictable. All other characters are matched literally.
244
+ */
245
+ export function globToRegExp(pattern: string): RegExp {
246
+ let out = '';
247
+ for (let i = 0; i < pattern.length; i++) {
248
+ const ch = pattern[i];
249
+ if (ch === '*') {
250
+ if (pattern[i + 1] === '*') {
251
+ // `**` → any sequence of characters including path separators.
252
+ out += '.*';
253
+ i++;
254
+ // Swallow a trailing slash after `**/` so `**/x` also matches `x`.
255
+ if (pattern[i + 1] === '/') i++;
256
+ } else {
257
+ out += '[^/]*';
258
+ }
259
+ } else if (ch === '?') {
260
+ out += '[^/]';
261
+ } else if ('.+^${}()|[]\\'.includes(ch)) {
262
+ out += '\\' + ch;
263
+ } else {
264
+ out += ch;
265
+ }
266
+ }
267
+ return new RegExp(`^${out}$`);
268
+ }
269
+
270
+ export interface ChangeImpactConfigSuggestion {
271
+ /** A non-executable, declarative config draft a human can save verbatim. */
272
+ suggestedConfig: ChangeImpactConfig;
273
+ /** Per-field human-facing rationale for the proposed classification. */
274
+ notes: string[];
275
+ /** Package directories discovered under `packages/` (and `oss/packages/`). */
276
+ discoveredPackages: { daemon: string[]; web: string[]; unclassified: string[] };
277
+ }
278
+
279
+ function listPackageDirs(packagesRoot: string): string[] {
280
+ try {
281
+ return readdirSync(packagesRoot, { withFileTypes: true })
282
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
283
+ .map(entry => entry.name);
284
+ } catch {
285
+ return [];
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Inspect a repo's package layout and propose a reasonable, declarative Change
291
+ * Impact config draft. This is heuristic scaffold ONLY — it never executes
292
+ * anything and the draft must be reviewed/saved by a human before it takes
293
+ * effect. The heuristic mirrors ADHDev's own conventions:
294
+ * - `web-*` / `*-web` packages → web-only (web redeploy, no daemon restart)
295
+ * - everything else under packages/ → daemon-runtime (rebuild + restart)
296
+ * - docs/license/marker root files → non-runtime
297
+ * Callers are expected to prune the draft to their actual runtime topology.
298
+ */
299
+ export function suggestChangeImpactConfig(repoRoot: string): ChangeImpactConfigSuggestion {
300
+ const notes: string[] = [];
301
+ const daemon = new Set<string>();
302
+ const web = new Set<string>();
303
+ const unclassified: string[] = [];
304
+
305
+ const roots = ['packages', join('oss', 'packages')];
306
+ for (const rel of roots) {
307
+ const packagesRoot = join(repoRoot, rel);
308
+ if (!existsSync(packagesRoot)) continue;
309
+ for (const name of listPackageDirs(packagesRoot)) {
310
+ if (/(^web[-.]|[-.]web$)/i.test(name) || /dashboard|frontend|ui$/i.test(name)) {
311
+ web.add(name);
312
+ } else {
313
+ daemon.add(name);
314
+ }
315
+ }
316
+ }
317
+
318
+ const daemonRuntimePackages = [...daemon].sort();
319
+ const webOnlyPackages = [...web].sort();
320
+
321
+ if (!daemonRuntimePackages.length && !webOnlyPackages.length) {
322
+ notes.push('No packages/ or oss/packages/ directories found — defaulting to an empty draft you should fill in by hand.');
323
+ } else {
324
+ if (daemonRuntimePackages.length) notes.push(`Classified ${daemonRuntimePackages.length} package(s) as daemon-runtime (change → rebuild/redeploy + restart).`);
325
+ if (webOnlyPackages.length) notes.push(`Classified ${webOnlyPackages.length} web-* package(s) as web-only (change → web redeploy, no daemon restart).`);
326
+ notes.push('Heuristic only: confirm each package actually matches its bucket before saving.');
327
+ }
328
+
329
+ const nonRuntimeRootFilePatterns = [
330
+ '*.md',
331
+ 'docs/**',
332
+ 'LICENSE',
333
+ 'LICENSE.*',
334
+ '.gitignore',
335
+ ];
336
+ notes.push('nonRuntimeRootFilePatterns lists root files that demonstrably cannot change daemon runtime behavior; extend with your repo markers.');
337
+
338
+ const suggestedConfig: ChangeImpactConfig = {
339
+ ...(daemonRuntimePackages.length ? { daemonRuntimePackages } : {}),
340
+ ...(webOnlyPackages.length ? { webOnlyPackages } : {}),
341
+ nonRuntimeRootFilePatterns,
342
+ impactTargets: {
343
+ daemon: { recommendedCommand: 'rebuild + redeploy the daemon, then restart it' },
344
+ web: { recommendedCommand: 'redeploy the web app (no daemon restart required)' },
345
+ none: { recommendedCommand: 'no action required' },
346
+ },
347
+ };
348
+
349
+ return {
350
+ suggestedConfig,
351
+ notes,
352
+ discoveredPackages: { daemon: daemonRuntimePackages, web: webOnlyPackages, unclassified },
353
+ };
354
+ }