@nexus-ai-nexoralabs/cli 0.1.2 → 0.1.4

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 (133) hide show
  1. package/.env.example +16 -16
  2. package/LICENSE +21 -21
  3. package/README.md +135 -106
  4. package/dist/api/index.d.ts +4 -0
  5. package/dist/api/index.d.ts.map +1 -1
  6. package/dist/api/index.js +9 -3
  7. package/dist/api/index.js.map +1 -1
  8. package/dist/chat/chatService.d.ts.map +1 -1
  9. package/dist/chat/chatService.js +22 -3
  10. package/dist/chat/chatService.js.map +1 -1
  11. package/dist/chat/conversationService.d.ts +7 -1
  12. package/dist/chat/conversationService.d.ts.map +1 -1
  13. package/dist/chat/conversationService.js +48 -15
  14. package/dist/chat/conversationService.js.map +1 -1
  15. package/dist/cli/agentRunner.d.ts.map +1 -1
  16. package/dist/cli/agentRunner.js +88 -8
  17. package/dist/cli/agentRunner.js.map +1 -1
  18. package/dist/cli/chatRunner.d.ts +65 -0
  19. package/dist/cli/chatRunner.d.ts.map +1 -0
  20. package/dist/cli/chatRunner.js +289 -0
  21. package/dist/cli/chatRunner.js.map +1 -0
  22. package/dist/cli/index.d.ts +2 -0
  23. package/dist/cli/index.d.ts.map +1 -1
  24. package/dist/cli/index.js +2 -0
  25. package/dist/cli/index.js.map +1 -1
  26. package/dist/cli/inputEngine.d.ts +10 -0
  27. package/dist/cli/inputEngine.d.ts.map +1 -1
  28. package/dist/cli/inputEngine.js +28 -1
  29. package/dist/cli/inputEngine.js.map +1 -1
  30. package/dist/cli/main.d.ts +4 -0
  31. package/dist/cli/main.d.ts.map +1 -1
  32. package/dist/cli/main.js +90 -26
  33. package/dist/cli/main.js.map +1 -1
  34. package/dist/cli/uiFormatters.d.ts +11 -1
  35. package/dist/cli/uiFormatters.d.ts.map +1 -1
  36. package/dist/cli/uiFormatters.js +253 -15
  37. package/dist/cli/uiFormatters.js.map +1 -1
  38. package/dist/intelligence/modelGateway.d.ts +1 -0
  39. package/dist/intelligence/modelGateway.d.ts.map +1 -1
  40. package/dist/intelligence/modelGateway.js +63 -6
  41. package/dist/intelligence/modelGateway.js.map +1 -1
  42. package/dist/intelligence/modelRegistry.d.ts +2 -2
  43. package/dist/intelligence/modelRegistry.d.ts.map +1 -1
  44. package/dist/intelligence/modelRegistry.js +72 -31
  45. package/dist/intelligence/modelRegistry.js.map +1 -1
  46. package/dist/intelligence/modelRouter.d.ts.map +1 -1
  47. package/dist/intelligence/modelRouter.js +62 -16
  48. package/dist/intelligence/modelRouter.js.map +1 -1
  49. package/dist/intelligence/providers/embeddedInferenceProvider.d.ts.map +1 -1
  50. package/dist/intelligence/providers/embeddedInferenceProvider.js +2224 -6
  51. package/dist/intelligence/providers/embeddedInferenceProvider.js.map +1 -1
  52. package/dist/intelligence/runtime/runtimeManager.d.ts +5 -0
  53. package/dist/intelligence/runtime/runtimeManager.d.ts.map +1 -1
  54. package/dist/intelligence/runtime/runtimeManager.js +64 -4
  55. package/dist/intelligence/runtime/runtimeManager.js.map +1 -1
  56. package/dist/orchestration/agentExecutionService.d.ts.map +1 -1
  57. package/dist/orchestration/agentExecutionService.js +22 -0
  58. package/dist/orchestration/agentExecutionService.js.map +1 -1
  59. package/dist/orchestration/agentTaskService.d.ts +24 -10
  60. package/dist/orchestration/agentTaskService.d.ts.map +1 -1
  61. package/dist/orchestration/agentTaskService.js +76 -13
  62. package/dist/orchestration/agentTaskService.js.map +1 -1
  63. package/dist/orchestration/codingAgentService.d.ts.map +1 -1
  64. package/dist/orchestration/codingAgentService.js +2 -0
  65. package/dist/orchestration/codingAgentService.js.map +1 -1
  66. package/dist/orchestration/goalCompletionVerifier.d.ts +9 -0
  67. package/dist/orchestration/goalCompletionVerifier.d.ts.map +1 -1
  68. package/dist/orchestration/goalCompletionVerifier.js +131 -9
  69. package/dist/orchestration/goalCompletionVerifier.js.map +1 -1
  70. package/dist/orchestration/index.d.ts +16 -2
  71. package/dist/orchestration/index.d.ts.map +1 -1
  72. package/dist/orchestration/index.js +20 -4
  73. package/dist/orchestration/index.js.map +1 -1
  74. package/dist/orchestration/placeholderDetector.d.ts +74 -0
  75. package/dist/orchestration/placeholderDetector.d.ts.map +1 -0
  76. package/dist/orchestration/placeholderDetector.js +741 -0
  77. package/dist/orchestration/placeholderDetector.js.map +1 -0
  78. package/dist/orchestration/planner.d.ts.map +1 -1
  79. package/dist/orchestration/planner.js +94 -55
  80. package/dist/orchestration/planner.js.map +1 -1
  81. package/dist/orchestration/projectOrchestrator.d.ts +63 -0
  82. package/dist/orchestration/projectOrchestrator.d.ts.map +1 -0
  83. package/dist/orchestration/projectOrchestrator.js +555 -0
  84. package/dist/orchestration/projectOrchestrator.js.map +1 -0
  85. package/dist/orchestration/taskDecompositionService.d.ts +36 -0
  86. package/dist/orchestration/taskDecompositionService.d.ts.map +1 -0
  87. package/dist/orchestration/taskDecompositionService.js +367 -0
  88. package/dist/orchestration/taskDecompositionService.js.map +1 -0
  89. package/dist/orchestration/taskDecompositionTypes.d.ts +62 -0
  90. package/dist/orchestration/taskDecompositionTypes.d.ts.map +1 -0
  91. package/dist/orchestration/taskDecompositionTypes.js +5 -0
  92. package/dist/orchestration/taskDecompositionTypes.js.map +1 -0
  93. package/dist/preview/previewServer.d.ts.map +1 -1
  94. package/dist/preview/previewServer.js +28 -7
  95. package/dist/preview/previewServer.js.map +1 -1
  96. package/dist/preview/previewService.d.ts +32 -3
  97. package/dist/preview/previewService.d.ts.map +1 -1
  98. package/dist/preview/previewService.js +370 -108
  99. package/dist/preview/previewService.js.map +1 -1
  100. package/dist/preview/previewTypes.d.ts +1 -1
  101. package/dist/preview/previewTypes.d.ts.map +1 -1
  102. package/dist/storage/index.d.ts +2 -0
  103. package/dist/storage/index.d.ts.map +1 -1
  104. package/dist/storage/index.js +3 -0
  105. package/dist/storage/index.js.map +1 -1
  106. package/dist/storage/migration.d.ts.map +1 -1
  107. package/dist/storage/migration.js +79 -0
  108. package/dist/storage/migration.js.map +1 -1
  109. package/dist/storage/repositories/agentTaskRepository.d.ts +13 -0
  110. package/dist/storage/repositories/agentTaskRepository.d.ts.map +1 -1
  111. package/dist/storage/repositories/agentTaskRepository.js +87 -25
  112. package/dist/storage/repositories/agentTaskRepository.js.map +1 -1
  113. package/dist/storage/repositories/conversationRepository.d.ts +5 -1
  114. package/dist/storage/repositories/conversationRepository.d.ts.map +1 -1
  115. package/dist/storage/repositories/conversationRepository.js +69 -0
  116. package/dist/storage/repositories/conversationRepository.js.map +1 -1
  117. package/dist/storage/repositories/messageRepository.d.ts +3 -0
  118. package/dist/storage/repositories/messageRepository.d.ts.map +1 -1
  119. package/dist/storage/repositories/messageRepository.js +29 -0
  120. package/dist/storage/repositories/messageRepository.js.map +1 -1
  121. package/dist/storage/repositories/taskArtifactRepository.d.ts +19 -0
  122. package/dist/storage/repositories/taskArtifactRepository.d.ts.map +1 -0
  123. package/dist/storage/repositories/taskArtifactRepository.js +82 -0
  124. package/dist/storage/repositories/taskArtifactRepository.js.map +1 -0
  125. package/dist/storage/repositories/types.d.ts +32 -0
  126. package/dist/storage/repositories/types.d.ts.map +1 -1
  127. package/dist/tools/adapters/filesystemAdapter.d.ts.map +1 -1
  128. package/dist/tools/adapters/filesystemAdapter.js +8 -1
  129. package/dist/tools/adapters/filesystemAdapter.js.map +1 -1
  130. package/dist/ui/public/app.js +777 -777
  131. package/dist/ui/public/index.html +117 -117
  132. package/dist/ui/public/styles.css +413 -413
  133. package/package.json +64 -63
