@descix/cli 1.0.5 → 1.0.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.
Files changed (46) hide show
  1. package/README.md +130 -34
  2. package/agent-assets/guides/dynamic-site-pattern.md +10 -14
  3. package/agent-assets/guides/knowledge-only-pattern.md +11 -15
  4. package/agent-assets/guides/microservice-pattern.md +12 -14
  5. package/agent-assets/guides/static-site-pattern.md +12 -15
  6. package/agent-assets/instructions/onboarding-flow.md +4 -4
  7. package/agent-assets/reference/app-creation.md +36 -101
  8. package/agent-assets/reference/architecture.md +24 -27
  9. package/agent-assets/reference/local-dev.md +0 -4
  10. package/agent-assets/reference/mcp-integration.md +14 -22
  11. package/agent-assets/reference/workspace-config.md +97 -340
  12. package/agent-assets/rules/descix_mcp.template.mdc +1 -1
  13. package/bin/descix.js +159 -98
  14. package/lib/api-client.js +30 -22
  15. package/lib/commands/airdrop.js +10 -0
  16. package/lib/commands/auth.js +33 -3
  17. package/lib/commands/briefer/index.js +8 -5
  18. package/lib/commands/config.js +13 -31
  19. package/lib/commands/corpus.js +161 -54
  20. package/lib/commands/health.js +116 -21
  21. package/lib/commands/init.js +25 -48
  22. package/lib/commands/kb.js +14 -4
  23. package/lib/commands/update.js +0 -1
  24. package/lib/core/Chunker.js +1 -1
  25. package/lib/core/CorpusWalker.js +238 -47
  26. package/lib/core/Hydrator.js +88 -11
  27. package/lib/core/ManifestLoader.js +68 -0
  28. package/lib/core/Syncer.js +147 -46
  29. package/lib/interactive.js +158 -0
  30. package/lib/origin.js +3 -3
  31. package/lib/wallet-file.js +36 -37
  32. package/lib/wizard/setup.js +104 -22
  33. package/lib/workspace-config.js +325 -167
  34. package/lib/workspace-identity.js +0 -1
  35. package/package.json +3 -3
  36. package/templates/agent-claude.md +0 -3
  37. package/templates/agent-cline.md +0 -3
  38. package/templates/agent-copilot.md +0 -3
  39. package/templates/agent-cursor.md +0 -3
  40. package/templates/drive/app/kb/General/README.md +1 -1
  41. package/templates/drive_templates/agent_app_template/kb/General/README.md +1 -1
  42. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +3 -3
  43. package/templates/scaffolds/microservice/defaults-config.json +3 -1
  44. package/templates/scaffolds/microservice/manifest.json +9 -5
  45. package/templates/scaffolds/microservice/package.json +1 -1
  46. package/templates/scaffolds/microservice/services/commandHandlers/sampleCommands.js +2 -2
@@ -7,16 +7,19 @@
7
7
  * Architecture:
8
8
  * - Files stay in place — no copying or staging
9
9
  * - Git blob SHAs provide versioning (same content = same SHA)
10
- * - Untracked/modified files use `git hash-object` for working tree version
10
+ * - GIT IS THE ONLY SOURCE OF TRUTH FOR WHAT IS A CORPUS SOURCE: a file must be TRACKED
11
+ * AT THE MANIFEST'S REF or it is refused by name. There is no working-tree fallback.
11
12
  * - Chunk IDs are content-addressed: {app_id}:{kb_name}:{blob_sha}:{chunk_idx}
12
13
  */
13
14
 
14
15
  import * as fs from 'fs/promises';
16
+ import * as fsSync from 'fs';
15
17
  import * as path from 'path';
16
18
  import { execSync } from 'child_process';
17
19
  import { minimatch } from 'minimatch';
18
- // The source-ref default is owned by ManifestLoader — consume it, never re-default.
19
- import { DEFAULT_SOURCE_REF } from './ManifestLoader.js';
20
+ // The source-ref default and "which repository owns this file" are both owned by
21
+ // ManifestLoader — consume them, never re-default and never re-derive.
22
+ import { DEFAULT_SOURCE_REF, resolveOwningRepo } from './ManifestLoader.js';
20
23
 
