@foggy-projects/deepseek-harness-plugin 0.4.0-beta.6 → 0.4.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ only when the user selects **Initialize Foggy**.
8
8
  ## Local beta installation
9
9
 
10
10
  ```powershell
11
- dsh plugin --profile web add --workspace-root ./foggy-projects-deepseek-harness-plugin-0.4.0-beta.6.tgz
11
+ dsh plugin --profile web add --workspace-root ./foggy-projects-deepseek-harness-plugin-0.4.0-beta.7.tgz
12
12
  ```
13
13
 
14
14
  Restart `dsh web`, open Settings → Plugins → Foggy Data Analysis, and initialize
@@ -38,7 +38,11 @@ evidence.
38
38
  Opaque CLI profiles default to the private persistent
39
39
  `<dataRoot>/cli-profiles` directory. Composite onboarding commands are
40
40
  idempotent: unchanged completed phases are resumed rather than re-adding a
41
- datasource or re-registering a published bundle.
41
+ datasource or re-registering a published bundle. Settings detects legacy
42
+ temporary profiles and offers an explicit, validated migration into the
43
+ persistent store. A completed profile can also be bound non-destructively to an
44
+ additional DSH workspace when its reviewed connection contract and published
45
+ semantic digest are unchanged.
42
46
 
43
47
  CLI, Launcher, the analysis Skill, install state, and Runtime state live in the
44
48
  user-level Foggy component directories. The managed CLI is intentionally isolated
@@ -47,6 +51,19 @@ analysis Skill, backs up modified or outdated managed content, restores it, and
47
51
  invalidates DSH's Skill catalog. The onboarding Skill is bundled with the plugin
48
52
  and is restored by reinstalling or upgrading the plugin package.
49
53
 
54
+ The Foggy settings tab shows the persisted database/semantic onboarding stages,
55
+ offers pinned checks and repair for CLI, Launcher, and the managed analysis
56
+ Skill, and exports a private redacted diagnostics report. Runtime start is
57
+ idempotent: an already-recorded process is verified with `wait-ready` and
58
+ `capabilities` instead of being treated as a failed second start.
59
+
60
+ This beta remains a local dev/test integration. The bundled Runtime reports
61
+ `securityMode=none-dev-test-only` and must not be exposed to a network or used as
62
+ a production service.
63
+
64
+ See [`docs/PUBLIC-BETA-READINESS.md`](./docs/PUBLIC-BETA-READINESS.md) for the
65
+ tested public Beta scope, release gates, and stable-release blockers.
66
+
50
67
  ## Linux and WSL2 experience
51
68
 
52
69
  Ubuntu and WSL2 users can use the checked-in preflighted installer under
