@descix/cli 1.0.6 → 1.0.8

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 (59) hide show
  1. package/README.md +1 -1
  2. package/agent-assets/guides/README.md +1 -1
  3. package/agent-assets/guides/dynamic-site-pattern.md +17 -30
  4. package/agent-assets/guides/knowledge-only-pattern.md +11 -15
  5. package/agent-assets/guides/microservice-pattern.md +12 -14
  6. package/agent-assets/guides/static-site-pattern.md +12 -15
  7. package/agent-assets/instructions/app-asset-validation.md +1 -1
  8. package/agent-assets/instructions/onboarding-flow.md +4 -4
  9. package/agent-assets/reference/app-creation.md +36 -101
  10. package/agent-assets/reference/architecture.md +43 -51
  11. package/agent-assets/reference/local-dev.md +1 -6
  12. package/agent-assets/reference/mcp-integration.md +21 -23
  13. package/agent-assets/reference/workspace-config.md +97 -340
  14. package/bin/descix.js +337 -618
  15. package/lib/api-client.js +30 -22
  16. package/lib/command-visibility.js +213 -0
  17. package/lib/commands/airdrop.js +10 -0
  18. package/lib/commands/auth.js +16 -2
  19. package/lib/commands/config.js +13 -31
  20. package/lib/commands/corpus.js +163 -59
  21. package/lib/commands/health.js +116 -21
  22. package/lib/commands/init.js +17 -45
  23. package/lib/commands/kb.js +7 -0
  24. package/lib/commands/retired-kb-sync.js +5 -27
  25. package/lib/commands/status.js +0 -9
  26. package/lib/core/Chunker.js +1 -1
  27. package/lib/core/CorpusWalker.js +238 -47
  28. package/lib/core/ManifestLoader.js +68 -0
  29. package/lib/core/Syncer.js +147 -46
  30. package/lib/interactive.js +158 -0
  31. package/lib/origin.js +3 -3
  32. package/lib/sdk-assets.js +28 -0
  33. package/lib/verb-invokes.generated.json +233 -0
  34. package/lib/wallet-file.js +36 -37
  35. package/lib/workspace-config.js +266 -199
  36. package/lib/workspace-identity.js +0 -1
  37. package/package.json +7 -3
  38. package/templates/agent-claude.md +1 -3
  39. package/templates/agent-cline.md +1 -2
  40. package/templates/agent-copilot.md +1 -2
  41. package/templates/agent-cursor.md +1 -2
  42. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +1 -1
  43. package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +2 -3
  44. package/agent-assets/rules/descix_mcp.template.mdc +0 -243
  45. package/lib/commands/briefer/errors.js +0 -88
  46. package/lib/commands/briefer/index.js +0 -319
  47. package/lib/commands/briefer/sources/canonical-sources.js +0 -129
  48. package/lib/commands/briefer/sources/entitlements.js +0 -220
  49. package/lib/commands/briefer/sources/environments.js +0 -205
  50. package/lib/commands/briefer/sources/identifiers.js +0 -214
  51. package/lib/commands/briefer/sources/microservice-deploy.js +0 -267
  52. package/lib/commands/briefer/sources/routing.js +0 -275
  53. package/lib/commands/briefer/sources/what-is-not.js +0 -223
  54. package/lib/commands/briefer/stitcher.js +0 -134
  55. package/lib/commands/briefer/util/source-reader.js +0 -502
  56. package/lib/commands/mcp.js +0 -273
  57. package/lib/commands/update.js +0 -581
  58. package/lib/wizard/setup.js +0 -503
  59. package/templates/descix_mcp.mdc +0 -122
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import * as fs from 'fs/promises';
16
+ import { statSync, realpathSync } from 'fs';
16
17
  import * as path from 'path';
17
18
  import { execFileSync } from 'child_process';
18
19
  // Canonical doc_class taxonomy lives in CorpusDenyLint — import it, never re-list.
@@ -110,6 +111,73 @@ export function detectRepoSlug(dir) {
110
111
  }
111
112
  }
112
113
 