21
24
  /**
22
25
  * File extensions that are processable for RAG chunking.
@@ -60,42 +63,132 @@ function isIgnored(relativePath, syncignore) {
60
63
  return false;
61
64
  }
62
65
 
66
+ const SHA40 = /^[0-9a-f]{40}$/i;
67
+
63
68
  /**
64
- * Compute the git blob SHA for a file.
65
- * First tries `git rev-parse {ref}:{workspace-relative-path}` for tracked files.
66
- * Falls back to `git hash-object {file}` for untracked/modified files.
69
+ * Explain WHICH WAY a path failed the "tracked at ref" predicate. Purely diagnostic —
70
+ * it never decides anything, so there is still exactly one decision site. Each branch
71
+ * ends in an instruction the operator can act on without further investigation.
67
72
  *
68
- * @param {string} absolutePath - Absolute path to the file
69
- * @param {string} workspaceRelativePath - Path relative to workspace root
70
- * @param {string} ref - Git ref (branch/tag)
71
- * @param {string} workspaceRoot - Workspace root directory
72
- * @returns {string} Git blob SHA (40 hex chars)
73
+ * @returns {string} the reason string embedded in the aggregated refusal
73
74
  */
74
- function computeBlobSha(absolutePath, workspaceRelativePath, ref, workspaceRoot) {
75
+ function diagnoseUntracked(repoRelativePath, ref, repoRoot, repoLabel) {
76
+ // Every diagnostic below runs IN THE OWNING REPOSITORY. Running them at the ambient
77
+ // workspace root is what produced the maximally wrong advice this gate was blocked for
78
+ // ("UNTRACKED — commit it on main" about content that was already committed, and
79
+ // "IGNORED BY GIT" about a sibling repo the SUPERREPO ignores but which tracks it fine).
80
+ const where = `in ${repoLabel}`;
81
+
82
+ // (c) Present at HEAD but absent at THIS ref — checked first, because .gitignore
83
+ // patterns do not apply to tracked files and would misdiagnose this case.
75
84
  try {
76
- // Try to get the blob SHA from the git tree at the specified ref
77
- const sha = execSync(
78
- `git rev-parse "${ref}:${workspaceRelativePath}"`,
79
- { cwd: workspaceRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
85
+ execSync(`git rev-parse "HEAD:${repoRelativePath}"`,
86
+ { cwd: repoRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
87
+ return `present at HEAD but ABSENT AT REF "${ref}" ${where} — the manifest pins a ref ` +
88
+ `that predates this file; commit it on "${ref}" or point the source at the right ref`;
89
+ } catch { /* not at HEAD either — fall through */ }
90
+
91
+ // (a) Deliberately excluded from the repository. The measured defect class.
92
+ try {
93
+ const hit = execSync(`git check-ignore -v "${repoRelativePath}"`,
94
+ { cwd: repoRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
95
+ ).trim().split('\n')[0];
96
+ if (hit) {
97
+ return `IGNORED BY GIT (${hit.split('\t')[0]}) ${where} and not tracked at ref ` +
98
+ `"${ref}" — content the repository deliberately excludes must never become a ` +
99
+ `citable KB source; remove this source from the manifest`;
100
+ }
101
+ } catch { /* not ignored — fall through */ }
102
+
103
+ // (b) Simply never committed.
104
+ return `UNTRACKED at ref "${ref}" ${where} — the file exists on disk but that repository ` +
105
+ `does not track it; commit it on "${ref}", or remove this source from the manifest`;
106
+ }
107
+
108
+ /**
109
+ * THE SOURCE GATE — the single owner of "is this file a legitimate corpus source".
110
+ *
111
+ * A source is legitimate iff GIT TRACKS IT AT THE MANIFEST'S REF. That one predicate is
112
+ * the whole rule, and it is deliberately not split by WHY a path fails it: gitignored
113
+ * scratch, never-committed, and absent-at-an-older-ref are all "not tracked at ref".
114
+ * Splitting them would re-derive "what is a corpus source" in a second place, and the
115
+ * copies would drift.
116
+ *
117
+ * THE WORKING TREE IS NEVER CONSULTED. Reading a sha from disk when the ref lookup failed
118
+ * is the measured defect: it served content git was explicitly told to exclude as a
119
+ * citable KB source (corpus:04406066... = `.claude/agent-memory/evp-descix/qa-pairs.jsonl`,
120
+ * excluded by .gitignore, chunked and served anyway). That path is deleted, not fenced
121
+ * behind a flag, and there is nothing that restores it — SUPER-DRY, CEO-D-2026-07-26.
122
+ *
123
+ * THE REF IS RESOLVED IN THE REPOSITORY THAT OWNS THE FILE, never in an ambient root. A
124
+ * workspace spans several repositories (superrepo, submodules, ignored sibling checkouts)
125
+ * and a ref names a branch in exactly one of them. Asking the superrepo about a file inside
126
+ * a submodule asks the wrong repository a question it cannot answer — it holds only a
127
+ * gitlink commit — and the honest-looking "not tracked" that comes back is FALSE. That was
128
+ * measured on this very branch: 596 committed files refused, the two largest live KBs
129
+ * stopped, with advice telling the operator to commit content already committed.
130
+ * "Which repository owns this file" has ONE owner: ManifestLoader::resolveOwningRepo.
131
+ *
132
+ * Returns a REASON rather than throwing so the caller can aggregate every offender into
133
+ * one refusal instead of failing on the first (mirroring CorpusDenyLint's report/assert
134
+ * split — the established refusal shape in this codebase).
135
+ *
136
+ * @param {string} absoluteFilePath - Absolute path to the file under test
137
+ * @param {string} ref - Git ref (branch/tag) the manifest source named
138
+ * @param {string} [workspaceRoot] - Only to label paths in messages; NEVER a resolution root
139
+ * @returns {{sha: string|null, reason: string|null, repoRoot: string|null}}
140
+ */
141
+ export function resolveTrackedBlobSha(absoluteFilePath, ref, workspaceRoot = null) {
142
+ const owner = resolveOwningRepo(absoluteFilePath);
143
+ if (!owner) {
144
+ return {
145
+ sha: null, repoRoot: null,
146
+ reason: `NOT INSIDE ANY GIT REPOSITORY — no repository owns this path, so ref "${ref}" ` +
147
+ `cannot be resolved for it and nothing can vouch for its content; remove this ` +
148
+ `source from the manifest, or put the content in a repository and commit it`
149
+ };
150
+ }
151
+ const { repoRoot, repoRelativePath } = owner;
152
+ // How the owning repo is named in messages: relative to the workspace when we have one,
153
+ // so the operator reads "DeSciX/DeSciX_Cloud" rather than an absolute machine path.
154
+ // `repoRoot` is a REAL path (git resolves symlinks), so the workspace root must be one
155
+ // too or the subtraction escapes upward into `../../..`.
156
+ let workspaceReal = workspaceRoot;
157
+ try { if (workspaceRoot) workspaceReal = fsSync.realpathSync(workspaceRoot); } catch { /* keep as given */ }
158
+ const repoLabel = workspaceRoot ? (path.relative(workspaceReal, repoRoot) || '.') : repoRoot;
159
+
160
+ let sha;
161
+ try {
162
+ sha = execSync(
163
+ `git rev-parse "${ref}:${repoRelativePath}"`,
164
+ { cwd: repoRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
80
165
  ).trim();
81
- return sha;
82
166
  } catch {
83
- // File is untracked or path doesn't exist in git tree at that ref
84
- // Fall back to hashing the working tree version
85
- try {
86
- const sha = execSync(
87
- `git hash-object "${absolutePath}"`,
88
- { cwd: workspaceRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
89
- ).trim();
90
- return sha;
91
- } catch {
92
- // Last resort: return null — caller should skip this file
93
- return null;
94
- }
167
+ // ONE PREDICATE ("tracked at ref, in the owning repo"), FOUR DIAGNOSTICS. The decision
168
+ // has already been made above — everything below only explains WHICH WAY the path
169
+ // failed, so DRY holds and each population gets a self-curing instruction.
170
+ return { sha: null, repoRoot, reason: diagnoseUntracked(repoRelativePath, ref, repoRoot, repoLabel) };
171
+ }
172
+
173
+ if (!SHA40.test(sha)) {
174
+ return {
175
+ sha: null, repoRoot,
176
+ reason: `git rev-parse "${ref}:${repoRelativePath}" in ${repoLabel} returned "${sha}", which is not a blob sha`
177
+ };
95
178
  }
179
+ return { sha, repoRoot, reason: null };
96
180
  }
97
181
 
98
- const SHA40 = /^[0-9a-f]{40}$/i;
182
+ /**
183
+ * What this gate compares, catches, and does NOT read — printed with the verdict so a
184
+ * reader of a GREEN sees where the green stops, in the gate's own words.
185
+ */
186
+ export const SOURCE_GATE_BOUNDARY = Object.freeze({
187
+ compares: 'each walked file against `git rev-parse <manifest source ref>:<path relative to THE REPOSITORY THAT OWNS THE FILE>`, where the owner is the nearest enclosing git repository (ManifestLoader::resolveOwningRepo) — the superrepo, a submodule, or an ignored sibling checkout',
188
+ catches: 'any corpus source not tracked at its ref BY ITS OWN REPOSITORY — gitignored scratch, never-committed files, paths absent from an older ref, and paths inside no repository at all',
189
+ does_not_read: 'file CONTENT (see CorpusDenyLint for content/path deny-classes), Pinecone live state, and cross-repo `repo:` sources (refused separately by resolveSourceProvenance). It also does NOT check that a submodule\'s ref matches the commit the superrepo pins at its gitlink: a source is walked at the ref it names in the repository that owns it, so content committed on that branch but not yet pinned by the superrepo is accepted.',
190
+ runs_automatically: 'yes — inside walkCorpus, so every `descix kb corpus sync` passes through it; there is no flag that skips it'
191
+ });
99
192
 
100
193
  /**
101
194
  * Resolve ONE source's provenance: which repository, which ref, and the exact commit synced.
@@ -117,33 +210,62 @@ const SHA40 = /^[0-9a-f]{40}$/i;
117
210
  export async function resolveSourceProvenance(source, workspaceRoot) {
118
211
  const ref = source.ref || DEFAULT_SOURCE_REF;
119
212
  const repo = source.repo ?? null;
120
- // ONE derivation of "which repo this content belongs to": the explicit slug when the source is
121
- // cross-repo, else the leading path segment (the historical in-repo value, preserved verbatim so
122
- // existing chunk metadata does not churn).
213
+ // `source_repo` is a LEGACY DISPLAY LABEL that rides into chunk metadata — NOT the
214
+ // ownership decision. Ownership is decided in exactly one place (resolveOwningRepo);
215
+ // this leading-path-segment value is preserved verbatim because rewriting it would
216
+ // re-stamp every existing chunk in every live KB. Do not read it as "which repository
217
+ // owns this file"; for that, ask the owner.
123
218
  const source_repo = repo ?? (String(source.path || '').split('/')[0] || 'unknown');
124
219
 
125
220
  if (!repo) {
221
+ // Resolve the commit IN THE REPOSITORY THAT OWNS THE SOURCE — the same one owner the
222
+ // gate consults. Resolving it at the ambient workspace root would record the
223
+ // SUPERREPO's commit for content whose blobs came from a submodule or a sibling repo:
224
+ // two derivations of "where did this content come from" that disagree, which is the
225
+ // mirror-drift this file already refuses elsewhere. A provenance record that names the
226
+ // wrong repository's commit is a FALSE record, not an approximate one.
227
+ const owner = resolveOwningRepo(source.absolutePath);
228
+ if (!owner) {
229
+ throw new Error(
230
+ `Source "${source.path}" resolves to ${source.absolutePath}, which is inside NO git ` +
231
+ `repository. Refusing to record provenance — there is no repository whose ref "${ref}" ` +
232
+ `could describe this content.`
233
+ );
234
+ }
235
+ const provenanceRoot = owner.repoRoot;
126
236
  let sha;
127
237
  try {
128
238
  sha = execSync(`git rev-parse "${ref}^{commit}"`, {
129
- cwd: workspaceRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
239
+ cwd: provenanceRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
130
240
  }).trim();
131
241
  } catch {
132
242
  throw new Error(
133
- `Cannot resolve ref "${ref}" to a commit in ${workspaceRoot} for source "${source.path}". ` +
134
- `Refusing to record provenance — a sync-state commit of "unknown" is a FALSE record, not a ` +
135
- `missing one. Check that the ref exists locally.`
243
+ `Cannot resolve ref "${ref}" to a commit in ${provenanceRoot} (the repository that owns ` +
244
+ `source "${source.path}") . Refusing to record provenance — a sync-state commit of ` +
245
+ `"unknown" is a FALSE record, not a missing one. Check that the ref exists in THAT ` +
246
+ `repository, which is not necessarily the workspace root ${workspaceRoot}.`
136
247
  );
137
248
  }
138
249
  if (!SHA40.test(sha)) {
139
250
  throw new Error(`Ref "${ref}" resolved to "${sha}", which is not a commit sha (source "${source.path}").`);
140
251
  }
141
- return { repo: null, ref, resolved_commit_sha: sha, source_repo };
252
+ // `repo_root` MAKES THE RECORDED COMMIT SELF-DESCRIBING. Before this, every commit in
253
+ // sync-state was a workspace-root commit, so a reader could resolve it at the workspace
254
+ // root and be right by accident. Now a commit may belong to a submodule or a sibling
255
+ // repo, and the SAME 40 hex characters mean different things depending on which
256
+ // repository you ask. Writing the owning repo beside the commit is what keeps the store
257
+ // homogeneous: a reader resolves the commit WHERE THIS SAYS, and a record written before
258
+ // this field existed is missing it and can be detected rather than silently misread.
259
+ return {
260
+ repo: null, ref, resolved_commit_sha: sha, source_repo,
261
+ repo_root: path.relative(workspaceRoot, provenanceRoot) || '.',
262
+ };
142
263
  }
143
264
 
144
- // CROSS-REPO: resolve against the remote. Never a local read.
265
+ // CROSS-REPO: resolve against the remote. Never a local read. `repo_root` is null because
266
+ // the content is not in this workspace at all — `repo` already says where it lives.
145
267
  if (SHA40.test(ref)) {
146
- return { repo, ref, resolved_commit_sha: ref.toLowerCase(), source_repo };
268
+ return { repo, ref, resolved_commit_sha: ref.toLowerCase(), source_repo, repo_root: null };
147
269
  }
148
270
  const remoteUrl = `https://github.com/${repo}.git`;
149
271
  let out;
@@ -167,7 +289,7 @@ export async function resolveSourceProvenance(source, workspaceRoot) {
167
289
  `Repo "${repo}" has no ref "${ref}" (source "${source.path}"). REFUSING rather than guessing.`
168
290
  );
169
291
  }
170
- return { repo, ref, resolved_commit_sha: sha, source_repo };
292
+ return { repo, ref, resolved_commit_sha: sha, source_repo, repo_root: null };
171
293
  }
172
294
 
173
295
  /**
@@ -247,6 +369,9 @@ async function isFile(filePath) {
247
369
  export async function walkCorpus(manifest, workspaceRoot) {
248
370
  const files = [];
249
371
  const provenance = [];
372
+ // Every file the source gate refused, collected so ONE error can name them all. A
373
+ // first-throw would hide offenders 2..N and turn one fix into N sync attempts.
374
+ const refused = [];
250
375
  let commitSha = 'unknown';
251
376
 
252
377
  // Nested-source dedup (WS-EVP-DESCIX-KB-CLEANUP item 4): a manifest may list both a directory
@@ -276,6 +401,39 @@ export async function walkCorpus(manifest, workspaceRoot) {
276
401
  const { absolutePath, ref, tier, doc_type, syncignore } = source;
277
402
  const thisSourceRoot = path.resolve(absolutePath);
278
403
 
404
+ // An IN-REPO source whose path lies in NO repository joins the AGGREGATED refusal rather
405
+ // than throwing. Both outcomes refuse — but throwing would abort on the first offending
406
+ // source and hide every later one, turning one manifest fix into N sync attempts, which
407
+ // is the whole reason this walk aggregates. Measured: `EGPT/` (in no repo) aborted the
408
+ // EVP-EGPT walk before its other sources were ever examined.
409
+ //
410
+ // CROSS-REPO SOURCES ARE DELIBERATELY EXEMPT. Their `absolutePath` is a fiction — the
411
+ // content lives in another repository — so "is it in a repo here" is the wrong question
412
+ // and answering it would MASK the accurate refusal resolveSourceProvenance already owns.
413
+ // Measured: EVP-BEAST's four `repo: eabadir/unk` sources were being reported as "not
414
+ // inside any git repository" instead of "cross-repo content fetch is not implemented".
415
+ if (source.repo === undefined) {
416
+ const exists = await fs.access(absolutePath).then(() => true, () => false);
417
+ if (!exists) {
418
+ refused.push({
419
+ path: source.path, ref, source: source.path,
420
+ reason: `DOES NOT EXIST at ${absolutePath} — the manifest names a path that is not ` +
421
+ `on disk; correct the path or remove this source from the manifest`,
422
+ });
423
+ continue;
424
+ }
425
+ if (!resolveOwningRepo(absolutePath)) {
426
+ refused.push({
427
+ path: source.path, ref, source: source.path,
428
+ reason: `NOT INSIDE ANY GIT REPOSITORY (${absolutePath}) — no repository owns this ` +
429
+ `path, so ref "${ref}" cannot be resolved for it and nothing can vouch for ` +
430
+ `its content; remove this source from the manifest, or put the content in a ` +
431
+ `repository and commit it`,
432
+ });
433
+ continue;
434
+ }
435
+ }
436
+
279
437
  // Resolve this source's exact provenance BEFORE reading anything. A cross-repo source that
280
438
  // cannot be fetched REFUSES here, naming the repo — it is never silently skipped and never
281
439
  // falls back to reading an adjacent working tree.
@@ -318,13 +476,17 @@ export async function walkCorpus(manifest, workspaceRoot) {
318
476
  if (ownerSourceRoot(filePath) !== thisSourceRoot) {
319
477
  continue;
320
478
  }
321
- // Compute workspace-relative path for git operations
479
+ // Workspace-relative path is the DISPLAY/metadata spelling only. Git resolution uses
480
+ // the owning repository — see resolveTrackedBlobSha.
322
481
  const workspaceRelativePath = path.relative(workspaceRoot, filePath);
323
482
 
324
- // Compute blob SHA
325
- const blobSha = computeBlobSha(filePath, workspaceRelativePath, ref, workspaceRoot);
483
+ // THE SOURCE GATE. A file the OWNING repository does not track at this source's ref
484
+ // is REFUSED by name — never silently chunked from the working tree, and never
485
+ // silently skipped either (a skip on a clean worktree would PURGE its live chunks).
486
+ const { sha: blobSha, reason } = resolveTrackedBlobSha(filePath, ref, workspaceRoot);
326
487
  if (!blobSha) {
327
- continue; // Skip files that can't be hashed
488
+ refused.push({ path: workspaceRelativePath, ref, reason, source: source.path });
489
+ continue;
328
490
  }
329
491
 
330
492
  // "Which repo does this content belong to" has ONE owner: resolveSourceProvenance.
@@ -351,7 +513,36 @@ export async function walkCorpus(manifest, workspaceRoot) {
351
513
  }
352
514
  }
353
515
 
354
- return { files, commitSha, provenance };
516
+ // ONE aggregated refusal naming every offender, its ref and its reason. Raised AFTER
517
+ // the full walk so the operator fixes the manifest once rather than N times, and BEFORE
518
+ // any caller can chunk, upsert or purge — the refused files are neither served nor
519
+ // treated as deletions.
520
+ if (refused.length > 0) {
521
+ // The error must be fixable IN ONE ACT from its text alone: it names the manifest
522
+ // FILE to edit and the offending ENTRY within it, not merely the resolved path.
523
+ // `_manifestPath` is owned by ManifestLoader — consume it, never re-derive it.
524
+ const manifestFile = manifest._manifestPath || `(manifest for kb "${manifest.kb_name}")`;
525
+ const lines = refused.map(
526
+ r => ` - ${r.path}\n ref: "${r.ref}" manifest entry: sources[] path "${r.source}"\n ${r.reason}`
527
+ );
528
+ const err = new Error(
529
+ `Corpus source gate FAILED for KB "${manifest.kb_name}": ${refused.length} file(s) are ` +
530
+ `not tracked by git at their manifest ref — refusing to sync.\n` +
531
+ `Manifest to edit: ${manifestFile}\n${lines.join('\n')}\n` +
532
+ `Every corpus source must be committed at its ref. There is no working-tree fallback: ` +
533
+ `content git excludes must never be served as a citable KB source.`
534
+ );
535
+ // The offenders as DATA, so a reporting tool consumes the gate's own verdict instead of
536
+ // re-implementing the predicate or scraping this prose. The message stays the human
537
+ // surface; `refused` is the machine one, and they cannot disagree because there is one
538
+ // decision behind both.
539
+ err.refused = refused;
540
+ err.kb_name = manifest.kb_name;
541
+ err.manifestPath = manifestFile;
542
+ throw err;
543
+ }
544
+
545
+ return { files, commitSha, provenance, gateBoundary: SOURCE_GATE_BOUNDARY };
355
546
  }
356
547
 
357
- export default { walkCorpus, resolveSourceProvenance };
548
+ export default { walkCorpus, resolveSourceProvenance, resolveTrackedBlobSha, SOURCE_GATE_BOUNDARY };
@@ -726,11 +726,15 @@ export async function checkStagingFiles(stagingDir) {
726
726
  *
727
727
  * @param {string} scaffoldType - 'site' or 'microservice'
728
728
  * @param {string} appPath - Path to the app directory
729
- * @param {Object} options - { verbose, force }
729
+ * @param {Object} options - { verbose, force, substitute }
730
+ * @param {{appId: string, communityId: string, appName?: string}} [options.substitute] - the
731
+ * caller's real identity. Supply it for any scaffold a developer will actually use: without it
732
+ * the copy is verbatim and every {{TOKEN}} ships unresolved. Omitting it is only appropriate
733
+ * when materializing the raw template itself.
730
734
  * @returns {Promise<{copied: number}>}
731
735
  */
732
736
  export async function copyScaffold(scaffoldType, appPath, options = {}) {
733
- const { verbose = false, force = false } = options;
737
+ const { verbose = false, force = false, substitute = null } = options;
734
738
 
735
739
  const scaffoldDir = SCAFFOLD_DIRS[scaffoldType];
736
740
  if (!scaffoldDir) {
@@ -767,8 +771,36 @@ export async function copyScaffold(scaffoldType, appPath, options = {}) {
767
771
 
768
772
  // Copy scaffold recursively
769
773
  const stats = { copied: 0 };
770
- await copyDir(scaffoldDir, targetDir, stats, verbose);
771
-
774
+ let values = null;
775
+ if (substitute) {
776
+ if (!substitute.appId || !substitute.communityId) {
777
+ throw new Error(
778
+ 'copyScaffold: substitute requires both appId and communityId. Refusing to resolve a ' +
779
+ 'scaffold against a partial identity — a half-substituted scaffold is harder to spot ' +
780
+ 'than an unsubstituted one.'
781
+ );
782
+ }
783
+ values = {
784
+ APP_ID: substitute.appId,
785
+ COMMUNITY_ID: substitute.communityId,
786
+ APP_NAME: substitute.appName || substitute.appId,
787
+ };
788
+ }
789
+
790
+ const unresolved = [];
791
+ await copyDir(scaffoldDir, targetDir, stats, verbose, values, unresolved);
792
+
793
+ // FAIL LOUD. A placeholder that survives the copy reaches the developer as literal braces, and
794
+ // that is exactly the class of defect this substitution exists to end — so it must never be a
795
+ // warning. Name every offender so a new token added to a template cannot ship unnoticed.
796
+ if (unresolved.length > 0) {
797
+ throw new Error(
798
+ `Scaffold "${scaffoldType}" has ${unresolved.length} unresolved placeholder(s): ` +
799
+ `${unresolved.join(', ')}. Every {{TOKEN}} in a scaffold needs a value in copyScaffold's ` +
800
+ `substitute map (see templates/scaffolds/README.md for the documented set).`
801
+ );
802
+ }
803
+
772
804
  return stats;
773
805
  }
774
806
 
@@ -785,21 +817,66 @@ async function hasContentInDir(dirPath) {
785
817
  }
786
818
 
787
819
  /**
788
- * Recursively copy a directory
820
+ * THE ONE OWNER of scaffold placeholder substitution.
821
+ *
822
+ * templates/scaffolds/README.md has documented this contract all along — "Scaffolds use these
823
+ * placeholders (replaced during copy): {{APP_NAME}}, {{APP_ID}}, {{COMMUNITY_ID}}" — but nothing
824
+ * ever performed the replacement. Both scaffolds shipped their placeholders and their sample
825
+ * literals verbatim, so a developer's site was titled `{{APP_NAME}}` and the sample handler
826
+ * queried a community that is not theirs.
827
+ *
828
+ * SUBSTITUTION, NOT A SECOND LITERAL. The fix resolves each token to the caller's real values;
829
+ * it does not swap one hardcoded community for another. That is what lets the same scaffold serve
830
+ * every community without the CLI knowing any of their names.
831
+ *
832
+ * @param {string} text - file contents
833
+ * @param {Record<string,string>} values - token name (without braces) -> replacement
834
+ * @returns {string}
789
835
  */
790
- async function copyDir(src, dest, stats, verbose) {
836
+ function applyScaffoldTokens(text, values) {
837
+ return text.replace(/\{\{([A-Z_]+)\}\}/g, (whole, token) =>
838
+ Object.prototype.hasOwnProperty.call(values, token) ? values[token] : whole
839
+ );
840
+ }
841
+
842
+ /** A NUL byte is the cheap, dependency-free binary tell. Never rewrite bytes we cannot read. */
843
+ function looksBinary(buf) {
844
+ return buf.includes(0);
845
+ }
846
+
847
+ /**
848
+ * Recursively copy a directory, optionally substituting scaffold tokens on the way.
849
+ *
850
+ * @param {Record<string,string>|null} values - when null, the copy is byte-for-byte verbatim
851
+ * @param {string[]} unresolved - collects `file:{{TOKEN}}` for tokens no value was supplied for
852
+ */
853
+ async function copyDir(src, dest, stats, verbose, values = null, unresolved = [], relBase = '') {
791
854
  await fs.mkdir(dest, { recursive: true });
792
-
855
+
793
856
  const entries = await fs.readdir(src, { withFileTypes: true });
794
-
857
+
795
858
  for (const entry of entries) {
796
859
  const srcPath = path.join(src, entry.name);
797
860
  const destPath = path.join(dest, entry.name);
798
-
861
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
862
+
799
863
  if (entry.isDirectory()) {
800
- await copyDir(srcPath, destPath, stats, verbose);
864
+ await copyDir(srcPath, destPath, stats, verbose, values, unresolved, rel);
801
865
  } else {
802
- await fs.copyFile(srcPath, destPath);
866
+ if (values) {
867
+ const buf = await fs.readFile(srcPath);
868
+ if (looksBinary(buf)) {
869
+ await fs.copyFile(srcPath, destPath);
870
+ } else {
871
+ const out = applyScaffoldTokens(buf.toString('utf8'), values);
872
+ // Anything still wearing braces had no value supplied. Collect it rather than shipping
873
+ // it: an unresolved placeholder reaching a developer is the defect this exists to end.
874
+ for (const m of out.matchAll(/\{\{([A-Z_]+)\}\}/g)) unresolved.push(`${rel}:{{${m[1]}}}`);
875
+ await fs.writeFile(destPath, out);
876
+ }
877
+ } else {
878
+ await fs.copyFile(srcPath, destPath);
879
+ }
803
880
  stats.copied++;
804
881
  if (verbose) console.log(` Copied: ${entry.name}`);
805
882
  }
@@ -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
  *