package/lib/client.js CHANGED
@@ -10,7 +10,10 @@ window.__ModuleLoader__.load({
10
10
  const passthroughSchema = { parse: (value) => value }
11
11
  const TYPERT_REMOTE = {
12
12
  package: '@foggy-projects/deepseek-harness-plugin',
13
- descriptors: ['status', 'plan', 'initialize', 'repair', 'runtimeStart', 'runtimeStop'].map((method) => ({
13
+ descriptors: [
14
+ 'status', 'plan', 'initialize', 'repair', 'repairCli', 'repairLauncher', 'repairAnalysisSkill',
15
+ 'migrateProfiles', 'diagnostics', 'runtimeStart', 'runtimeStop',
16
+ ].map((method) => ({
14
17
  id: `@foggy-projects/deepseek-harness-plugin#foggyIntegration/${method}`,
15
18
  service: 'foggyIntegration',
16
19
  namespace: 'foggyIntegration',
@@ -51,6 +54,12 @@ window.__ModuleLoader__.load({
51
54
  unavailable: '不可用',
52
55
  initialize: '初始化 Foggy',
53
56
  repair: '重新下载 / 修复',
57
+ repairTitle: '分组件检查与修复',
58
+ repairCli: '检查 / 修复 CLI',
59
+ repairLauncher: '检查 / 修复 Launcher',
60
+ repairAnalysisSkill: '检查 / 修复分析 Skill',
61
+ diagnostics: '导出诊断报告',
62
+ diagnosticsSaved: '诊断报告已保存',
54
63
  start: '启动 Runtime',
55
64
  stop: '停止 Runtime',
56
65
  working: '操作进行中…',
@@ -69,6 +78,22 @@ window.__ModuleLoader__.load({
69
78
  installRoot: '组件目录',
70
79
  dataRoot: '数据目录',
71
80
  profileStore: 'CLI Profile',
81
+ profileMigrationTitle: '旧 Profile 迁移',
82
+ profileMigrationPending: '检测到旧版临时 Profile。迁移只转移连接元数据和密码环境变量引用,不复制密码值。',
83
+ profileMigrationConflict: '旧 Profile 存在冲突或格式问题,请先导出诊断报告。',
84
+ migrateProfiles: '迁移到持久目录',
85
+ onboardingProgress: '数据库与语义层进度',
86
+ noOnboarding: '尚无数据库引导记录;请在对话中调用 Foggy onboarding Skill。',
87
+ projectRoot: '工作区',
88
+ resumeInChat: '未完成步骤应回到对应工作区,在对话中继续。',
89
+ stepPlanned: '规划',
90
+ stepDatasourceConfigured: '数据库连接',
91
+ stepDatasourceVerified: '连接验证',
92
+ stepSchemaDiscovered: 'Schema 发现',
93
+ stepSemanticDrafted: '语义层草拟',
94
+ stepSemanticValidated: '模型校验',
95
+ stepSemanticPublished: '发布',
96
+ stepSemanticVerified: '首次查询',
72
97
  runtimeUrl: 'Runtime 地址',
73
98
  nextTitle: '后续配置',
74
99
  nextCopy: 'Skills 已通过 DeepSeek Harness 原生注册表提供给所有工作区;每个会话直接使用自己的工作目录。Runtime 启动成功后,将继续进入数据库连接与语义层向导。',
@@ -100,6 +125,12 @@ window.__ModuleLoader__.load({
100
125
  unavailable: 'Unavailable',
101
126
  initialize: 'Initialize Foggy',
102
127
  repair: 'Re-download / Repair',
128
+ repairTitle: 'Component checks and repair',
129
+ repairCli: 'Check / repair CLI',
130
+ repairLauncher: 'Check / repair Launcher',
131
+ repairAnalysisSkill: 'Check / repair analysis Skill',
132
+ diagnostics: 'Export diagnostics',
133
+ diagnosticsSaved: 'Diagnostics saved',
103
134
  start: 'Start Runtime',
104
135
  stop: 'Stop Runtime',
105
136
  working: 'Operation in progress…',
@@ -118,6 +149,22 @@ window.__ModuleLoader__.load({
118
149
  installRoot: 'Components',
119
150
  dataRoot: 'Data',
120
151
  profileStore: 'CLI profiles',
152
+ profileMigrationTitle: 'Legacy profile migration',
153
+ profileMigrationPending: 'Legacy temporary profiles were found. Migration transfers connection metadata and password environment-variable references, never password values.',
154
+ profileMigrationConflict: 'A legacy profile has a conflict or invalid format. Export diagnostics before continuing.',
155
+ migrateProfiles: 'Move to persistent store',
156
+ onboardingProgress: 'Database and semantic-layer progress',
157
+ noOnboarding: 'No database onboarding record yet. Invoke the Foggy onboarding Skill in a conversation.',
158
+ projectRoot: 'Workspace',
159
+ resumeInChat: 'Resume incomplete steps from a conversation in the matching workspace.',
160
+ stepPlanned: 'Plan',
161
+ stepDatasourceConfigured: 'Database connection',
162
+ stepDatasourceVerified: 'Connection test',
163
+ stepSchemaDiscovered: 'Schema discovery',
164
+ stepSemanticDrafted: 'Semantic draft',
165
+ stepSemanticValidated: 'Model validation',
166
+ stepSemanticPublished: 'Publish',
167
+ stepSemanticVerified: 'First query',
121
168
  runtimeUrl: 'Runtime URL',
122
169
  nextTitle: 'Next configuration',
123
170
  nextCopy: 'Skills are provided to every workspace through the native DeepSeek Harness registry, and each session uses its own working directory. After Runtime starts, the database connection and semantic-layer wizard comes next.',
@@ -133,7 +180,7 @@ window.__ModuleLoader__.load({
133
180
  .foggy-actions{display:flex;gap:8px;flex-wrap:wrap}.foggy-button{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;border-radius:8px;padding:7px 12px;cursor:pointer}.foggy-button:hover{background:var(--dsw-alias-interactive-bg-hover)}.foggy-button:disabled{opacity:.55;cursor:not-allowed}.foggy-button-primary{border-color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-state-business-primary);color:white}
134
181
  .foggy-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.foggy-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:10px;padding:13px 14px}.foggy-card-head{display:flex;justify-content:space-between;gap:8px}.foggy-card strong{font-size:13px}.foggy-badge{font-size:11px;color:var(--dsw-alias-label-secondary)}.foggy-card code{display:block;margin-top:8px;color:var(--dsw-alias-label-tertiary);font-size:11px;overflow-wrap:anywhere}
135
182
  .foggy-progress{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:10px;padding:13px 14px}.foggy-progress-head{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:13px}.foggy-progress-head strong{font-weight:600}.foggy-progress-percent{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}.foggy-progress-track{height:8px;margin-top:10px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}.foggy-progress-fill{height:100%;border-radius:inherit;background:var(--dsw-alias-state-business-primary);transition:width .25s ease}.foggy-progress-meta{display:flex;justify-content:space-between;gap:12px;margin-top:8px;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}.foggy-progress-file{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
136
- .foggy-message{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}.foggy-message[data-error=true]{color:var(--dsw-alias-state-error-primary)}.foggy-paths,.foggy-next{border-top:1px solid var(--dsw-alias-border-l2);padding-top:14px}.foggy-paths h4,.foggy-next h4{margin:0 0 8px;font-size:13px}.foggy-paths dl{display:grid;grid-template-columns:100px minmax(0,1fr);gap:6px 10px;margin:0}.foggy-paths dt{color:var(--dsw-alias-label-tertiary);font-size:12px}.foggy-paths dd{margin:0;min-width:0;overflow-wrap:anywhere;font-family:var(--ds-font-family-code);font-size:11px}.foggy-next p{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
183
+ .foggy-message{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}.foggy-message[data-error=true]{color:var(--dsw-alias-state-error-primary)}.foggy-paths,.foggy-next,.foggy-section{border-top:1px solid var(--dsw-alias-border-l2);padding-top:14px}.foggy-paths h4,.foggy-next h4,.foggy-section h4{margin:0 0 8px;font-size:13px}.foggy-paths dl{display:grid;grid-template-columns:100px minmax(0,1fr);gap:6px 10px;margin:0}.foggy-paths dt{color:var(--dsw-alias-label-tertiary);font-size:12px}.foggy-paths dd{margin:0;min-width:0;overflow-wrap:anywhere;font-family:var(--ds-font-family-code);font-size:11px}.foggy-next p,.foggy-section p{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}.foggy-section-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}.foggy-onboarding-head{display:flex;justify-content:space-between;gap:12px;align-items:start}.foggy-onboarding-head code{font-size:11px;color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere}.foggy-step-list{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px;margin-top:10px}.foggy-step{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px;font-size:11px;color:var(--dsw-alias-label-tertiary)}.foggy-step[data-state=completed]{border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 45%,var(--dsw-alias-border-l2));color:var(--dsw-alias-state-success-primary)}.foggy-step[data-state=failed],.foggy-step[data-state=invalid]{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}.foggy-warning{border:1px solid color-mix(in srgb,var(--dsw-alias-state-warning-primary) 55%,var(--dsw-alias-border-l2));background:color-mix(in srgb,var(--dsw-alias-state-warning-primary) 8%,transparent);border-radius:10px;padding:12px}
137
184
  @media (width<=680px){.foggy-grid{grid-template-columns:minmax(0,1fr)}}
138
185
  `
139
186
 
@@ -215,6 +262,41 @@ window.__ModuleLoader__.load({
215
262
  })
216
263
  }
217
264
 
265
+ function onboardingPanel(onboarding, t) {
266
+ const profile = onboarding?.profiles?.[0]
267
+ if (!profile) return jsxs('div', { className: 'foggy-section', children: [
268
+ jsx('h4', { children: t('onboardingProgress') }),
269
+ jsx('p', { children: t('noOnboarding') }),
270
+ ] })
271
+ const steps = [
272
+ ['planned', 'stepPlanned'],
273
+ ['datasourceConfigured', 'stepDatasourceConfigured'],
274
+ ['datasourceVerified', 'stepDatasourceVerified'],
275
+ ['schemaDiscovered', 'stepSchemaDiscovered'],
276
+ ['semanticDrafted', 'stepSemanticDrafted'],
277
+ ['semanticValidated', 'stepSemanticValidated'],
278
+ ['semanticPublished', 'stepSemanticPublished'],
279
+ ['semanticVerified', 'stepSemanticVerified'],
280
+ ]
281
+ const percent = Math.round(((profile.completedSteps || 0) / Math.max(profile.totalSteps || 8, 1)) * 100)
282
+ return jsxs('div', { className: 'foggy-section', children: [
283
+ jsxs('div', { className: 'foggy-onboarding-head', children: [
284
+ jsxs('div', { children: [
285
+ jsx('h4', { children: `${t('onboardingProgress')} · ${profile.profile}` }),
286
+ jsx('code', { children: `${t('projectRoot')}: ${profile.projectRoot || '—'}` }),
287
+ ] }),
288
+ jsx('span', { className: 'foggy-progress-percent', children: `${percent}%` }),
289
+ ] }),
290
+ jsx('div', { className: 'foggy-step-list', children: steps.map(([name, label]) => jsx('div', {
291
+ className: 'foggy-step',
292
+ 'data-state': profile.steps?.[name]?.status || 'pending',
293
+ title: profile.steps?.[name]?.status || 'pending',
294
+ children: t(label),
295
+ }, name)) }),
296
+ profile.next?.status !== 'completed' ? jsx('p', { style: { marginTop: '9px' }, children: t('resumeInChat') }) : null,
297
+ ] })
298
+ }
299
+
218
300
  function FoggySettingsTab({ api, t }) {
219
301
  const [view, setView] = useState({ phase: 'loading', status: null, message: '', error: false })
220
302
 
@@ -239,7 +321,12 @@ window.__ModuleLoader__.load({
239
321
  setView((current) => ({ ...current, message: t('working'), error: false }))
240
322
  try {
241
323
  const result = unwrap(await api[method](), method)
242
- setView((current) => ({ ...current, message: result.accepted ? t('accepted') : '', error: result.success === false }))
324
+ const message = result.accepted
325
+ ? t('accepted')
326
+ : result.path
327
+ ? `${t('diagnosticsSaved')}: ${result.path}`
328
+ : ''
329
+ setView((current) => ({ ...current, message, error: result.success === false }))
243
330
  await refresh()
244
331
  } catch (error) {
245
332
  setView((current) => ({ ...current, message: `${t('actionFailed')}: ${String(error.message || error)}`, error: true }))
@@ -268,6 +355,7 @@ window.__ModuleLoader__.load({
268
355
  jsxs('div', { className: 'foggy-state', children: [jsx('span', { className: 'foggy-dot', 'data-state': status.state }), jsx('span', { children: t(stateLabels[status.state] || 'notInstalled') })] }),
269
356
  jsxs('div', { className: 'foggy-actions', children: [
270
357
  jsx('button', { className: 'foggy-button', type: 'button', disabled: busy, onClick: refresh, children: t('refresh') }),
358
+ jsx('button', { className: 'foggy-button', type: 'button', disabled: busy, onClick: () => run('diagnostics'), children: t('diagnostics') }),
271
359
  status.state === 'not-installed' ? jsx('button', { className: 'foggy-button foggy-button-primary', type: 'button', disabled: busy, onClick: () => run('initialize'), children: t('initialize') }) : null,
272
360
  status.state !== 'not-installed' ? jsx('button', { className: 'foggy-button', type: 'button', disabled: busy, onClick: () => run('repair'), children: t('repair') }) : null,
273
361
  status.installed && !status.running && status.components.java.available ? jsx('button', { className: 'foggy-button foggy-button-primary', type: 'button', disabled: busy, onClick: () => run('runtimeStart'), children: t('start') }) : null,
@@ -285,6 +373,23 @@ window.__ModuleLoader__.load({
285
373
  componentCard(t('onboardingSkill'), status.components.onboardingSkill, 'runtime', t),
286
374
  componentCard(t('skillRegistry'), { installed: status.components.onboardingSkill?.provider === 'foggy-managed-skills', version: 'native' }, 'runtime', t),
287
375
  ] }),
376
+ jsxs('div', { className: 'foggy-section', children: [
377
+ jsx('h4', { children: t('repairTitle') }),
378
+ jsx('div', { className: 'foggy-section-actions', children: [
379
+ jsx('button', { className: 'foggy-button', type: 'button', disabled: busy, onClick: () => run('repairCli'), children: t('repairCli') }),
380
+ jsx('button', { className: 'foggy-button', type: 'button', disabled: busy, onClick: () => run('repairLauncher'), children: t('repairLauncher') }),
381
+ jsx('button', { className: 'foggy-button', type: 'button', disabled: busy, onClick: () => run('repairAnalysisSkill'), children: t('repairAnalysisSkill') }),
382
+ ] }),
383
+ ] }),
384
+ status.profileMigration?.pendingCount || status.profileMigration?.conflictCount ? jsxs('div', { className: 'foggy-warning', children: [
385
+ jsx('h4', { children: t('profileMigrationTitle') }),
386
+ jsx('p', { children: status.profileMigration.conflictCount ? t('profileMigrationConflict') : t('profileMigrationPending') }),
387
+ !status.profileMigration.conflictCount ? jsx('div', { className: 'foggy-section-actions', children: jsx('button', {
388
+ className: 'foggy-button foggy-button-primary', type: 'button', disabled: busy,
389
+ onClick: () => run('migrateProfiles'), children: t('migrateProfiles'),
390
+ }) }) : null,
391
+ ] }) : null,
392
+ onboardingPanel(status.onboarding, t),
288
393
  jsxs('div', { className: 'foggy-paths', children: [
289
394
  jsx('h4', { children: t('roots') }),
290
395
  jsxs('dl', { children: [
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFile } from 'node:child_process'
2
- import { access, readFile } from 'node:fs/promises'
2
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { constants as fsConstants } from 'node:fs'
4
4
  import { dirname, join, delimiter } from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
@@ -157,6 +157,16 @@ export class FoggyIntegrationGateway extends TypertRemoteService {
157
157
  let runtime = null
158
158
  try { state = await readJson(statePath) } catch {}
159
159
  try { runtime = await readJson(runtimeStatePath) } catch {}
160
+ let onboarding = { success: true, profiles: [], profileCount: 0 }
161
+ let profileMigration = { success: true, entries: [], pendingCount: 0, conflictCount: 0, profileStore: roots.profileStore }
162
+ if (state) {
163
+ try { onboarding = await runOnboarding(['onboard-list']) } catch (error) {
164
+ onboarding = { success: false, profiles: [], profileCount: 0, error: String(error.message ?? error) }
165
+ }
166
+ try { profileMigration = await runOnboarding(['profile-migration-status']) } catch (error) {
167
+ profileMigration = { success: false, entries: [], pendingCount: 0, conflictCount: 0, profileStore: roots.profileStore, error: String(error.message ?? error) }
168
+ }
169
+ }
160
170
  const cliPath = state?.cli?.command
161
171
  const launcherPath = state?.launcher?.path
162
172
  const analysisSkillPath = state?.skills?.analysis?.path || join(roots.installRoot, 'skills', 'foggy-ai-analysis')
@@ -218,6 +228,8 @@ export class FoggyIntegrationGateway extends TypertRemoteService {
218
228
  roots,
219
229
  components,
220
230
  operation,
231
+ onboarding,
232
+ profileMigration,
221
233
  next: installed ? (running ? 'configure-database' : 'start-runtime') : 'initialize',
222
234
  }
223
235
  }
@@ -249,6 +261,49 @@ export class FoggyIntegrationGateway extends TypertRemoteService {
249
261
  return this.startOperation('repair', true)
250
262
  }
251
263
 
264
+ async repairCli() {
265
+ return this.startOperation('repair-cli', false, ['install', '--repair-component', 'cli'])
266
+ }
267
+
268
+ async repairLauncher() {
269
+ return this.startOperation('repair-launcher', false, ['install', '--repair-component', 'launcher'])
270
+ }
271
+
272
+ async repairAnalysisSkill() {
273
+ return this.startOperation('repair-analysis-skill', true, ['install', '--repair-component', 'analysis-skill'])
274
+ }
275
+
276
+ async migrateProfiles() {
277
+ return this.startOperation('profile-migration', false, ['profile-migrate', '--approve'])
278
+ }
279
+
280
+ async diagnostics() {
281
+ const roots = defaultRoots()
282
+ const [status, doctor] = await Promise.all([
283
+ this.status(),
284
+ runOnboarding(['doctor', '--no-fail']),
285
+ ])
286
+ const report = {
287
+ schemaVersion: 'foggy-deepseek-diagnostics/v1',
288
+ generatedAt: new Date().toISOString(),
289
+ packageVersion: status.packageVersion,
290
+ state: status.state,
291
+ installed: status.installed,
292
+ running: status.running,
293
+ runtimeUrl: status.runtimeUrl,
294
+ roots: status.roots,
295
+ components: status.components,
296
+ onboarding: status.onboarding,
297
+ profileMigration: status.profileMigration,
298
+ doctor,
299
+ }
300
+ const directory = join(roots.dataRoot, 'diagnostics')
301
+ await mkdir(directory, { recursive: true })
302
+ const path = join(directory, `diagnostics-${Date.now()}.json`)
303
+ await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
304
+ return { success: true, path, report }
305
+ }
306
+
252
307
  async runtimeStart() {
253
308
  return this.startOperation('runtime-start', false, ['runtime-start'])
254
309
  }
@@ -263,7 +318,8 @@ export class FoggyIntegrationGateway extends TypertRemoteService {
263
318
  }
264
319
  const id = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
265
320
  const args = explicitArgs ?? ['install']
266
- if (!explicitArgs) {
321
+ const reportsProgress = args[0] === 'install'
322
+ if (reportsProgress) {
267
323
  const roots = defaultRoots()
268
324
  args.push('--progress-file', join(roots.dataRoot, 'operation-progress.json'), '--operation-id', id, '--operation-kind', kind)
269
325
  }
@@ -279,7 +335,7 @@ export class FoggyIntegrationGateway extends TypertRemoteService {
279
335
  finishedAt: null,
280
336
  result: null,
281
337
  error: null,
282
- progress: !explicitArgs ? {
338
+ progress: reportsProgress ? {
283
339
  schemaVersion: 'foggy-deepseek-onboarding-progress/v1',
284
340
  operationId: id,
285
341
  kind,
@@ -327,7 +383,10 @@ export class FoggyIntegrationGateway extends TypertRemoteService {
327
383
  }
328
384
 
329
385
  const markerInitializers = []
330
- for (const method of ['status', 'plan', 'initialize', 'repair', 'runtimeStart', 'runtimeStop']) {
386
+ for (const method of [
387
+ 'status', 'plan', 'initialize', 'repair', 'repairCli', 'repairLauncher', 'repairAnalysisSkill',
388
+ 'migrateProfiles', 'diagnostics', 'runtimeStart', 'runtimeStop',
389
+ ]) {
331
390
  Remote(method)(FoggyIntegrationGateway.prototype[method], {
332
391
  kind: 'method',
333
392
  name: method,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foggy-projects/deepseek-harness-plugin",
3
- "version": "0.4.0-beta.6",
3
+ "version": "0.4.0-beta.7",
4
4
  "description": "Foggy Java data analysis engine integration for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -22,7 +22,7 @@ copy this Skill into the current workspace.
22
22
  its absolute command in the global install state.
23
23
  - Do not independently download or reinstall the CLI. If the global install state, managed marker, or
24
24
  analysis Skill is missing or invalid, ask the user to open the Foggy plugin settings and use
25
- **Re-download / Repair**. Repair restores the global managed analysis Skill and invalidates the
25
+ the matching component repair action. Repair restores managed components and invalidates the
26
26
  native DSH Skill catalog. Reinstall or upgrade the plugin itself to restore this bundled Skill.
27
27
  - Treat the current DSH session workspace as the authoritative `projectRoot` for the whole onboarding
28
28
  run. Do not redirect semantic drafts or contracts to a different repository merely because another
@@ -75,7 +75,9 @@ For every new-database onboarding session, this Skill is the orchestration autho
75
75
  plugin's Repair action. Use the matching install script only when the plugin UI is unavailable;
76
76
  use `--dry-run` first when paths or permissions are uncertain.
77
77
  3. Run `runtime-start` and require successful `wait-ready` plus `capabilities`. Record engine,
78
- Runtime API version, schema version, security mode, URL, namespace, PID, and evidence path.
78
+ Runtime API version, schema version, security mode, URL, namespace, PID, and evidence path. If the
79
+ recorded Runtime is already running, `runtime-start` verifies and reuses it instead of starting a
80
+ second process.
79
81
  4. Load `foggy-ai-analysis` from the native DSH Skill registry. Do not require a workspace copy.
80
82
  5. For a new business database, read [references/onboarding-workflow.md](references/onboarding-workflow.md)
81
83
  and prefer its two composite `onboard-datasource-run` / `onboard-semantic-run` commands. Require the
@@ -84,6 +86,9 @@ For every new-database onboarding session, this Skill is the orchestration autho
84
86
  (`<dataRoot>/cli-profiles`), never `/tmp`. Accept only the opaque profile
85
87
  ID, exact revision, datasource name/type, and namespace; never request JDBC URL, username,
86
88
  password, or password environment-variable name in Harness.
89
+ If plugin settings report a legacy temporary profile, use the explicit migration action before
90
+ onboarding. It moves only validated connection metadata and environment-variable references; it
91
+ never copies a password value and leaves a recoverable private backup below the Foggy data root.
87
92
  6. After schema discovery, use `foggy-ai-analysis` only to author TM/QM files in the standard project-local
88
93
  draft directory. Register, validate, publish, and verify them through this Skill's wrapper using the deterministic commands in
89
94
  [references/onboarding-workflow.md](references/onboarding-workflow.md). Do not publish, prune, replace
@@ -114,5 +119,13 @@ publication, and verification phases when the approved contract and draft digest
114
119
  query payload in place and rerun the same semantic composite command; do not remove a successfully
115
120
  published bundle merely to recover from a later query-validation failure.
116
121
 
122
+ An already-completed profile may be reused from another DSH workspace when the approved connection
123
+ contract is byte-for-byte equivalent. Run the datasource composite in the new workspace first; it adds
124
+ that workspace as a non-destructive binding and reuses datasource/schema checkpoints. Then pass the
125
+ current workspace explicitly as `--project-root` to the semantic composite. A secondary workspace may
126
+ reuse an identical published semantic digest and run its own bounded verification query, but it cannot
127
+ replace the published semantic layer; changes must be published from the original workspace or a new
128
+ profile.
129
+
117
130
  Keep user business data separate from the sales-drop SQLite demo. Prefer a read-only database account,
118
131
  opaque CLI profile references, and bounded query limits.
@@ -13,6 +13,26 @@
13
13
  "installRoot": {"type": "string"},
14
14
  "dataRoot": {"type": "string"},
15
15
  "projectRoot": {"type": "string"},
16
+ "workspaceBindings": {
17
+ "type": "array",
18
+ "items": {"type": "string"},
19
+ "uniqueItems": true
20
+ },
21
+ "workspaceVerifications": {
22
+ "type": "array",
23
+ "items": {
24
+ "type": "object",
25
+ "required": ["projectRoot", "queryModel", "queryPayloadDigest", "verifiedAt"],
26
+ "properties": {
27
+ "projectRoot": {"type": "string"},
28
+ "queryModel": {"type": "string"},
29
+ "queryPayloadDigest": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
30
+ "rowCount": {"type": ["integer", "null"], "minimum": 0},
31
+ "verifiedAt": {"type": "string", "format": "date-time"}
32
+ },
33
+ "additionalProperties": false
34
+ }
35
+ },
16
36
  "runtime": {"type": "object"},
17
37
  "connection": {"$ref": "connection.schema.json"},
18
38
  "semantic": {"type": "object"},
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "foggy-deepseek-onboarding-versions/v1",
3
- "packageVersion": "0.4.0-beta.6",
3
+ "packageVersion": "0.4.0-beta.7",
4
4
  "validatedAt": "2026-09-01",
5
5
  "components": {
6
6
  "deepseekHarness": {
@@ -33,6 +33,12 @@ The wrapper defaults `FOGGY_RUNTIME_PROFILE_STORE` to the private persistent dir
33
33
  `<dataRoot>/cli-profiles`. An explicit operator-provided value still wins. Do not use `/tmp` for a profile
34
34
  that must survive a WSL or Harness restart.
35
35
 
36
+ If plugin settings detect profiles in the legacy temporary store, use **Move to persistent store**.
37
+ Migration validates the opaque profile schema, rejects embedded passwords and conflicts, writes the
38
+ destination with private permissions, verifies it, and moves the legacy JSON to a recoverable private
39
+ backup below the Foggy data root.
40
+ Do not manually copy or edit opaque profile JSON.
41
+
36
42
  Treat every user-supplied identifier and bound as immutable input: profile, datasource, namespace,
37
43
  models directory, bundle name, TM/QM name, query fields, and limit. Do not swap in demo names, add
38
44
  fields, raise the limit, or introduce replacement flags. If one of these inputs is missing, pause for
@@ -52,11 +58,13 @@ For Harness-driven onboarding, use the two composite commands below. Each writes
52
58
  evidence internally and refuses to cross an unapproved mutation gate:
53
59
 
54
60
  ```text
55
- onboard-datasource-run --connection-file <approved-json> \
61
+ onboard-datasource-run --project-root <current-session-workspace> \
62
+ --connection-file <approved-json> \
56
63
  --approve-configure --approve-bind --include-indexes
57
64
 
58
65
  # After authoring the registered TM/QM draft from schema metadata:
59
- onboard-semantic-run --semantic-plan <approved-json> --query-payload <approved-json> \
66
+ onboard-semantic-run --project-root <current-session-workspace> \
67
+ --semantic-plan <approved-json> --query-payload <approved-json> \
60
68
  --approve-validate --approve-publish --approve-execute
61
69
  ```
62
70
 
@@ -70,6 +78,12 @@ payload and rerun `onboard-semantic-run`; it resumes at query verification and l
70
78
  place. A same-name datasource is accepted idempotently only when its public name and database type match
71
79
  the approved plan; otherwise replacement still requires explicit approval.
72
80
 
81
+ When a completed profile already belongs to another workspace, the datasource composite may add the
82
+ current workspace as a binding only if the approved connection contract is identical and the datasource,
83
+ schema, and semantic publication checkpoints are complete. The semantic composite then accepts the same
84
+ published draft digest from the bound workspace and performs a workspace-specific bounded verification
85
+ query. It refuses semantic replacement from the secondary workspace.
86
+
73
87
  The granular commands below remain available for manual troubleshooting and resumption. Do not expand
74
88
  the composite commands into this list during a normal Harness turn.
75
89
 
@@ -85,8 +99,8 @@ semantic-validate --profile <profile>
85
99
  semantic-validate --profile <profile> --apply
86
100
  semantic-publish --profile <profile>
87
101
  semantic-publish --profile <profile> --apply
88
- semantic-verify --profile <profile> --query-payload <json>
89
- semantic-verify --profile <profile> --query-payload <json> --execute
102
+ semantic-verify --profile <profile> --project-root <current-session-workspace> --query-payload <json>
103
+ semantic-verify --profile <profile> --project-root <current-session-workspace> --query-payload <json> --execute
90
104
  onboard-status --profile <profile>
91
105
  ```
92
106
 
@@ -34,6 +34,7 @@ PROFILE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$")
34
34
  ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
35
35
  OPAQUE_PROFILE_PATTERN = re.compile(r"^fop_[a-f0-9]{32}$")
36
36
  OPAQUE_REVISION_PATTERN = re.compile(r"^sha256:[a-f0-9]{64}$")
37
+ OPAQUE_PROFILE_SCHEMA = "foggy-runtime-onboarding-profile/v1"
37
38
  MODEL_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
38
39
 
39
40
 
@@ -283,11 +284,20 @@ def materialize(
283
284
  progress_index: int = 0,
284
285
  progress_total: int = 1,
285
286
  progress_message: str = "Downloading and verifying asset",
287
+ replace_corrupt: bool = False,
286
288
  ) -> dict:
287
289
  destination.parent.mkdir(parents=True, exist_ok=True)
288
290
  if destination.is_file():
289
- verify_asset(destination, asset["sha256"])
290
- return {"file": asset["file"], "path": str(destination), "source": "existing", "sha256": asset["sha256"]}
291
+ try:
292
+ verify_asset(destination, asset["sha256"])
293
+ return {"file": asset["file"], "path": str(destination), "source": "existing", "sha256": asset["sha256"]}
294
+ except OnboardingError:
295
+ if not replace_corrupt:
296
+ raise
297
+ quarantine = destination.with_name(
298
+ destination.name + f".corrupt-{dt.datetime.now().strftime('%Y%m%d-%H%M%S-%f')}"
299
+ )
300
+ destination.replace(quarantine)
291
301
  cached = cached_asset(asset["file"], asset["sha256"], cache_dirs)
292
302
  if cached:
293
303
  shutil.copy2(cached, destination)
@@ -461,6 +471,153 @@ def configure_profile_store(data_root: Path, *, create: bool = True) -> Path:
461
471
  return destination
462
472
 
463
473
 
474
+ def legacy_profile_stores(destination: Path) -> list[Path]:
475
+ configured = [
476
+ normalized(item)
477
+ for item in os.environ.get("FOGGY_RUNTIME_PROFILE_LEGACY_STORES", "").split(os.pathsep)
478
+ if item.strip()
479
+ ]
480
+ default_legacy = Path(tempfile.gettempdir()) / "foggy-profiles"
481
+ candidates = [*configured, normalized(default_legacy)]
482
+ unique: list[Path] = []
483
+ for candidate in candidates:
484
+ if candidate == destination or candidate in unique:
485
+ continue
486
+ unique.append(candidate)
487
+ return unique
488
+
489
+
490
+ def validate_opaque_profile_document(payload: dict, path: Path) -> dict:
491
+ allowed = {"schemaVersion", "profileId", "revision", "createdAt", "updatedAt", "connection"}
492
+ unexpected = sorted(set(payload) - allowed)
493
+ if unexpected:
494
+ raise OnboardingError(f"Unsupported opaque profile fields in {path.name}: {', '.join(unexpected)}")
495
+ if payload.get("schemaVersion") != OPAQUE_PROFILE_SCHEMA:
496
+ raise OnboardingError(f"Unexpected opaque profile schema in {path.name}")
497
+ profile_id = payload.get("profileId")
498
+ revision = payload.get("revision")
499
+ if not isinstance(profile_id, str) or not OPAQUE_PROFILE_PATTERN.fullmatch(profile_id):
500
+ raise OnboardingError(f"Invalid opaque profile ID in {path.name}")
501
+ if path.stem != profile_id:
502
+ raise OnboardingError(f"Opaque profile filename does not match its ID: {path.name}")
503
+ if not isinstance(revision, str) or not OPAQUE_REVISION_PATTERN.fullmatch(revision):
504
+ raise OnboardingError(f"Invalid opaque profile revision in {path.name}")
505
+ connection = payload.get("connection")
506
+ if not isinstance(connection, dict):
507
+ raise OnboardingError(f"Opaque profile connection is missing in {path.name}")
508
+ allowed_connection = {"name", "type", "jdbcUrl", "username", "passwordEnv", "namespace"}
509
+ unexpected_connection = sorted(set(connection) - allowed_connection)
510
+ if unexpected_connection:
511
+ raise OnboardingError(
512
+ f"Unsupported opaque connection fields in {path.name}: {', '.join(unexpected_connection)}"
513
+ )
514
+ for name in ("name", "type", "jdbcUrl", "namespace"):
515
+ if not isinstance(connection.get(name), str) or not connection[name].strip():
516
+ raise OnboardingError(f"Opaque profile connection.{name} is invalid in {path.name}")
517
+ password_env = connection.get("passwordEnv")
518
+ if password_env is not None and (
519
+ not isinstance(password_env, str) or not ENV_NAME_PATTERN.fullmatch(password_env)
520
+ ):
521
+ raise OnboardingError(f"Opaque profile passwordEnv is invalid in {path.name}")
522
+ jdbc_url = connection["jdbcUrl"]
523
+ if re.search(r"(?i)(?:password|passwd|pwd)\s*=", jdbc_url) or re.search(r"//[^/@:]+:[^/@]+@", jdbc_url):
524
+ raise OnboardingError(f"Opaque profile embeds a password in jdbcUrl: {path.name}")
525
+ return payload
526
+
527
+
528
+ def profile_migration_inventory(data_root: Path) -> dict:
529
+ destination = configure_profile_store(data_root, create=False)
530
+ entries: list[dict] = []
531
+ for source in legacy_profile_stores(destination):
532
+ if not source.is_dir():
533
+ continue
534
+ for path in sorted(source.glob("fop_*.json")):
535
+ try:
536
+ payload = validate_opaque_profile_document(read_json_object(path, "Opaque profile"), path)
537
+ target = destination / path.name
538
+ status = "pending"
539
+ if target.is_file():
540
+ existing = validate_opaque_profile_document(read_json_object(target, "Opaque profile"), target)
541
+ status = "migrated" if existing == payload else "conflict"
542
+ entries.append({
543
+ "profileId": payload["profileId"],
544
+ "revision": payload["revision"],
545
+ "source": str(source),
546
+ "destination": str(destination),
547
+ "status": status,
548
+ })
549
+ except OnboardingError as exc:
550
+ entries.append({
551
+ "profileId": path.stem if OPAQUE_PROFILE_PATTERN.fullmatch(path.stem) else None,
552
+ "source": str(source),
553
+ "destination": str(destination),
554
+ "status": "invalid",
555
+ "error": str(exc),
556
+ })
557
+ return {
558
+ "schemaVersion": "foggy-deepseek-profile-migration-status/v1",
559
+ "profileStore": str(destination),
560
+ "legacyStores": [str(path) for path in legacy_profile_stores(destination)],
561
+ "entries": entries,
562
+ "pendingCount": sum(item["status"] == "pending" for item in entries),
563
+ "conflictCount": sum(item["status"] in {"conflict", "invalid"} for item in entries),
564
+ }
565
+
566
+
567
+ def profile_migration_status_command(args: argparse.Namespace) -> dict:
568
+ install_root = normalized(args.install_root or default_install_root())
569
+ install_state = read_install_state(install_root)
570
+ data_root = normalized(args.data_root or install_state["dataRoot"])
571
+ return {"success": True, **profile_migration_inventory(data_root), "productionReady": False}
572
+
573
+
574
+ def profile_migrate_command(args: argparse.Namespace) -> dict:
575
+ if not args.approve:
576
+ raise OnboardingError("Profile migration requires --approve")
577
+ install_root = normalized(args.install_root or default_install_root())
578
+ install_state = read_install_state(install_root)
579
+ data_root = normalized(args.data_root or install_state["dataRoot"])
580
+ inventory = profile_migration_inventory(data_root)
581
+ if inventory["conflictCount"]:
582
+ raise OnboardingError("Legacy profile migration has conflicts or invalid entries; inspect status first")
583
+ destination = configure_profile_store(data_root)
584
+ migrated: list[dict] = []
585
+ for item in inventory["entries"]:
586
+ if item["status"] != "pending":
587
+ continue
588
+ source = normalized(Path(item["source"]) / f"{item['profileId']}.json")
589
+ payload = validate_opaque_profile_document(read_json_object(source, "Opaque profile"), source)
590
+ target = destination / source.name
591
+ atomic_json(target, payload)
592
+ if os.name != "nt":
593
+ target.chmod(0o600)
594
+ if read_json_object(target, "Migrated opaque profile") != payload:
595
+ target.unlink(missing_ok=True)
596
+ raise OnboardingError(f"Migrated profile verification failed: {source.name}")
597
+ backup_root = data_root / "profile-migration-backups" / dt.datetime.now().strftime("%Y%m%d-%H%M%S-%f")
598
+ backup_root.mkdir(parents=True, exist_ok=True)
599
+ if os.name != "nt":
600
+ backup_root.chmod(0o700)
601
+ archived = backup_root / (source.name + ".bak")
602
+ source.replace(archived)
603
+ if os.name != "nt":
604
+ archived.chmod(0o600)
605
+ migrated.append({
606
+ "profileId": payload["profileId"],
607
+ "revision": payload["revision"],
608
+ "destination": str(target),
609
+ "legacyBackup": str(archived),
610
+ })
611
+ return {
612
+ "success": True,
613
+ "schemaVersion": "foggy-deepseek-profile-migration/v1",
614
+ "profileStore": str(destination),
615
+ "migrated": migrated,
616
+ "migratedCount": len(migrated),
617
+ "productionReady": False,
618
+ }
619
+
620
+
464
621
  def resolve_profile_name(data_root: Path, requested: str | None) -> str:
465
622
  if requested:
466
623
  return safe_profile(requested)
@@ -825,6 +982,7 @@ def install_command(args: argparse.Namespace) -> dict:
825
982
  data_root = normalized(args.data_root or default_data_root())
826
983
  cache_dirs = [normalized(item) for item in args.asset_cache_dir]
827
984
  components = versions["components"]
985
+ repair_component = getattr(args, "repair_component", None)
828
986
  assert_managed_root(install_root, "Install root")
829
987
  assert_managed_root(data_root, "Data root")
830
988
  if install_root == data_root:
@@ -837,6 +995,7 @@ def install_command(args: argparse.Namespace) -> dict:
837
995
  "profileStore": str(profile_store),
838
996
  "workspaceMode": "dsh-session-cwd",
839
997
  "versions": {name: value.get("version") for name, value in components.items()},
998
+ "repairComponent": repair_component,
840
999
  "operations": ["install isolated CLI", "verify Launcher assets", "install global analysis Skill", "write install state"],
841
1000
  "productionReady": False,
842
1001
  }
@@ -870,6 +1029,7 @@ def install_command(args: argparse.Namespace) -> dict:
870
1029
  progress=progress, progress_phase="cli", progress_step=1,
871
1030
  progress_index=index, progress_total=len(cli_assets),
872
1031
  progress_message="Downloading and verifying CLI",
1032
+ replace_corrupt=repair_component == "cli",
873
1033
  ))
874
1034
  progress.update(
875
1035
  "cli", 1, "Downloading and verifying CLI",
@@ -925,6 +1085,7 @@ def install_command(args: argparse.Namespace) -> dict:
925
1085
  progress=progress, progress_phase="launcher", progress_step=2,
926
1086
  progress_index=index, progress_total=len(launcher_assets),
927
1087
  progress_message="Downloading and verifying Launcher",
1088
+ replace_corrupt=repair_component == "launcher",
928
1089
  ))
929
1090
  progress.update(
930
1091
  "launcher", 2, "Downloading and verifying Launcher",
@@ -947,6 +1108,7 @@ def install_command(args: argparse.Namespace) -> dict:
947
1108
  progress=progress, progress_phase="analysis-skill", progress_step=3,
948
1109
  progress_index=index, progress_total=len(analysis_assets),
949
1110
  progress_message="Downloading and verifying analysis Skill",
1111
+ replace_corrupt=repair_component == "analysis-skill",
950
1112
  ))
951
1113
  progress.update(
952
1114
  "analysis-skill", 3, "Downloading and verifying analysis Skill",
@@ -957,7 +1119,7 @@ def install_command(args: argparse.Namespace) -> dict:
957
1119
  progress.update("analysis-skill", 3, "Installing analysis Skill", fraction=0.9, current_file=zip_asset["file"])
958
1120
  analysis_skill = install_analysis_skill(
959
1121
  downloads / "skill" / zip_asset["file"], install_root, components["analysisSkill"]["version"],
960
- zip_asset["sha256"], versions["packageVersion"], args.replace_skill,
1122
+ zip_asset["sha256"], versions["packageVersion"], args.replace_skill or repair_component == "analysis-skill",
961
1123
  )
962
1124
  progress.update("analysis-skill", 3, "Analysis Skill ready", fraction=1.0)
963
1125
  progress.update("workspace-skills", 4, "Registering native DSH Skills", fraction=0.1)
@@ -1088,8 +1250,61 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1088
1250
  existing = data_root / "runtime-state.json"
1089
1251
  if existing.is_file():
1090
1252
  prior = json.loads(existing.read_text(encoding="utf-8"))
1091
- if process_info(int(prior.get("pid", 0)))["running"]:
1092
- raise OnboardingError(f"Recorded Runtime is already running with PID {prior['pid']}")
1253
+ prior_info = process_info(int(prior.get("pid", 0)))
1254
+ if prior_info["running"]:
1255
+ expected_jar = f"foggy-runtime-launcher-{state['launcher']['version']}.jar"
1256
+ if prior_info.get("commandLine") and expected_jar not in prior_info["commandLine"]:
1257
+ raise OnboardingError(
1258
+ f"Recorded Runtime PID {prior['pid']} does not match the pinned Launcher"
1259
+ )
1260
+ cli = state["cli"]["command"]
1261
+ namespace = args.namespace or prior.get("namespace") or versions["defaults"]["namespace"]
1262
+ base_url = prior.get("runtimeUrl")
1263
+ if not isinstance(base_url, str) or not base_url:
1264
+ raise OnboardingError("Recorded Runtime does not contain runtimeUrl")
1265
+ wait_result = command_result(
1266
+ [cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "wait-ready",
1267
+ "--timeout-seconds", str(args.timeout or versions["defaults"]["readinessTimeoutSeconds"]),
1268
+ "--interval-seconds", "1"],
1269
+ timeout=(args.timeout or versions["defaults"]["readinessTimeoutSeconds"]) + 30,
1270
+ )
1271
+ wait_payload = parse_json_output(wait_result, "wait-ready")
1272
+ if wait_payload.get("success") is not True:
1273
+ raise OnboardingError("wait-ready returned success=false for recorded Runtime")
1274
+ capabilities = parse_json_output(
1275
+ command_result(
1276
+ [cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "capabilities"],
1277
+ timeout=30,
1278
+ ),
1279
+ "capabilities",
1280
+ )
1281
+ expected_contract = versions["components"]["launcher"]["runtimeApiContract"]
1282
+ if capabilities.get("success") is not True or capabilities.get("runtimeApiVersion") != expected_contract:
1283
+ raise OnboardingError(f"Unexpected Runtime API contract; expected {expected_contract}")
1284
+ if capabilities.get("data", {}).get("securityMode") != versions["defaults"]["securityMode"]:
1285
+ raise OnboardingError("Recorded Runtime did not report the expected dev/test security mode")
1286
+ verified_at = now_utc()
1287
+ identity = {
1288
+ "engine": capabilities.get("engine"),
1289
+ "runtimeApiVersion": capabilities.get("runtimeApiVersion"),
1290
+ "schemaVersion": capabilities.get("data", {}).get("schemaVersion"),
1291
+ "securityMode": capabilities.get("data", {}).get("securityMode"),
1292
+ }
1293
+ return {
1294
+ "success": True,
1295
+ **prior,
1296
+ "identity": identity,
1297
+ "lastVerifiedAt": verified_at,
1298
+ "verification": {
1299
+ "waitReady": True,
1300
+ "capabilities": identity,
1301
+ "persisted": False,
1302
+ "instruction": "Capture this JSON in the current workspace when fresh verification evidence is required",
1303
+ },
1304
+ "action": "already-running-verified",
1305
+ "resumed": True,
1306
+ "productionReady": False,
1307
+ }
1093
1308
  existing.unlink()
1094
1309
  port = args.port or int(versions["defaults"]["port"])
1095
1310
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
@@ -1834,7 +2049,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
1834
2049
  raise OnboardingError("--query-model must be declared in the registered semantic plan")
1835
2050
  if not args.query_payload:
1836
2051
  raise OnboardingError("--query-payload is required for semantic verification")
1837
- project_root = normalized(state["projectRoot"])
2052
+ project_root = normalized(getattr(args, "project_root", None) or state["projectRoot"])
2053
+ if not project_root_is_bound(state, project_root):
2054
+ raise OnboardingError("Query projectRoot is not bound to this onboarding profile")
1838
2055
  payload_path = normalized(args.query_payload)
1839
2056
  if not is_child(payload_path, project_root):
1840
2057
  raise OnboardingError("Query payload must stay inside projectRoot")
@@ -1885,7 +2102,20 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
1885
2102
  queryExecuted=True,
1886
2103
  rowCount=row_count,
1887
2104
  queryPayloadDigest=sha256(payload_path),
2105
+ projectRoot=str(project_root),
1888
2106
  )
2107
+ workspace_verifications = state.setdefault("workspaceVerifications", [])
2108
+ workspace_verifications[:] = [
2109
+ item for item in workspace_verifications
2110
+ if not (item.get("projectRoot") == str(project_root) and item.get("queryModel") == query_model)
2111
+ ]
2112
+ workspace_verifications.append({
2113
+ "projectRoot": str(project_root),
2114
+ "queryModel": query_model,
2115
+ "queryPayloadDigest": sha256(payload_path),
2116
+ "rowCount": row_count,
2117
+ "verifiedAt": now_utc(),
2118
+ })
1889
2119
  state.setdefault("artifacts", {})["semanticVerifyEvidence"] = str(evidence_dir)
1890
2120
  path = write_onboarding_state(data_root, state)
1891
2121
  return {
@@ -1944,12 +2174,63 @@ def onboarding_status_command(args: argparse.Namespace) -> dict:
1944
2174
  "passwordEnv": password_env,
1945
2175
  "passwordEnvPresent": bool(password_env and os.environ.get(password_env)),
1946
2176
  "steps": state["steps"],
2177
+ "projectRoot": state.get("projectRoot"),
2178
+ "workspaceBindings": [str(path) for path in bound_project_roots(state)],
2179
+ "workspaceVerifications": state.get("workspaceVerifications", []),
1947
2180
  "artifacts": state.get("artifacts", {}),
1948
2181
  "next": next_onboarding_action(state),
1949
2182
  "productionReady": False,
1950
2183
  }
1951
2184
 
1952
2185
 
2186
+ def onboarding_list_command(args: argparse.Namespace) -> dict:
2187
+ install_root = normalized(args.install_root or default_install_root())
2188
+ install_state = read_install_state(install_root)
2189
+ data_root = normalized(args.data_root or install_state["dataRoot"])
2190
+ profiles_dir = data_root / "onboarding" / "profiles"
2191
+ profiles: list[dict] = []
2192
+ ordered_steps = [
2193
+ "planned", "datasourceConfigured", "datasourceVerified", "schemaDiscovered",
2194
+ "semanticDrafted", "semanticValidated", "semanticPublished", "semanticVerified",
2195
+ ]
2196
+ for path in sorted(profiles_dir.glob("*.json")):
2197
+ if not PROFILE_PATTERN.fullmatch(path.stem):
2198
+ continue
2199
+ try:
2200
+ state = read_onboarding_state(data_root, path.stem)
2201
+ steps = state.get("steps", {})
2202
+ completed = sum(steps.get(name, {}).get("status") == "completed" for name in ordered_steps)
2203
+ profiles.append({
2204
+ "profile": state["profile"],
2205
+ "projectRoot": state.get("projectRoot"),
2206
+ "workspaceBindings": [str(item) for item in bound_project_roots(state)],
2207
+ "updatedAt": state.get("updatedAt"),
2208
+ "completedSteps": completed,
2209
+ "totalSteps": len(ordered_steps),
2210
+ "steps": {name: steps.get(name, {"status": "pending"}) for name in ordered_steps},
2211
+ "next": next_onboarding_action(state),
2212
+ })
2213
+ except OnboardingError as exc:
2214
+ profiles.append({
2215
+ "profile": path.stem,
2216
+ "projectRoot": None,
2217
+ "updatedAt": None,
2218
+ "completedSteps": 0,
2219
+ "totalSteps": len(ordered_steps),
2220
+ "steps": {},
2221
+ "next": {"status": "invalid"},
2222
+ "error": str(exc),
2223
+ })
2224
+ profiles.sort(key=lambda item: item.get("updatedAt") or "", reverse=True)
2225
+ return {
2226
+ "success": True,
2227
+ "schemaVersion": "foggy-deepseek-onboarding-list/v1",
2228
+ "profiles": profiles,
2229
+ "profileCount": len(profiles),
2230
+ "productionReady": False,
2231
+ }
2232
+
2233
+
1953
2234
  def onboarding_resume_command(args: argparse.Namespace) -> dict:
1954
2235
  result = onboarding_status_command(args)
1955
2236
  result["schemaVersion"] = "foggy-deepseek-onboarding-resume/v1"
@@ -1986,6 +2267,37 @@ def step_completed(state: dict, name: str) -> bool:
1986
2267
  return state.get("steps", {}).get(name, {}).get("status") == "completed"
1987
2268
 
1988
2269
 
2270
+ def bound_project_roots(state: dict) -> list[Path]:
2271
+ values = [state.get("projectRoot"), *state.get("workspaceBindings", [])]
2272
+ result: list[Path] = []
2273
+ for value in values:
2274
+ if not isinstance(value, str) or not value:
2275
+ continue
2276
+ path = normalized(value)
2277
+ if path not in result:
2278
+ result.append(path)
2279
+ return result
2280
+
2281
+
2282
+ def project_root_is_bound(state: dict, project_root: Path) -> bool:
2283
+ return normalized(project_root) in bound_project_roots(state)
2284
+
2285
+
2286
+ def bind_completed_workspace(state: dict, data_root: Path, project_root: Path) -> bool:
2287
+ project_root = normalized(project_root)
2288
+ if project_root_is_bound(state, project_root):
2289
+ return False
2290
+ required = ("datasourceConfigured", "datasourceVerified", "schemaDiscovered", "semanticPublished")
2291
+ if not all(step_completed(state, name) for name in required):
2292
+ raise OnboardingError(
2293
+ "Existing onboarding profile belongs to a different projectRoot and is not complete enough for safe reuse; "
2294
+ "resume from its original DSH workspace or choose a new profile name"
2295
+ )
2296
+ state.setdefault("workspaceBindings", []).append(str(project_root))
2297
+ write_onboarding_state(data_root, state)
2298
+ return True
2299
+
2300
+
1989
2301
  def datasource_run_command(args: argparse.Namespace) -> dict:
1990
2302
  _install_root, install_state, data_root, _runtime_state = onboarding_context(args, require_runtime=True)
1991
2303
  project_root = normalized(args.project_root or Path.cwd())
@@ -2005,16 +2317,14 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2005
2317
  if existing:
2006
2318
  if existing.get("connection") != requested_connection:
2007
2319
  raise OnboardingError("Existing onboarding profile does not match the requested connection plan")
2008
- if normalized(existing.get("projectRoot", "")) != project_root:
2009
- raise OnboardingError(
2010
- "Existing onboarding profile belongs to a different projectRoot; resume from that DSH workspace "
2011
- "or choose a new profile name"
2012
- )
2320
+ adopted = bind_completed_workspace(existing, data_root, project_root)
2013
2321
  plan_result = {
2014
2322
  "success": True,
2015
2323
  "schemaVersion": "foggy-deepseek-onboarding-plan-result/v1",
2016
2324
  "profile": profile,
2017
2325
  "resumed": True,
2326
+ "workspaceAdopted": adopted,
2327
+ "projectRoot": str(project_root),
2018
2328
  "statePath": str(onboarding_state_path(data_root, profile)),
2019
2329
  "next": next_onboarding_action(existing),
2020
2330
  "productionReady": False,
@@ -2119,8 +2429,8 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2119
2429
  }
2120
2430
 
2121
2431
 
2122
- def semantic_plan_snapshot(state: dict, plan: dict) -> tuple[Path, dict, bool]:
2123
- project_root = normalized(state["projectRoot"])
2432
+ def semantic_plan_snapshot(state: dict, plan: dict, project_root: Path | None = None) -> tuple[Path, dict, bool]:
2433
+ project_root = normalized(project_root or state["projectRoot"])
2124
2434
  draft_dir = normalized(project_root / plan["draftDir"])
2125
2435
  if not is_child(draft_dir, project_root) or draft_dir == project_root:
2126
2436
  raise OnboardingError("draftDir must stay inside projectRoot and cannot equal it")
@@ -2142,6 +2452,22 @@ def published_digest_matches(state: dict, digest: str) -> bool:
2142
2452
  return published.get("status") == "completed" and published.get("digest") == digest
2143
2453
 
2144
2454
 
2455
+ def workspace_query_verification(state: dict, project_root: Path, query_model: str, digest: str) -> dict | None:
2456
+ requested_root = str(normalized(project_root))
2457
+ candidates = list(state.get("workspaceVerifications", []))
2458
+ legacy = state.get("steps", {}).get("semanticVerified", {})
2459
+ if legacy.get("status") == "completed" and legacy.get("projectRoot"):
2460
+ candidates.append(legacy)
2461
+ for item in candidates:
2462
+ if (
2463
+ item.get("projectRoot") == requested_root
2464
+ and item.get("queryModel") == query_model
2465
+ and item.get("queryPayloadDigest") == digest
2466
+ ):
2467
+ return item
2468
+ return None
2469
+
2470
+
2145
2471
  def semantic_run_command(args: argparse.Namespace) -> dict:
2146
2472
  approved_plan = validate_semantic_plan(read_json_object(normalized(args.semantic_plan), "Semantic plan"))
2147
2473
  if not approved_plan.get("profile"):
@@ -2152,7 +2478,11 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2152
2478
  profile_args = argparse.Namespace(**vars(args))
2153
2479
  profile_args.profile = profile
2154
2480
  state, _install_state, _data_root, _runtime_state = require_profile(profile_args, require_runtime=True)
2155
- project_root = normalized(state["projectRoot"])
2481
+ project_root = normalized(getattr(args, "project_root", None) or state["projectRoot"])
2482
+ if not project_root_is_bound(state, project_root):
2483
+ raise OnboardingError(
2484
+ "Current projectRoot is not bound to this completed profile; run onboard-datasource-run from this workspace first"
2485
+ )
2156
2486
  payload_path = normalized(args.query_payload)
2157
2487
  if not is_child(payload_path, project_root):
2158
2488
  raise OnboardingError(
@@ -2171,8 +2501,13 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2171
2501
  evidence_dir = composite_evidence_dir(project_root, profile, str(project_root / contract_evidence) if contract_evidence else args.evidence_dir)
2172
2502
  files: list[str] = []
2173
2503
 
2174
- _draft_dir, manifest, draft_matches = semantic_plan_snapshot(state, approved_plan)
2504
+ _draft_dir, manifest, draft_matches = semantic_plan_snapshot(state, approved_plan, project_root)
2175
2505
  published_matches = published_digest_matches(state, manifest["digest"])
2506
+ if normalized(state["projectRoot"]) != project_root and not published_matches:
2507
+ raise OnboardingError(
2508
+ "A secondary workspace may reuse an identical published semantic layer but cannot replace it; "
2509
+ "publish changes from the original projectRoot or choose a new profile"
2510
+ )
2176
2511
  if draft_matches or published_matches:
2177
2512
  drafted = resumed_phase(profile, "semanticDrafted", digest=manifest["digest"])
2178
2513
  else:
@@ -2265,13 +2600,9 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2265
2600
  ))
2266
2601
  save_composite_result(evidence_dir, "11-semantic-publish-apply.json", published, files)
2267
2602
  state = read_onboarding_state(normalized(state["dataRoot"]), profile)
2268
- verified_step = state.get("steps", {}).get("semanticVerified", {})
2269
2603
  payload_digest = sha256(payload_path)
2270
- if (
2271
- verified_step.get("status") == "completed"
2272
- and verified_step.get("queryModel") == declared_query_model
2273
- and verified_step.get("queryPayloadDigest") == payload_digest
2274
- ):
2604
+ verified_step = workspace_query_verification(state, project_root, declared_query_model, payload_digest)
2605
+ if verified_step:
2275
2606
  resumed = resumed_phase(
2276
2607
  profile,
2277
2608
  "semanticVerified",
@@ -2307,6 +2638,7 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2307
2638
  profile=profile,
2308
2639
  query_model=declared_query_model,
2309
2640
  query_payload=args.query_payload,
2641
+ project_root=str(project_root),
2310
2642
  execute=False,
2311
2643
  ))
2312
2644
  save_composite_result(evidence_dir, "12-query-validate.json", query_validated, files)
@@ -2330,6 +2662,7 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2330
2662
  profile=profile,
2331
2663
  query_model=declared_query_model,
2332
2664
  query_payload=args.query_payload,
2665
+ project_root=str(project_root),
2333
2666
  execute=True,
2334
2667
  ))
2335
2668
  save_composite_result(evidence_dir, "13-query-execute.json", executed, files)
@@ -2464,6 +2797,7 @@ def build_parser() -> argparse.ArgumentParser:
2464
2797
  install.add_argument("--project-root")
2465
2798
  install.add_argument("--asset-cache-dir", action="append", default=[])
2466
2799
  install.add_argument("--replace-skill", action="store_true")
2800
+ install.add_argument("--repair-component", choices=("cli", "launcher", "analysis-skill"))
2467
2801
  install.add_argument("--skip-cli-install", action="store_true")
2468
2802
  install.add_argument("--cli-command")
2469
2803
  install.add_argument("--progress-file")
@@ -2516,6 +2850,22 @@ def build_parser() -> argparse.ArgumentParser:
2516
2850
  resume.add_argument("--profile")
2517
2851
  resume.set_defaults(handler=onboarding_resume_command)
2518
2852
 
2853
+ onboarding_list = sub.add_parser("onboard-list")
2854
+ onboarding_list.add_argument("--install-root")
2855
+ onboarding_list.add_argument("--data-root")
2856
+ onboarding_list.set_defaults(handler=onboarding_list_command)
2857
+
2858
+ migration_status = sub.add_parser("profile-migration-status")
2859
+ migration_status.add_argument("--install-root")
2860
+ migration_status.add_argument("--data-root")
2861
+ migration_status.set_defaults(handler=profile_migration_status_command)
2862
+
2863
+ migrate = sub.add_parser("profile-migrate")
2864
+ migrate.add_argument("--install-root")
2865
+ migrate.add_argument("--data-root")
2866
+ migrate.add_argument("--approve", action="store_true")
2867
+ migrate.set_defaults(handler=profile_migrate_command)
2868
+
2519
2869
  datasource_run = sub.add_parser("onboard-datasource-run")
2520
2870
  datasource_run.add_argument("--install-root")
2521
2871
  datasource_run.add_argument("--data-root")
@@ -2536,6 +2886,7 @@ def build_parser() -> argparse.ArgumentParser:
2536
2886
  semantic_run = sub.add_parser("onboard-semantic-run")
2537
2887
  semantic_run.add_argument("--install-root")
2538
2888
  semantic_run.add_argument("--data-root")
2889
+ semantic_run.add_argument("--project-root")
2539
2890
  semantic_run.add_argument("--profile")
2540
2891
  semantic_run.add_argument("--semantic-plan", required=True)
2541
2892
  semantic_run.add_argument("--query-payload", required=True)
@@ -2602,6 +2953,7 @@ def build_parser() -> argparse.ArgumentParser:
2602
2953
  semantic_verify = sub.add_parser("semantic-verify")
2603
2954
  semantic_verify.add_argument("--install-root")
2604
2955
  semantic_verify.add_argument("--data-root")
2956
+ semantic_verify.add_argument("--project-root")
2605
2957
  semantic_verify.add_argument("--profile")
2606
2958
  semantic_verify.add_argument("--query-model")
2607
2959
  semantic_verify.add_argument("--query-payload", required=True)