114
+ /**
115
+ * THE ONE OWNER OF "WHICH REPOSITORY OWNS THIS FILE".
116
+ *
117
+ * A workspace is NOT one repository. This one crosses three kinds of boundary at once:
118
+ * the superrepo itself; git SUBMODULES (DeSciX/DeSciX_Cloud|Core|Powch are gitlinks —
119
+ * `git ls-tree HEAD DeSciX/` shows mode 160000); and SIBLING repos that are not submodules
120
+ * at all but independent checkouts the superrepo deliberately ignores (EGPT-research via
121
+ * .gitignore:9, FRAQTL — each with its own `.git` and its own `origin` remote).
122
+ *
123
+ * A REF HAS NO MEANING EXCEPT RELATIVE TO A REPOSITORY. Resolving one against the ambient
124
+ * workspace root is what made this gate refuse 596 files that ARE committed: the superrepo
125
+ * sees a submodule only as a gitlink commit, and an ignored sibling repo not at all.
126
+ * `git rev-parse --show-toplevel` walks UP from the path and stops at the FIRST enclosing
127
+ * repository — which is precisely the repository whose branches the source's `ref` names,
128
+ * and which settles submodule and sibling boundaries with ONE mechanism rather than two.
129
+ *
130
+ * This is NOT the question `detectRepoSlug` answers. That one is "what is this repository
131
+ * CALLED" (for the cross-repo self-naming refusal); this one is "where does it BEGIN", so a
132
+ * ref and a repo-relative path can be formed. Both live here so repository identity has a
133
+ * single home and no consumer re-derives either.
134
+ *
135
+ * @param {string} absolutePath - absolute path to a file or directory in the workspace
136
+ * @returns {{repoRoot: string, repoRelativePath: string}|null} null when the path lies in
137
+ * NO git repository at all — callers must fail loud, never assume a root.
138
+ */
139
+ const OWNING_REPO_CACHE = new Map(); // real directory -> repoRoot. POSITIVE results only.
140
+ export function resolveOwningRepo(absolutePath) {
141
+ const abs = path.resolve(absolutePath);
142
+ // `--show-toplevel` needs a directory that exists, and a source may name a file. Split
143
+ // off the basename ONLY for a non-directory: for a DIRECTORY source, `path.dirname` would
144
+ // step over the very boundary being resolved (dirname of `DeSciX/DeSciX_Cloud` is the
145
+ // superrepo, which is the wrong answer).
146
+ let dir = abs, base = null;
147
+ try {
148
+ if (!statSync(abs).isDirectory()) { dir = path.dirname(abs); base = path.basename(abs); }
149
+ } catch {
150
+ dir = path.dirname(abs); base = path.basename(abs);
151
+ }
152
+
153
+ // BOTH SIDES MUST BE REAL PATHS OR THE SUBTRACTION IS GARBAGE. `git rev-parse
154
+ // --show-toplevel` always answers with symlinks resolved, so comparing it against a
155
+ // caller path that still contains one yields a `../../..`-riddled relative path that
156
+ // matches nothing. This is not a corner case: on macOS every temp dir is /var ->
157
+ // /private/var, and this repo's own dev flow serves the Core tree through a symlink.
158
+ let realDir;
159
+ try { realDir = realpathSync(dir); } catch { realDir = dir; }
160
+
161
+ let repoRoot = OWNING_REPO_CACHE.get(realDir);
162
+ if (repoRoot === undefined) {
163
+ try {
164
+ repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
165
+ cwd: realDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
166
+ }).trim();
167
+ } catch {
168
+ return null; // No repository. NOT cached: a negative is not durable — the directory
169
+ } // may be `git init`-ed later in the same process (fixtures do exactly this).
170
+ if (!repoRoot) return null;
171
+ OWNING_REPO_CACHE.set(realDir, repoRoot);
172
+ }
173
+
174
+ const repoRelativeDir = path.relative(repoRoot, realDir);
175
+ return {
176
+ repoRoot,
177
+ repoRelativePath: base ? path.join(repoRelativeDir, base) : repoRelativeDir,
178
+ };
179
+ }
180
+
113
181
  /**
114
182
  * Enforce the two-shape source contract on ONE source entry.
115
183
  *
@@ -25,6 +25,54 @@ import * as fs from 'fs/promises';
25
25
  import * as path from 'path';
26
26
  import { loadChunks } from './Chunker.js';
27
27
 
28
+ /**
29
+ * THE ONE VALUE this CLI uses to mean "the store did not report a count".
30
+ *
31
+ * A receipt is derived from the STORE, never from the REQUEST. Where the store
32
+ * cannot report, we say so — we never substitute the number of things we ASKED
33
+ * to be changed, because that number is a description of our own request and
34
+ * carries no information about what happened.
35
+ *
36
+ * @type {number}
37
+ */
38
+ export const UNREPORTED_COUNT = -1;
39
+
40
+ /**
41
+ * Normalize a count the store returned into either that count or UNREPORTED_COUNT.
42
+ * This is the ONLY place the "did the store report a number?" decision is made.
43
+ *
44
+ * @param {*} value - the raw count field as it arrived from the store
45
+ * @returns {number} the store's count, or UNREPORTED_COUNT if it reported none
46
+ */
47
+ export function reportedCount(value) {
48
+ return Number.isFinite(value) ? value : UNREPORTED_COUNT;
49
+ }
50
+
51
+ /**
52
+ * True when `value` is a count the store actually reported. Callers use this to
53
+ * choose between printing a number and printing "unknown" — never to fabricate one.
54
+ *
55
+ * @param {*} value
56
+ * @returns {boolean}
57
+ */
58
+ export function isReportedCount(value) {
59
+ return Number.isFinite(value) && value >= 0;
60
+ }
61
+
62
+ /**
63
+ * Sum counts across batches while preserving unknown-ness: if ANY batch could not
64
+ * be reported, the total is not a number we can honestly state.
65
+ *
66
+ * @param {number} runningTotal - UNREPORTED_COUNT once any batch was unreportable
67
+ * @param {*} batchValue - the raw count field from this batch's store response
68
+ * @returns {number}
69
+ */
70
+ export function accumulateReportedCount(runningTotal, batchValue) {
71
+ const next = reportedCount(batchValue);
72
+ if (!isReportedCount(runningTotal) || !isReportedCount(next)) return UNREPORTED_COUNT;
73
+ return runningTotal + next;
74
+ }
75
+
28
76
  /**
29
77
  * Compute delta between local chunks and remote Pinecone records
30
78
  * Uses content_hash for efficient change detection when available.
@@ -73,39 +121,64 @@ export function computeChunkDelta(localChunks, remoteChunks) {
73
121
  }
74
122
 
75
123
  /**
76
- * Get existing chunk metadata from Pinecone (with content_hash for delta)
77
- * Falls back to IDs-only if metadata endpoint not available.
78
- *
124
+ * Get existing chunk metadata from Pinecone (with content_hash for delta).
125
+ * Falls back to the IDs-only endpoint when the metadata endpoint is unavailable.
126
+ *
127
+ * An empty array means the STORE REPORTED an empty KB. It never means "we could
128
+ * not find out" — when neither endpoint can report, this THROWS naming both
129
+ * endpoints and both causes, because an unknown remote set presented as an empty
130
+ * one makes every caller conclude that nothing is stale.
131
+ *
79
132
  * @param {Object} apiClient - DeSciXApiClient instance
80
133
  * @param {string} communityId - Community ID
81
134
  * @param {string} appId - App ID
82
135
  * @param {string} kbId - Knowledge base ID
83
- * @returns {Promise<Array<{id: string, content_hash: string}>>} Chunk metadata
136
+ * @returns {Promise<Array<{id: string, content_hash: string}>|Array<string>>} Chunk
137
+ * metadata, or plain chunk ids from the compatibility endpoint.
138
+ * @throws {Error} when neither endpoint reports the remote chunk set.
84
139
  */