@@ -6,27 +6,56 @@
6
6
  * safe browser launching.
7
7
  */
8
8
  import type { AgentTaskRepository } from '../storage/repositories/agentTaskRepository.js';
9
+ import type { TaskArtifactRepository } from '../storage/repositories/taskArtifactRepository.js';
9
10
  import type { AgentTask } from '../storage/repositories/types.js';
10
11
  import type { PreviewResult, PreviewStatus, DiscoveredArtifacts } from './previewTypes.js';
11
12
  export declare class PreviewService {
12
13
  private taskRepo;
13
14
  private workspaceRoot;
15
+ private artifactRepo?;
14
16
  private server;
15
17
  private logger;
16
18
  private activeTask;
17
- constructor(taskRepo: AgentTaskRepository, workspaceRoot: string, logLevel?: import('../common/logger.js').LogLevel);
19
+ constructor(taskRepo: AgentTaskRepository, workspaceRoot: string, logLevel?: import('../common/logger.js').LogLevel, artifactRepo?: TaskArtifactRepository | undefined);
20
+ setArtifactRepository(artifactRepo: TaskArtifactRepository): void;
21
+ /**
22
+ * Resolve target task based on explicit index (number), task ID (string), or latest task (undefined).
23
+ *
24
+ * 1. No argument: targets the latest task across ALL states in the task repository.
25
+ * 2. Number / Numeric string (e.g. 2, "2", "#2"): targets the N-th task (1-indexed) from deterministic task history.
26
+ * 3. Task ID string: targets the specific task by its unique ID.
27
+ */
28
+ resolveTargetTask(target?: string | number): {
29
+ task: AgentTask | null;
30
+ targetType: 'latest' | 'index' | 'id';
31
+ targetIndex?: number;
32
+ error?: string;
33
+ };
18
34
  /**
19
35
  * Resolve latest completed task or specific task by ID.
36
+ * Maintained for backwards compatibility.
20
37
  */
21
38
  resolveCompletedTask(taskId?: string): AgentTask | null;
22
39
  /**
23
40
  * Discover files and project entry point created by a completed task.
41
+ *
42
+ * PRIORITY 1: Task's persisted artifact record in SQLite (TaskArtifactRepository).
43
+ * PRIORITY 2: Task-scoped plan steps (legacy tasks only).
44
+ * PRIORITY 3: FAIL SAFELY. Never fall back to arbitrary workspace scanning, mtime, or other tasks.
24
45
  */
25
46
  discoverArtifacts(task: AgentTask): DiscoveredArtifacts;
26
47
  /**
27
- * Launch preview for latest completed task or specified task ID.
48
+ * Launch preview for latest task, specific task number (#N), or specified task ID.
49
+ *
50
+ * STRICT SEMANTICS:
51
+ * 1. If no target is given, resolves the user's latest task.
52
+ * 2. If target is numeric (e.g. 2, "2", "#2"), resolves task #N from /tasks list.
53
+ * 3. If target is string, resolves task by ID.
54
+ * 4. If target task is FAILED -> returns status TASK_FAILED with reason, blocks preview, NO FALLBACK.
55
+ * 5. If target task is INCOMPLETE -> returns status TASK_INCOMPLETE, blocks preview, NO FALLBACK.
56
+ * 6. If target task is COMPLETED -> verifies artifact for THAT task, then launches preview.
28
57
  */
29
- preview(taskId?: string): Promise<PreviewResult>;
58
+ preview(target?: string | number): Promise<PreviewResult>;
30
59
  /**
31
60
  * Stop active preview server.
32
61
  */
@@ -1 +1 @@
1
- {"version":3,"file":"previewService.d.ts","sourceRoot":"","sources":["../../src/preview/previewService.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAKH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gDAAgD,CAAC;AAC1F,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAGlE,OAAO,KAAK,EACV,aAAa,EACb,aAAa,EACb,mBAAmB,EACpB,MAAM,mBAAmB,CAAC;AAG3B,qBAAa,cAAc;IAMvB,OAAO,CAAC,QAAQ;IAChB,OAAO,CAAC,aAAa;IANvB,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,UAAU,CAA0B;gBAGlC,QAAQ,EAAE,mBAAmB,EAC7B,aAAa,EAAE,MAAM,EAC7B,QAAQ,GAAE,OAAO,qBAAqB,EAAE,QAAiB;IAM3D;;OAEG;IACH,oBAAoB,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAyBvD;;OAEG;IACH,iBAAiB,CAAC,IAAI,EAAE,SAAS,GAAG,mBAAmB;IA6JvD;;OAEG;IACG,OAAO,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAgFtD;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,aAAa,CAAC;IAoBpC;;OAEG;IACH,SAAS,IAAI,aAAa;IAc1B;;OAEG;IACG,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAiCjD"}
1
+ {"version":3,"file":"previewService.d.ts","sourceRoot":"","sources":["../../src/preview/previewService.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAKH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gDAAgD,CAAC;AAC1F,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,mDAAmD,CAAC;AAChG,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAElE,OAAO,KAAK,EACV,aAAa,EACb,aAAa,EACb,mBAAmB,EACpB,MAAM,mBAAmB,CAAC;AAW3B,qBAAa,cAAc;IAMvB,OAAO,CAAC,QAAQ;IAChB,OAAO,CAAC,aAAa;IAErB,OAAO,CAAC,YAAY,CAAC;IARvB,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,UAAU,CAA0B;gBAGlC,QAAQ,EAAE,mBAAmB,EAC7B,aAAa,EAAE,MAAM,EAC7B,QAAQ,GAAE,OAAO,qBAAqB,EAAE,QAAiB,EACjD,YAAY,CAAC,EAAE,sBAAsB,YAAA;IAM/C,qBAAqB,CAAC,YAAY,EAAE,sBAAsB,GAAG,IAAI;IAIjE;;;;;;OAMG;IACH,iBAAiB,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG;QAC3C,IAAI,EAAE,SAAS,GAAG,IAAI,CAAC;QACvB,UAAU,EAAE,QAAQ,GAAG,OAAO,GAAG,IAAI,CAAC;QACtC,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB;IAwDD;;;OAGG;IACH,oBAAoB,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAQvD;;;;;;OAMG;IACH,iBAAiB,CAAC,IAAI,EAAE,SAAS,GAAG,mBAAmB;IAmTvD;;;;;;;;;;OAUG;IACG,OAAO,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAoI/D;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,aAAa,CAAC;IAoBpC;;OAEG;IACH,SAAS,IAAI,aAAa;IAc1B;;OAEG;IACG,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAqCjD"}
@@ -9,139 +9,344 @@ import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { execFile } from 'child_process';
11
11
  import { PreviewServer } from './previewServer.js';
12
+ import { verifyHtmlContentSubstance } from '../orchestration/placeholderDetector.js';
12
13
  import { Logger } from '../common/logger.js';
14
+ function isSubpathOrEqual(parentDir, childPath) {
15
+ const isWin = process.platform === 'win32';
16
+ const p = isWin ? parentDir.toLowerCase() : parentDir;
17
+ const c = isWin ? childPath.toLowerCase() : childPath;
18
+ return c === p || c.startsWith(p + (isWin ? '\\' : '/')) || c.startsWith(p + path.sep);
19
+ }
13
20
  export class PreviewService {
14
21
  taskRepo;
15
22
  workspaceRoot;
23
+ artifactRepo;
16
24
  server;
17
25
  logger;
18
26
  activeTask = null;
19
- constructor(taskRepo, workspaceRoot, logLevel = 'info') {
27
+ constructor(taskRepo, workspaceRoot, logLevel = 'info', artifactRepo) {
20
28
  this.taskRepo = taskRepo;
21
29
  this.workspaceRoot = workspaceRoot;
30
+ this.artifactRepo = artifactRepo;
22
31
  this.logger = new Logger('PreviewService', logLevel);
23
32
  this.server = new PreviewServer(logLevel);
24
33
  }
34
+ setArtifactRepository(artifactRepo) {
35
+ this.artifactRepo = artifactRepo;
36
+ }
25
37
  /**
26
- * Resolve latest completed task or specific task by ID.
38
+ * Resolve target task based on explicit index (number), task ID (string), or latest task (undefined).
39
+ *
40
+ * 1. No argument: targets the latest task across ALL states in the task repository.
41
+ * 2. Number / Numeric string (e.g. 2, "2", "#2"): targets the N-th task (1-indexed) from deterministic task history.
42
+ * 3. Task ID string: targets the specific task by its unique ID.
27
43
  */
28
- resolveCompletedTask(taskId) {
29
- if (taskId && taskId.trim()) {
30
- const cleanId = taskId.trim();
31
- const task = this.taskRepo.findById(cleanId);
32
- if (task && task.state === 'completed') {
33
- return task;
44
+ resolveTargetTask(target) {
45
+ const allTasks = this.taskRepo.listAll();
46
+ if (allTasks.length === 0) {
47
+ return {
48
+ task: null,
49
+ targetType: 'latest',
50
+ error: 'No tasks recorded yet.',
51
+ };
52
+ }
53
+ if (target === undefined || target === null || (typeof target === 'string' && target.trim() === '')) {
54
+ return {
55
+ task: allTasks[0],
56
+ targetType: 'latest',
57
+ targetIndex: 1,
58
+ };
59
+ }
60
+ const str = String(target).trim();
61
+ // Check if target is a numeric index (e.g. 1, 2, "1", "2", "#1", "#2")
62
+ if (/^#?\d+$/.test(str)) {
63
+ const idx = parseInt(str.replace(/^#/, ''), 10);
64
+ if (idx >= 1) {
65
+ if (idx > allTasks.length) {
66
+ return {
67
+ task: null,
68
+ targetType: 'index',
69
+ targetIndex: idx,
70
+ error: `Task #${idx} does not exist. Available tasks: 1 to ${allTasks.length}. Run /tasks to view all tasks.`,
71
+ };
72
+ }
73
+ return {
74
+ task: allTasks[idx - 1],
75
+ targetType: 'index',
76
+ targetIndex: idx,
77
+ };
34
78
  }
35
- return null;
36
79
  }
37
- const completed = this.taskRepo.findByState('completed');
38
- if (completed.length === 0) {
39
- return null;
80
+ // Otherwise, treat as explicit taskId
81
+ const task = this.taskRepo.findById(str);
82
+ if (!task) {
83
+ return {
84
+ task: null,
85
+ targetType: 'id',
86
+ error: `Task with ID '${str}' not found.`,
87
+ };
40
88
  }
41
- // Sort descending by completedAt or updatedAt or createdAt
42
- completed.sort((a, b) => {
43
- const tA = new Date(a.completedAt || a.updatedAt || a.createdAt).getTime();
44
- const tB = new Date(b.completedAt || b.updatedAt || b.createdAt).getTime();
45
- return tB - tA;
46
- });
47
- return completed[0];
89
+ return {
90
+ task,
91
+ targetType: 'id',
92
+ };
93
+ }
94
+ /**
95
+ * Resolve latest completed task or specific task by ID.
96
+ * Maintained for backwards compatibility.
97
+ */
98
+ resolveCompletedTask(taskId) {
99
+ const resolved = this.resolveTargetTask(taskId);
100
+ if (resolved.task && resolved.task.state === 'completed') {
101
+ return resolved.task;
102
+ }
103
+ return null;
48
104
  }
49
105
  /**
50
106
  * Discover files and project entry point created by a completed task.
107
+ *
108
+ * PRIORITY 1: Task's persisted artifact record in SQLite (TaskArtifactRepository).
109
+ * PRIORITY 2: Task-scoped plan steps (legacy tasks only).
110
+ * PRIORITY 3: FAIL SAFELY. Never fall back to arbitrary workspace scanning, mtime, or other tasks.
51
111
  */
52
112
  discoverArtifacts(task) {
53
113
  const canonicalWorkspace = fs.realpathSync(path.resolve(this.workspaceRoot));
54
- const createdFiles = [];
55
- // 1. Inspect task plan steps if present
56
- if (task.plan) {
57
- try {
58
- const planObj = typeof task.plan === 'string' ? JSON.parse(task.plan) : task.plan;
59
- if (Array.isArray(planObj.steps)) {
60
- for (const step of planObj.steps) {
61
- if ((step.toolId === 'filesystem_write' || step.toolId === 'filesystem_edit') &&
62
- step.params) {
63
- const rel = step.params.relativePath || step.params.path;
64
- if (rel && typeof rel === 'string') {
65
- createdFiles.push(rel);
114
+ // ── PRIORITY 1: Persisted Artifact Metadata from SQLite ────────────────────
115
+ if (this.artifactRepo) {
116
+ let persistedArtifact = this.artifactRepo.findByTaskId(task.id);
117
+ // If this is a parent task, check artifacts owned by its child tasks
118
+ if (!persistedArtifact) {
119
+ const childTasks = this.taskRepo.findByParentTaskId(task.id);
120
+ if (childTasks.length > 0) {
121
+ const childArtifacts = childTasks
122
+ .map((c) => this.artifactRepo.findByTaskId(c.id))
123
+ .filter((a) => !!a);
124
+ if (childArtifacts.length > 0) {
125
+ const mergedFiles = Array.from(new Set(childArtifacts.flatMap((a) => a.files)));
126
+ const primary = childArtifacts[childArtifacts.length - 1];
127
+ persistedArtifact = {
128
+ ...primary,
129
+ id: `art-${task.id}`,
130
+ taskId: task.id,
131
+ files: mergedFiles,
132
+ };
133
+ }
134
+ }
135
+ }
136
+ // If this is a child task, check parent artifact
137
+ if (!persistedArtifact && task.parentTaskId) {
138
+ persistedArtifact = this.artifactRepo.findByTaskId(task.parentTaskId);
139
+ }
140
+ if (persistedArtifact) {
141
+ let canonicalProjectDir = persistedArtifact.projectRoot;
142
+ try {
143
+ if (fs.existsSync(canonicalProjectDir)) {
144
+ canonicalProjectDir = fs.realpathSync(canonicalProjectDir);
145
+ }
146
+ }
147
+ catch {
148
+ // keep path
149
+ }
150
+ const entryPath = path.join(canonicalProjectDir, persistedArtifact.entryPoint || 'index.html');
151
+ const entryExists = fs.existsSync(entryPath) && fs.statSync(entryPath).isFile();
152
+ if (entryExists) {
153
+ // Scan project directory for associated web files
154
+ const allProjectFiles = [];
155
+ const scanDir = (currentDir, relPrefix) => {
156
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
157
+ for (const entry of entries) {
158
+ if (['node_modules', '.git', 'dist', 'build', '.nexus'].includes(entry.name))
159
+ continue;
160
+ const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
161
+ if (entry.isDirectory()) {
162
+ scanDir(path.join(currentDir, entry.name), relPath);
163
+ }
164
+ else if (entry.isFile()) {
165
+ allProjectFiles.push(relPath.replace(/\\/g, '/'));
66
166
  }
67
167
  }
168
+ };
169
+ try {
170
+ scanDir(canonicalProjectDir, '');
171
+ }
172
+ catch {
173
+ // ignore scan failure
68
174
  }
175
+ const uniqueFiles = Array.from(new Set([...persistedArtifact.files, ...allProjectFiles]));
176
+ this.logger.info(`Resolved preview artifact from SQLite metadata for task '${task.id}'`, {
177
+ projectDir: canonicalProjectDir,
178
+ entryFile: persistedArtifact.entryPoint,
179
+ fileCount: uniqueFiles.length,
180
+ });
181
+ return {
182
+ projectDir: canonicalProjectDir,
183
+ entryFile: persistedArtifact.entryPoint || 'index.html',
184
+ files: uniqueFiles,
185
+ hasIndexHtml: true,
186
+ isWebProject: true,
187
+ };
188
+ }
189
+ else {
190
+ this.logger.warn(`Persisted artifact entry point missing on disk for task '${task.id}'`, { entryPath });
191
+ return {
192
+ projectDir: canonicalProjectDir,
193
+ entryFile: persistedArtifact.entryPoint || 'index.html',
194
+ files: [],
195
+ hasIndexHtml: false,
196
+ isWebProject: false,
197
+ };
69
198
  }
70
199
  }
200
+ }
201
+ // ── PRIORITY 2: Task-Scoped Plan Steps (Legacy Plan Fallback) ──────────────
202
+ const createdFiles = [];
203
+ const createdDirs = [];
204
+ const extractFromObj = (obj) => {
205
+ if (!obj || typeof obj !== 'object')
206
+ return;
207
+ for (const [key, value] of Object.entries(obj)) {
208
+ if (typeof value === 'string' && value.trim()) {
209
+ const val = value.trim();
210
+ const lowerKey = key.toLowerCase();
211
+ if (lowerKey.includes('path') ||
212
+ lowerKey.includes('file') ||
213
+ lowerKey.includes('dir') ||
214
+ lowerKey.includes('dest') ||
215
+ lowerKey === 'target' ||
216
+ val.endsWith('.html') ||
217
+ val.endsWith('.css') ||
218
+ val.endsWith('.js')) {
219
+ if (val.endsWith('.html') || val.endsWith('.css') || val.endsWith('.js') || val.includes('/') || val.includes('\\')) {
220
+ createdFiles.push(val);
221
+ }
222
+ else {
223
+ createdDirs.push(val);
224
+ }
225
+ }
226
+ }
227
+ else if (typeof value === 'object' && value !== null) {
228
+ extractFromObj(value);
229
+ }
230
+ }
231
+ };
232
+ if (task.plan) {
233
+ try {
234
+ const planObj = typeof task.plan === 'string' ? JSON.parse(task.plan) : task.plan;
235
+ extractFromObj(planObj);
236
+ }
71
237
  catch (err) {
72
238
  this.logger.warn(`Failed to parse plan JSON for task '${task.id}'`, { error: String(err) });
73
239
  }
74
240
  }
75
- // 2. Determine base directory candidate from created files
76
- let projectDir = canonicalWorkspace;
77
- let entryFile = 'index.html';
78
- // Look for index.html among step file targets
79
- const indexRel = createdFiles.find((f) => path.basename(f).toLowerCase() === 'index.html');
80
- if (indexRel) {
81
- const absIndex = path.resolve(canonicalWorkspace, indexRel);
82
- projectDir = path.dirname(absIndex);
83
- entryFile = path.basename(absIndex);
241
+ // Extract folder name from task title if explicit or matches existing subdirectories
242
+ try {
243
+ const wsEntries = fs.readdirSync(canonicalWorkspace, { withFileTypes: true });
244
+ const dirNames = wsEntries
245
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.') && d.name !== 'node_modules')
246
+ .map((d) => d.name);
247
+ const folderMatch = task.title.match(/(?:folder|directory|project|called|named|create)\s+[`"']?([a-zA-Z0-9_\-]+)[`"']?/i);
248
+ if (folderMatch && folderMatch[1]) {
249
+ const candidate = folderMatch[1].trim();
250
+ if (dirNames.some((d) => d.toLowerCase() === candidate.toLowerCase())) {
251
+ createdDirs.push(candidate);
252
+ }
253
+ }
254
+ const titleWords = task.title.toLowerCase().replace(/[^a-z0-9_\-\s]/g, ' ').split(/\s+/).filter((w) => w.length > 2 && w !== 'task' && w !== 'the');
255
+ for (const d of dirNames) {
256
+ const lowerName = d.toLowerCase();
257
+ if (titleWords.some((w) => lowerName.includes(w) || (w.length > 3 && w.includes(lowerName)))) {
258
+ createdDirs.push(d);
259
+ }
260
+ }
261
+ // If task title mentions subdirectory/folder/app and exactly one subdirectory with index.html exists
262
+ if (createdDirs.length === 0 && /(?:subdirectory|folder|project|app|site|web)/i.test(task.title)) {
263
+ const subdirsWithIndex = dirNames.filter((d) => fs.existsSync(path.join(canonicalWorkspace, d, 'index.html')));
264
+ if (subdirsWithIndex.length === 1) {
265
+ createdDirs.push(subdirsWithIndex[0]);
266
+ }
267
+ }
84
268
  }
85
- else if (createdFiles.length > 0) {
86
- const firstAbs = path.resolve(canonicalWorkspace, createdFiles[0]);
87
- const dirCandidate = path.dirname(firstAbs);
88
- if (fs.existsSync(dirCandidate) && fs.statSync(dirCandidate).isDirectory()) {
89
- projectDir = dirCandidate;
269
+ catch {
270
+ /* ignore */
271
+ }
272
+ const validAbsFiles = [];
273
+ for (const f of createdFiles) {
274
+ const abs = path.resolve(canonicalWorkspace, f);
275
+ if (isSubpathOrEqual(canonicalWorkspace, abs) && fs.existsSync(abs) && fs.statSync(abs).isFile()) {
276
+ validAbsFiles.push(fs.realpathSync(abs));
90
277
  }
91
278
  }
92
- // Canonicalize projectDir
93
- let canonicalProjectDir = fs.existsSync(projectDir)
94
- ? fs.realpathSync(projectDir)
95
- : canonicalWorkspace;
96
- // Security check: projectDir must be inside canonicalWorkspace
97
- if (!canonicalProjectDir.startsWith(canonicalWorkspace)) {
98
- canonicalProjectDir = canonicalWorkspace;
279
+ const validAbsDirs = [];
280
+ for (const d of createdDirs) {
281
+ const abs = path.resolve(canonicalWorkspace, d);
282
+ if (isSubpathOrEqual(canonicalWorkspace, abs) && fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
283
+ validAbsDirs.push(fs.realpathSync(abs));
284
+ }
99
285
  }
100
- // Check if canonicalWorkspace root itself directly contains index.html and get mtime
101
- const rootIndexPath = path.join(canonicalWorkspace, 'index.html');
102
- const rootHasIndex = fs.existsSync(rootIndexPath) && fs.statSync(rootIndexPath).isFile();
103
- const rootIndexMtime = rootHasIndex ? fs.statSync(rootIndexPath).mtimeMs : 0;
104
- // If canonicalProjectDir is still canonicalWorkspace, check subdirectories for candidate web projects
105
- if (canonicalProjectDir === canonicalWorkspace) {
106
- const subdirsWithIndex = [];
107
- try {
108
- const entries = fs.readdirSync(canonicalWorkspace, { withFileTypes: true });
109
- for (const entry of entries) {
110
- if (entry.isDirectory() &&
111
- !entry.name.startsWith('.') &&
112
- !['node_modules', 'dist', 'build', 'coverage', 'src', 'data', 'bin', 'docs'].includes(entry.name)) {
113
- const subDir = path.join(canonicalWorkspace, entry.name);
114
- const indexPath = path.join(subDir, 'index.html');
115
- if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
116
- const stat = fs.statSync(indexPath);
117
- subdirsWithIndex.push({
118
- dir: subDir,
119
- mtime: stat.mtimeMs,
120
- });
121
- }
122
- }
286
+ // Root workspace fallback ONLY when task is a web task and no specific subdirectory was claimed and root index.html exists
287
+ if (validAbsFiles.length === 0 && validAbsDirs.length === 0) {
288
+ if (!/(?:python|data|analysis|backend|fibonacci|non-web)/i.test(task.title)) {
289
+ const rootIndex = path.join(canonicalWorkspace, 'index.html');
290
+ if (fs.existsSync(rootIndex) && fs.statSync(rootIndex).isFile()) {
291
+ validAbsFiles.push(fs.realpathSync(rootIndex));
123
292
  }
124
293
  }
125
- catch (err) {
126
- this.logger.warn('Failed to scan workspace subdirectories for web projects', { error: String(err) });
127
- }
128
- if (subdirsWithIndex.length > 0) {
129
- // If createdFiles point to a subdirectory, prefer that directory
130
- const resolvedCreated = createdFiles.map((f) => path.resolve(canonicalWorkspace, f));
131
- const matchedDir = subdirsWithIndex.find((s) => resolvedCreated.some((c) => c.startsWith(s.dir)));
132
- if (matchedDir) {
133
- canonicalProjectDir = fs.realpathSync(matchedDir.dir);
294
+ }
295
+ let targetProjectDir = null;
296
+ let entryFile = 'index.html';
297
+ const indexAbs = validAbsFiles.find((f) => path.basename(f).toLowerCase() === 'index.html');
298
+ if (indexAbs) {
299
+ targetProjectDir = path.dirname(indexAbs);
300
+ entryFile = path.basename(indexAbs);
301
+ }
302
+ if (!targetProjectDir && validAbsFiles.length > 0) {
303
+ const fileDirs = validAbsFiles.map((f) => path.dirname(f));
304
+ const candidateDirs = Array.from(new Set([...fileDirs, ...validAbsDirs]));
305
+ for (const dir of candidateDirs) {
306
+ const indexPath = path.join(dir, 'index.html');
307
+ if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
308
+ targetProjectDir = dir;
309
+ entryFile = 'index.html';
310
+ break;
134
311
  }
135
- else {
136
- // Sort by mtime descending (most recently modified project first)
137
- subdirsWithIndex.sort((a, b) => b.mtime - a.mtime);
138
- if (!rootHasIndex || subdirsWithIndex[0].mtime > rootIndexMtime) {
139
- canonicalProjectDir = fs.realpathSync(subdirsWithIndex[0].dir);
140
- }
312
+ }
313
+ if (!targetProjectDir) {
314
+ targetProjectDir = fileDirs[0];
315
+ }
316
+ }
317
+ if (!targetProjectDir && validAbsDirs.length > 0) {
318
+ for (const dir of validAbsDirs) {
319
+ const indexPath = path.join(dir, 'index.html');
320
+ if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
321
+ targetProjectDir = dir;
322
+ entryFile = 'index.html';
323
+ break;
141
324
  }
142
325
  }
326
+ if (!targetProjectDir) {
327
+ targetProjectDir = validAbsDirs[0];
328
+ }
329
+ }
330
+ // If NO task-specific artifact could be identified for THIS task, FAIL SAFELY.
331
+ // NEVER fall back to global workspace, root index.html, or arbitrary mtime directories.
332
+ if (!targetProjectDir) {
333
+ this.logger.info(`No task-specific previewable artifact found for task '${task.id}'`);
334
+ return {
335
+ projectDir: canonicalWorkspace,
336
+ entryFile: 'index.html',
337
+ files: [],
338
+ hasIndexHtml: false,
339
+ isWebProject: false,
340
+ };
341
+ }
342
+ // Canonicalize target project directory
343
+ let canonicalProjectDir = fs.existsSync(targetProjectDir)
344
+ ? fs.realpathSync(targetProjectDir)
345
+ : canonicalWorkspace;
346
+ if (!isSubpathOrEqual(canonicalWorkspace, canonicalProjectDir)) {
347
+ canonicalProjectDir = canonicalWorkspace;
143
348
  }
144
- // Scan selected projectDir for existing files
349
+ // Scan selected projectDir for files
145
350
  const allProjectFiles = [];
146
351
  let hasIndexHtml = false;
147
352
  let hasWebFiles = false;
@@ -175,8 +380,8 @@ export class PreviewService {
175
380
  this.logger.warn(`Error scanning directory '${canonicalProjectDir}'`, { error: String(err) });
176
381
  }
177
382
  }
178
- // Combine step created files and scanned files
179
- const uniqueFiles = Array.from(new Set([...createdFiles, ...allProjectFiles]));
383
+ const relativeCreatedFiles = validAbsFiles.map((abs) => path.relative(canonicalProjectDir, abs));
384
+ const uniqueFiles = Array.from(new Set([...relativeCreatedFiles, ...allProjectFiles]));
180
385
  return {
181
386
  projectDir: canonicalProjectDir,
182
387
  entryFile,
@@ -186,24 +391,59 @@ export class PreviewService {
186
391
  };
187
392
  }
188
393
  /**
189
- * Launch preview for latest completed task or specified task ID.
394
+ * Launch preview for latest task, specific task number (#N), or specified task ID.
395
+ *
396
+ * STRICT SEMANTICS:
397
+ * 1. If no target is given, resolves the user's latest task.
398
+ * 2. If target is numeric (e.g. 2, "2", "#2"), resolves task #N from /tasks list.
399
+ * 3. If target is string, resolves task by ID.
400
+ * 4. If target task is FAILED -> returns status TASK_FAILED with reason, blocks preview, NO FALLBACK.
401
+ * 5. If target task is INCOMPLETE -> returns status TASK_INCOMPLETE, blocks preview, NO FALLBACK.
402
+ * 6. If target task is COMPLETED -> verifies artifact for THAT task, then launches preview.
190
403
  */
191
- async preview(taskId) {
192
- const task = this.resolveCompletedTask(taskId);
193
- if (!task) {
194
- if (taskId) {
195
- return {
196
- success: false,
197
- status: 'NO_COMPLETED_TASK',
198
- message: `Task '${taskId}' is not a completed task or does not exist.`,
199
- };
200
- }
404
+ async preview(target) {
405
+ const resolved = this.resolveTargetTask(target);
406
+ if (!resolved.task) {
201
407
  return {
202
408
  success: false,
203
409
  status: 'NO_COMPLETED_TASK',
204
- message: 'No completed task is available to preview.',
410
+ message: resolved.error || 'No task is available to preview.',
205
411
  };
206
412
  }
413
+ const task = resolved.task;
414
+ const targetLabel = resolved.targetType === 'latest'
415
+ ? 'Latest task'
416
+ : resolved.targetType === 'index'
417
+ ? `Task #${resolved.targetIndex}`
418
+ : `Task '${task.id}'`;
419
+ // 1. Check if task failed -> NEVER FALL BACK TO AN OLDER TASK
420
+ if (task.state === 'failed') {
421
+ this.logger.warn(`Preview blocked: ${targetLabel} is in FAILED state`, {
422
+ taskId: task.id,
423
+ errorCategory: task.errorCategory,
424
+ });
425
+ return {
426
+ success: false,
427
+ status: 'TASK_FAILED',
428
+ task,
429
+ error: task.errorCategory || 'Execution Failed',
430
+ message: `Preview unavailable: ${targetLabel} '${task.title}' failed (${task.errorCategory || 'Execution Failed'}).`,
431
+ };
432
+ }
433
+ // 2. Check if task is incomplete -> NEVER FALL BACK
434
+ if (task.state !== 'completed') {
435
+ this.logger.warn(`Preview blocked: ${targetLabel} is not completed (state: ${task.state})`, {
436
+ taskId: task.id,
437
+ state: task.state,
438
+ });
439
+ return {
440
+ success: false,
441
+ status: 'TASK_INCOMPLETE',
442
+ task,
443
+ message: `Preview unavailable: ${targetLabel} '${task.title}' is currently ${task.state.toUpperCase()}. Only completed tasks can be previewed.`,
444
+ };
445
+ }
446
+ // 3. Task is COMPLETED: Discover artifacts for THIS task only
207
447
  const artifacts = this.discoverArtifacts(task);
208
448
  if (!artifacts.isWebProject) {
209
449
  return {
@@ -211,7 +451,7 @@ export class PreviewService {
211
451
  status: 'NOT_WEB_PROJECT',
212
452
  task,
213
453
  artifactsSummary: artifacts.files,
214
- message: `Latest task '${task.title}' does not contain a previewable web project.`,
454
+ message: `${targetLabel} '${task.title}' does not contain a previewable web project.`,
215
455
  };
216
456
  }
217
457
  if (!artifacts.hasIndexHtml) {
@@ -220,12 +460,12 @@ export class PreviewService {
220
460
  status: 'MISSING_INDEX',
221
461
  task,
222
462
  detectedFiles: artifacts.files,
223
- message: 'No index.html entry point was found.',
463
+ message: `No index.html entry point was found for task '${task.title}'.`,
224
464
  };
225
465
  }
226
466
  // Ensure projectDir is inside workspace
227
467
  const canonicalWorkspace = fs.realpathSync(path.resolve(this.workspaceRoot));
228
- if (!artifacts.projectDir.startsWith(canonicalWorkspace)) {
468
+ if (!isSubpathOrEqual(canonicalWorkspace, artifacts.projectDir)) {
229
469
  return {
230
470
  success: false,
231
471
  status: 'ERROR',
@@ -233,6 +473,25 @@ export class PreviewService {
233
473
  error: 'Security Error: Preview directory is outside workspace boundaries.',
234
474
  };
235
475
  }
476
+ // Verify artifact is a real implementation and passes content integrity checks
477
+ const entryFullPath = path.join(artifacts.projectDir, artifacts.entryFile || 'index.html');
478
+ if (fs.existsSync(entryFullPath)) {
479
+ const content = fs.readFileSync(entryFullPath, 'utf8');
480
+ const contentVerif = verifyHtmlContentSubstance(content, task.title);
481
+ if (!contentVerif.valid) {
482
+ this.logger.warn(`Preview blocked: artifact failed content verification for task '${task.id}'`, {
483
+ category: contentVerif.errorCategory,
484
+ reason: contentVerif.reason,
485
+ });
486
+ return {
487
+ success: false,
488
+ status: 'PLACEHOLDER_ARTIFACT_BLOCKED',
489
+ task,
490
+ detectedFiles: artifacts.files,
491
+ message: `Preview blocked: generated artifact failed integrity/content verification. ${contentVerif.reason || ''}`.trim(),
492
+ };
493
+ }
494
+ }
236
495
  try {
237
496
  const serverInfo = await this.server.start(artifacts.projectDir, artifacts.entryFile || 'index.html');
238
497
  this.activeTask = task;
@@ -296,6 +555,9 @@ export class PreviewService {
296
555
  * Open local preview URL safely in default browser without shell injection risk.
297
556
  */
298
557
  async openBrowser(url) {
558
+ if (process.env.NODE_ENV === 'test' || process.env.VITEST) {
559
+ return true;
560
+ }
299
561
  if (!url.startsWith('http://127.0.0.1:')) {
300
562
  this.logger.warn(`Refusing to open non-loopback URL: '${url}'`);
301
563
  return false;