85
140
  export async function getRemoteChunkMetadata(apiClient, communityId, appId, kbId) {
141
+ // A fallback is a COMPATIBILITY PATH, not an error handler: we fall back when
142
+ // the newer endpoint is absent, and we FAIL LOUD when neither can report.
143
+ // Returning [] here would present a transport failure as a store state
144
+ // ("remote is empty"), and every caller would then compute that nothing is
145
+ // stale and re-upsert everything — silently.
146
+ const failures = [];
147
+
86
148
  try {
87
- // Try new metadata endpoint first
88
149
  const result = await apiClient.invoke('kb_get_chunk_metadata', {
89
150
  app_id: appId,
90
151
  kb_id: kbId
91
152
  });
92
153
  // apiClient.invoke returns { status, message: { chunks, count, ... } }
93
- const data = result.message || result;
94
- return data.chunks || [];
154
+ const data = result?.message || result;
155
+ // An empty ARRAY is the store reporting an empty KB — legal, and preserved.
156
+ // An ABSENT field is the store not reporting at all — not the same fact.
157
+ if (Array.isArray(data?.chunks)) return data.chunks;
158
+ failures.push("kb_get_chunk_metadata: response carried no 'chunks' array");
95
159
  } catch (error) {
96
- // Fall back to IDs-only endpoint
97
- try {
98
- const result = await apiClient.invoke('kb_get_chunk_ids', {
99
- app_id: appId,
100
- kb_id: kbId
101
- });
102
- // apiClient.invoke returns { status, message: { chunk_ids, count, ... } }
103
- const data = result.message || result;
104
- return data.chunk_ids || [];
105
- } catch {
106
- return [];
107
- }
160
+ failures.push(`kb_get_chunk_metadata: ${error.message}`);
108
161
  }
162
+
163
+ try {
164
+ const result = await apiClient.invoke('kb_get_chunk_ids', {
165
+ app_id: appId,
166
+ kb_id: kbId
167
+ });
168
+ // apiClient.invoke returns { status, message: { chunk_ids, count, ... } }
169
+ const data = result?.message || result;
170
+ if (Array.isArray(data?.chunk_ids)) return data.chunk_ids;
171
+ failures.push("kb_get_chunk_ids: response carried no 'chunk_ids' array");
172
+ } catch (error) {
173
+ failures.push(`kb_get_chunk_ids: ${error.message}`);
174
+ }
175
+
176
+ throw new Error(
177
+ `Cannot enumerate remote chunks for ${appId}/${kbId}: both endpoints failed to report. ` +
178
+ 'The remote chunk set is UNKNOWN — it must NOT be treated as an empty KB, because ' +
179
+ 'that would silently skip stale-chunk deletion and re-upsert everything. ' +
180
+ failures.join(' | ')
181
+ );
109
182
  }
110
183
 
111
184
  /**
@@ -129,7 +202,9 @@ export async function getRemoteChunkIds(apiClient, communityId, appId, kbId) {
129
202
  * @param {string} appId - App ID
130
203
  * @param {string} kbId - Knowledge base ID
131
204
  * @param {Array} chunks - Chunk records to upsert
132
- * @returns {Promise<{upserted: number}>}
205
+ * @returns {Promise<{upserted: number}>} `upserted` is the store's own count, or
206
+ * UNREPORTED_COUNT if ANY batch's store response carried no count.
207
+ * Test it with isReportedCount(); never print it unguarded.
133
208
  */
134
209
  export async function upsertChunks(apiClient, communityId, appId, kbId, chunks) {
135
210
  if (chunks.length === 0) {
@@ -147,7 +222,9 @@ export async function upsertChunks(apiClient, communityId, appId, kbId, chunks)
147
222
  kb_id: kbId,
148
223
  chunks: batch
149
224
  });
150
- totalUpserted += result.upserted_count || batch.length;
225
+ // apiClient.invoke returns the whole { status, message: {...} } envelope.
226
+ const data = result?.message || result;
227
+ totalUpserted = accumulateReportedCount(totalUpserted, data?.upserted_count);
151
228
  }
152
229
 
153
230
  return { upserted: totalUpserted };
@@ -164,7 +241,9 @@ export async function upsertChunks(apiClient, communityId, appId, kbId, chunks)
164
241
  * @param {string} appId - App ID
165
242
  * @param {string} kbId - Knowledge base ID
166
243
  * @param {Array<string>} chunkIds - Chunk IDs to delete
167
- * @returns {Promise<{deleted: number}>}
244
+ * @returns {Promise<{deleted: number}>} `deleted` is the store's own count, or
245
+ * UNREPORTED_COUNT if the store reported none. It is NOT chunkIds.length.
246
+ * Test it with isReportedCount(); never print it unguarded.
168
247
  */
169
248
  export async function deleteStaleChunks(apiClient, communityId, appId, kbId, chunkIds) {
170
249
  if (chunkIds.length === 0) {
@@ -179,7 +258,7 @@ export async function deleteStaleChunks(apiClient, communityId, appId, kbId, chu
179
258
  });
180
259
 
181
260
  const data = result?.message || result;
182
- return { deleted: data.deleted_count ?? chunkIds.length };
261
+ return { deleted: reportedCount(data?.deleted_count) };
183
262
  }
184
263
 
185
264
  /**
@@ -198,7 +277,10 @@ export async function deleteStaleChunks(apiClient, communityId, appId, kbId, chu
198
277
  * @param {string} appId - App ID
199
278
  * @param {string} kbId - Knowledge base ID
200
279
  * @param {Array<string>} fileIds - file_id values to delete (e.g., ['corpus:abc...', 'local:def...'])
201
- * @returns {Promise<{deleted: number, deleted_by_file_id: number}>}
280
+ * @returns {Promise<{deleted: number, deleted_by_file_id: number}>} Each field is the
281
+ * store's own count, or UNREPORTED_COUNT if the store reported that field.
282
+ * The two are INDEPENDENT — one may be reported and the other not. Neither
283
+ * is validFileIds.length. Test with isReportedCount(); never print unguarded.
202
284
  */
203
285
  export async function deleteStaleChunksByFileId(apiClient, communityId, appId, kbId, fileIds) {
204
286
  if (!Array.isArray(fileIds) || fileIds.length === 0) {
@@ -220,8 +302,8 @@ export async function deleteStaleChunksByFileId(apiClient, communityId, appId, k
220
302
 
221
303
  const data = result?.message || result;
222
304
  return {
223
- deleted: data.deleted_count ?? validFileIds.length,
224
- deleted_by_file_id: data.deleted_by_file_id ?? validFileIds.length
305
+ deleted: reportedCount(data?.deleted_count),
306
+ deleted_by_file_id: reportedCount(data?.deleted_by_file_id)
225
307
  };
226
308
  }
227
309
 
@@ -253,7 +335,8 @@ export async function deleteStaleChunksByFileId(apiClient, communityId, appId, k
253
335
  * @param {string} kbId - Knowledge base ID
254
336
  * @returns {Promise<{ deleted: number, purged_scope: boolean }>}
255
337
  * `deleted` is the actual count of vectors purged (the batched path always
256
- * reports it); -1 only if a future primitive cannot report a count.
338
+ * reports it), or UNREPORTED_COUNT if the store reported no count.
339
+ * Test it with isReportedCount(); never print it unguarded.
257
340
  */
258
341
  export async function purgeKbScope(apiClient, communityId, appId, kbId) {
259
342
  const result = await apiClient.invoke('kb_delete_chunks', {
@@ -264,7 +347,7 @@ export async function purgeKbScope(apiClient, communityId, appId, kbId) {
264
347
  });
265
348
  const data = result?.message || result;
266
349
  return {
267
- deleted: typeof data?.deleted_count === 'number' ? data.deleted_count : -1,
350
+ deleted: reportedCount(data?.deleted_count),
268
351
  purged_scope: data?.purged_scope === true
269
352
  };
270
353
  }
@@ -286,10 +369,21 @@ export async function listRemoteFileIds(apiClient, appId, kbId) {
286
369
  kb_id: kbId
287
370
  });
288
371
  const data = result?.message || result;
372
+ // Same rule as getRemoteChunkMetadata: an empty array is the store REPORTING an
373
+ // empty namespace; an ABSENT array is the store not reporting. This list is the
374
+ // drift input (stale = remote_file_ids - local), so fabricating [] here would
375
+ // silently mean "nothing is stale" — the failure this module exists to prevent.
376
+ if (!Array.isArray(data?.file_ids)) {
377
+ throw new Error(
378
+ `Cannot enumerate remote file_ids for ${appId}/${kbId}: the store reported no ` +
379
+ `'file_ids' array. The remote file set is UNKNOWN and must NOT be treated as ` +
380
+ `empty — that would silently skip the stale purge.`
381
+ );
382
+ }
289
383
  return {
290
- file_ids: data.file_ids || [],
291
- unique_count: data.unique_count || 0,
292
- total_chunks: data.total_chunks || 0
384
+ file_ids: data.file_ids,
385
+ unique_count: reportedCount(data?.unique_count),
386
+ total_chunks: reportedCount(data?.total_chunks)
293
387
  };
294
388
  }
295
389
 
@@ -336,9 +430,9 @@ export async function syncKb(apiClient, config, options = {}) {
336
430
  if (onProgress) onProgress('Fetching existing chunks from Pinecone...');
337
431
  const remoteChunks = await getRemoteChunkMetadata(apiClient, communityId, appId, kbId);
338
432
 
339
- const remoteCount = Array.isArray(remoteChunks)
340
- ? remoteChunks.length
341
- : 0;
433
+ // getRemoteChunkMetadata returns an array or throws — it never reports an
434
+ // unknown remote set as an empty one, so there is no non-array case to default.
435
+ const remoteCount = remoteChunks.length;
342
436
 
343
437
  if (verbose) {
344
438
  console.log(` Local chunks: ${localChunks.length}`);
@@ -361,7 +455,10 @@ export async function syncKb(apiClient, config, options = {}) {
361
455
  deleted = deleteResult.deleted;
362
456
  if (verbose) console.log(` Deleted ${deleted} stale chunks`);
363
457
  } catch (error) {
364
- if (verbose) console.log(` Delete failed: ${error.message}`);
458
+ // The delete ERRORED: how many were removed is unknown. Leaving `deleted` at 0
459
+ // would report "nothing was deleted" — a claim about the store we cannot make.
460
+ deleted = UNREPORTED_COUNT;
461
+ console.warn(` Delete failed (deleted count unknown): ${error.message}`);
365
462
  }
366
463
  }
367
464
 
@@ -385,12 +482,11 @@ export async function syncKb(apiClient, config, options = {}) {
385
482
  let deletedByFileId = 0;
386
483
  try {
387
484
  const localFileIds = new Set(localChunks.map(c => c.file_id).filter(Boolean));
388
- const remoteFileIdsResp = await apiClient.invoke('kb_list_file_ids', {
389
- app_id: appId,
390
- kb_id: kbId
391
- });
392
- const remoteData = remoteFileIdsResp?.message || remoteFileIdsResp;
393
- const remoteFileIds = remoteData?.file_ids || [];
485
+ // Consume the module's own enumeration owner rather than re-invoking
486
+ // kb_list_file_ids and hand-unwrapping its payload: two derivations of one
487
+ // fact drift apart silently, and this copy would keep the `|| []` defect
488
+ // that the owner no longer has.
489
+ const { file_ids: remoteFileIds } = await listRemoteFileIds(apiClient, appId, kbId);
394
490
  const staleFileIds = remoteFileIds.filter(fid => !localFileIds.has(fid));
395
491
  if (staleFileIds.length > 0) {
396
492
  if (onProgress) onProgress(`Purging ${staleFileIds.length} stale file_id(s)...`);
@@ -398,14 +494,18 @@ export async function syncKb(apiClient, config, options = {}) {
398
494
  deletedByFileId = purgeResult.deleted;
399
495
  }
400
496
  } catch (purgeErr) {
401
- // Non-fatal: surface via verbose log. The chunk_id-based delete above is the primary
402
- // path; this is a safety net. We do NOT silently swallow — caller sees a warning.
403
- if (verbose) console.log(` File-id purge skipped: ${purgeErr.message}`);
497
+ // Non-fatal: the chunk_id-based delete above is the primary path and this is a
498
+ // safety net. We do NOT silently swallow — the caller sees a warning. That claim
499
+ // was previously false: the warning sat behind `if (verbose)`, so a default run
500
+ // saw nothing. It is unconditional now, which is what makes the sentence true.
501
+ console.warn(` File-id purge skipped (stale chunks may remain): ${purgeErr.message}`);
404
502
  }
405
503
 
406
504
  return {
407
505
  synced,
408
- deleted: deleted + deletedByFileId,
506
+ // Unknown-ness survives the sum: if either leg could not be reported, their
507
+ // total is not a number we can state. (-1 + 5 would silently read as 4.)
508
+ deleted: accumulateReportedCount(deleted, deletedByFileId),
409
509
  deleted_chunk_ids: deleted,
410
510
  deleted_by_file_id: deletedByFileId,
411
511
  unchanged
@@ -433,7 +533,8 @@ export async function getSyncStatus(apiClient, config) {
433
533
 
434
534
  // Get remote chunk metadata
435
535
  const remoteChunks = await getRemoteChunkMetadata(apiClient, communityId, appId, kbId);
436
- const remoteCount = Array.isArray(remoteChunks) ? remoteChunks.length : 0;
536
+ // Array-or-throw, as above: no fabricated zero for an unknown remote set.
537
+ const remoteCount = remoteChunks.length;
437
538
 
438
539
  // Compute delta using content_hash
439
540
  const { toUpsert, toDelete, unchanged } = computeChunkDelta(localChunks, remoteChunks);
@@ -0,0 +1,158 @@
1
+ /**
2
+ * ONE OWNER for the question "may I prompt?".
3
+ *
4
+ * Every prompt site in this CLI consumes this module. No site derives the answer itself, and
5
+ * there is no flag, env var or option that restores prompting when the environment cannot
6
+ * support it: the guard is a property of the ENVIRONMENT, not of the invocation. A flag would
7
+ * be a guard the caller must remember, and forgetting it reproduces the exact hang this module
8
+ * exists to remove.
9
+ *
10
+ * The rule is enforced by ENCAPSULATION, not by detection: you cannot obtain a prompt handle
11
+ * without passing the gate, because createPromptSession() is the only way to get one and it
12
+ * calls requireInteractive() before it constructs anything. A comment asking authors to check
13
+ * is not a guard — an author who does not read it ships the hang.
14
+ *
15
+ * Reference behaviour is gcloud: it refuses non-interactively rather than hanging or silently
16
+ * succeeding. Both failure modes were measured in this CLI before this module existed:
17
+ * - stdin an open pipe or a FIFO whose write end is held: readline waits forever. SIGKILL.
18
+ * - stdin /dev/null: readline closes, the question callback NEVER fires, the event loop
19
+ * drains and node exits 0 having written NOTHING. That is worse than the hang, because it
20
+ * is indistinguishable from success.
21
+ */
22
+
23
+ import readline from 'readline';
24
+ import chalk from 'chalk';
25
+
26
+ /**
27
+ * The single derivation of "is there a human on the other end of stdin".
28
+ *
29
+ * stdin is the decisive stream: a prompt must READ. stdout being redirected does not stop a
30
+ * terminal user from answering, so it is deliberately not consulted.
31
+ *
32
+ * @returns {boolean}
33
+ */
34
+ export function stdinIsInteractive() {
35
+ return process.stdin.isTTY === true;
36
+ }
37
+
38
+ /**
39
+ * Thrown when a command needs to prompt and cannot. Carries the refusal text already formatted.
40
+ * Command actions in bin/descix.js catch Error, print `error.message` and exit 1, so throwing
41
+ * this produces a LOUD, NON-ZERO refusal — never a hang, never a silent exit 0.
42
+ */
43
+ export class NonInteractiveError extends Error {
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = 'NonInteractiveError';
47
+ this.isNonInteractive = true;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Build the refusal text. Names what was being asked and how to get the job done without a
53
+ * prompt. It NEVER prescribes a destructive remedy: where the non-interactive form overwrites
54
+ * or deletes something, the caller is told so explicitly and the non-destructive option leads.
55
+ *
56
+ * @param {Object} o
57
+ * @param {string} o.what - the command, e.g. 'descix init'
58
+ * @param {string} [o.question] - the question that cannot be asked
59
+ * @param {string[]} [o.nonInteractiveForm] - concrete lines the caller can run instead
60
+ */
61
+ function refusalText({ what, question, nonInteractiveForm, destructive }) {
62
+ // NO leading blank line and NO ❌ marker: command actions print `❌ ${error.message}`
63
+ // themselves. Emitting one here produced a doubled marker — the message OWNS the sentence,
64
+ // the caller OWNS the decoration.
65
+ const lines = [];
66
+ lines.push(`${what} cannot prompt during non-interactive execution.`);
67
+ lines.push(chalk.gray(' stdin is not a terminal, so there is no one to answer.'));
68
+ if (question) {
69
+ lines.push('');
70
+ lines.push(chalk.gray(` It needed to ask: ${question}`));
71
+ }
72
+ lines.push('');
73
+ if (destructive) {
74
+ // The confirmation being refused guards an IRREVERSIBLE act. The safe reading LEADS, and we
75
+ // never hand back a copy-pasteable destructive command as if it were the obvious next step.
76
+ lines.push(chalk.yellow(' This was a confirmation for an IRREVERSIBLE operation.'));
77
+ lines.push(chalk.gray(' Nothing has been changed.'));
78
+ lines.push('');
79
+ }
80
+ if (nonInteractiveForm && nonInteractiveForm.length > 0) {
81
+ lines.push(chalk.white(' Non-interactive form:'));
82
+ for (const line of nonInteractiveForm) lines.push(` ${line}`);
83
+ } else {
84
+ lines.push(chalk.white(` ${what} has no non-interactive form. Run it in a terminal.`));
85
+ }
86
+ lines.push('');
87
+ return lines.join('\n');
88
+ }
89
+
90
+ /**
91
+ * The gate. Call this before ANY prompt — readline, inquirer, or anything else.
92
+ * Returns silently when a human can answer; throws a loud NonInteractiveError when not.
93
+ *
94
+ * @param {Object} o - see refusalText
95
+ * @throws {NonInteractiveError}
96
+ */
97
+ export function requireInteractive({ what, question, nonInteractiveForm, destructive } = {}) {
98
+ if (stdinIsInteractive()) return;
99
+ throw new NonInteractiveError(refusalText({ what, question, nonInteractiveForm, destructive }));
100
+ }
101
+
102
+ /**
103
+ * The ONLY way to obtain a prompt handle in this CLI.
104
+ *
105
+ * Gated at construction, so a site physically cannot reach readline without the check. Each
106
+ * ask() also re-states the question in the refusal if the session is used later, and every
107
+ * ask() rejects rather than hanging if stdin stops being a terminal mid-session.
108
+ *
109
+ * @param {Object} o
110
+ * @param {string} o.what - the command, e.g. 'descix init'
111
+ * @param {string[]} [o.nonInteractiveForm]
112
+ * @param {boolean} [o.terminal] - readline terminal option (airdrop needs true to mask input)
113
+ * @returns {{ask: Function, askYesNo: Function, close: Function}}
114
+ */
115
+ export function createPromptSession({ what, nonInteractiveForm, terminal, destructive } = {}) {
116
+ requireInteractive({ what, nonInteractiveForm, destructive });
117
+
118
+ const rl = readline.createInterface({
119
+ input: process.stdin,
120
+ output: process.stdout,
121
+ ...(terminal === undefined ? {} : { terminal })
122
+ });
123
+
124
+ /** Ask for free text, with an optional default. */
125
+ const ask = (question, defaultValue = '') => {
126
+ requireInteractive({ what, question, nonInteractiveForm, destructive });
127
+ const suffix = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `;
128
+ return new Promise((resolve) => {
129
+ rl.question(suffix, (answer) => resolve(answer.trim() || defaultValue));
130
+ });
131
+ };
132
+
133
+ /** Ask a yes/no question with an explicit default. */
134
+ const askYesNo = (question, defaultYes = true) => {
135
+ requireInteractive({ what, question, nonInteractiveForm, destructive });
136
+ const suffix = defaultYes ? '(Y/n)' : '(y/N)';
137
+ return new Promise((resolve) => {
138
+ rl.question(`${question} ${suffix}: `, (answer) => {
139
+ const trimmed = answer.trim().toLowerCase();
140
+ if (trimmed === '') return resolve(defaultYes);
141
+ resolve(trimmed === 'y' || trimmed === 'yes');
142
+ });
143
+ });
144
+ };
145
+
146
+ /**
147
+ * Ask with the prompt text EXACTLY as given — no added suffix. For sites that already spell
148
+ * their own "[y/N] " and must keep their wording verbatim.
149
+ */
150
+ const askRaw = (question) => {
151
+ requireInteractive({ what, question, nonInteractiveForm, destructive });
152
+ return new Promise((resolve) => rl.question(question, resolve));
153
+ };
154
+
155
+ return { ask, askYesNo, askRaw, close: () => rl.close() };
156
+ }
157
+
158
+ export default { stdinIsInteractive, requireInteractive, createPromptSession, NonInteractiveError };
package/lib/origin.js CHANGED
@@ -79,15 +79,16 @@ export const ORIGIN_SOURCE_LABELS = {
79
79
  flagEnv: '--env flag',
80
80
  envVar: 'DESCIX_API_URL environment variable',
81
81
  workspaceEnvApiUrl: '.descix/workspace.json env.apiUrl',
82
- legacyApiUrl: '.descix/workspace.json apiUrl (legacy)',
83
82
  globalApiUrl: '~/.descix/config.json api_url',
84
83
  };
85
84
 
86
85
  /**
87
86
  * The resolution order, most explicit first. The ORDER is the contract; this array is the only
88
87
  * place it is written down. Labels come from ORIGIN_SOURCE_LABELS, never from a second literal.
88
+ * The workspace names its origin at `env.apiUrl` and nowhere else — a top-level `apiUrl` is a
89
+ * retired v1 key that `WorkspaceConfig.load()` refuses by name.
89
90
  */
90
- const PRECEDENCE = ['envVar', 'workspaceEnvApiUrl', 'legacyApiUrl', 'globalApiUrl'];
91
+ const PRECEDENCE = ['envVar', 'workspaceEnvApiUrl', 'globalApiUrl'];
91
92
 
92
93
  /**
93
94
  * What this invocation's FLAGS named, if anything: `{ origin, sourceKey }`.
@@ -181,7 +182,6 @@ function isUsableOrigin(value) {
181
182
  * @param {object} sources
182
183
  * @param {string|null} [sources.envVar] process.env.DESCIX_API_URL
183
184
  * @param {string|null} [sources.workspaceEnvApiUrl] workspace.json env.apiUrl
184
- * @param {string|null} [sources.legacyApiUrl] workspace.json top-level apiUrl
185
185
  * @param {string|null} [sources.globalApiUrl] ~/.descix/config.json api_url
186
186
  * @returns {{ origin: string, source: string, isDefault: boolean }} the origin AND which source
187
187
  * supplied it — the source is returned because "where did this come from" is the
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The SDK's agent assets (instructions, guides, reference) ship inside this package at
3
+ * agent-assets/. `pullSdkAssets` copies them into a workspace at .descix/sdk-assets/ so a local AI
4
+ * agent can read them. A missing source is a broken install and throws; it is never skipped.
5
+ */
6
+ import * as fs from 'fs/promises';
7
+ import * as path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ /** agent-assets/ at the package root, one level above lib/. */
11
+ export const SDK_ASSETS_SOURCE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'agent-assets');
12
+
13
+ /**
14
+ * Copy the package's agent-assets/ to <workspaceRoot>/.descix/sdk-assets/.
15
+ * @param {string} workspaceRoot
16
+ * @returns {Promise<string>} the target directory
17
+ * @throws when the package's agent-assets/ is absent or the copy fails
18
+ */
19
+ export async function pullSdkAssets(workspaceRoot) {
20
+ const target = path.join(workspaceRoot, '.descix', 'sdk-assets');
21
+ try {
22
+ await fs.access(SDK_ASSETS_SOURCE);
23
+ } catch {
24
+ throw new Error(`SDK agent assets are missing from this install (${SDK_ASSETS_SOURCE}). Reinstall @descix/cli.`);
25
+ }
26
+ await fs.cp(SDK_ASSETS_SOURCE, target, { recursive: true });
27
+ return target;
28
+ }