tadforce 0.0.0-stage → 0.2.0

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 (142) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +202 -0
  4. package/NOTICE +11 -0
  5. package/README.fr.md +125 -0
  6. package/README.md +256 -2
  7. package/assets/logo-mark.svg +24 -0
  8. package/assets/logo.svg +31 -0
  9. package/bin/tadforce.js +4 -0
  10. package/commands/tadforce-review.md +8 -0
  11. package/commands/tadforce-update.md +6 -0
  12. package/commands/tadforce.md +10 -0
  13. package/package.json +60 -4
  14. package/skills/tadforce/SKILL.md +73 -0
  15. package/skills/tadforce/references/axes-rules.md +29 -0
  16. package/skills/tadforce/references/evidence-rules.md +40 -0
  17. package/skills/tadforce/references/exclusion-heuristics.md +40 -0
  18. package/skills/tadforce/references/figure-rules.md +42 -0
  19. package/skills/tadforce/references/markdown-conventions.md +67 -0
  20. package/skills/tadforce/references/modules/agentforce-ai.md +24 -0
  21. package/skills/tadforce/references/modules/analytics.md +19 -0
  22. package/skills/tadforce/references/modules/commerce.md +25 -0
  23. package/skills/tadforce/references/modules/data-cloud.md +23 -0
  24. package/skills/tadforce/references/modules/experience-cloud.md +24 -0
  25. package/skills/tadforce/references/modules/field-service.md +25 -0
  26. package/skills/tadforce/references/modules/industries.md +21 -0
  27. package/skills/tadforce/references/modules/integration-platforms.md +19 -0
  28. package/skills/tadforce/references/modules/marketing.md +24 -0
  29. package/skills/tadforce/references/modules/omnistudio.md +24 -0
  30. package/skills/tadforce/references/modules/platform-events.md +19 -0
  31. package/skills/tadforce/references/modules/revenue-cpq.md +25 -0
  32. package/skills/tadforce/references/modules/sales-cloud.md +26 -0
  33. package/skills/tadforce/references/modules/security-shield.md +18 -0
  34. package/skills/tadforce/references/modules/service-cloud.md +27 -0
  35. package/skills/tadforce/references/naming-external-systems.md +32 -0
  36. package/skills/tadforce/references/questions.md +59 -0
  37. package/skills/tadforce/references/salesforce-skills.md +27 -0
  38. package/skills/tadforce/references/workflow.md +85 -0
  39. package/skills/tadforce/references/writing-rules.md +47 -0
  40. package/skills/tadforce-review/SKILL.md +53 -0
  41. package/skills/tadforce-update/SKILL.md +48 -0
  42. package/src/build/brand.js +107 -0
  43. package/src/build/browser.js +70 -0
  44. package/src/build/directives.js +293 -0
  45. package/src/build/export.js +60 -0
  46. package/src/build/figures.js +119 -0
  47. package/src/build/mermaid.js +107 -0
  48. package/src/build/render.js +299 -0
  49. package/src/build/serve.js +59 -0
  50. package/src/cli.js +68 -0
  51. package/src/commands/docs.js +191 -0
  52. package/src/commands/doctor.js +366 -0
  53. package/src/commands/init.js +132 -0
  54. package/src/commands/org.js +151 -0
  55. package/src/commands/review.js +35 -0
  56. package/src/commands/spec.js +91 -0
  57. package/src/commands/update.js +153 -0
  58. package/src/facts/extractors/apex.js +111 -0
  59. package/src/facts/extractors/automation.js +85 -0
  60. package/src/facts/extractors/data.js +94 -0
  61. package/src/facts/extractors/integration.js +76 -0
  62. package/src/facts/extractors/misc.js +100 -0
  63. package/src/facts/extractors/security.js +92 -0
  64. package/src/facts/extractors/ui.js +120 -0
  65. package/src/facts/fingerprints.js +32 -0
  66. package/src/facts/heuristics/exclusions.js +143 -0
  67. package/src/facts/heuristics/modules.js +85 -0
  68. package/src/facts/heuristics/naming.js +31 -0
  69. package/src/facts/heuristics/signals.js +76 -0
  70. package/src/facts/index.js +197 -0
  71. package/src/facts/org.js +87 -0
  72. package/src/facts/xml.js +85 -0
  73. package/src/sf/sf.js +120 -0
  74. package/src/util/args.js +21 -0
  75. package/src/util/config.js +104 -0
  76. package/src/util/fsx.js +58 -0
  77. package/src/util/i18n.js +33 -0
  78. package/src/util/log.js +31 -0
  79. package/src/util/paths.js +11 -0
  80. package/src/validate/index.js +126 -0
  81. package/templates/axes/en/00-document-control.md +3 -0
  82. package/templates/axes/en/01-summary.md +7 -0
  83. package/templates/axes/en/02-method.md +9 -0
  84. package/templates/axes/en/03-findings.md +19 -0
  85. package/templates/axes/en/04-register.md +3 -0
  86. package/templates/axes/en/05-roadmap.md +3 -0
  87. package/templates/axes/en/99-appendix.md +5 -0
  88. package/templates/axes/fr/00-document-control.md +3 -0
  89. package/templates/axes/fr/01-summary.md +7 -0
  90. package/templates/axes/fr/02-method.md +9 -0
  91. package/templates/axes/fr/03-findings.md +19 -0
  92. package/templates/axes/fr/04-register.md +3 -0
  93. package/templates/axes/fr/05-roadmap.md +3 -0
  94. package/templates/axes/fr/99-appendix.md +5 -0
  95. package/templates/html/cover.html +12 -0
  96. package/templates/html/editor.js +502 -0
  97. package/templates/html/style.css +255 -0
  98. package/templates/html/template.html +88 -0
  99. package/templates/i18n/en.json +366 -0
  100. package/templates/i18n/fr.json +366 -0
  101. package/templates/manifest/package-tad.xml +543 -0
  102. package/templates/specs/sections.yml +494 -0
  103. package/templates/tad/en/00-document-control.md +7 -0
  104. package/templates/tad/en/01-summary.md +17 -0
  105. package/templates/tad/en/02-purpose.md +35 -0
  106. package/templates/tad/en/03-context.md +17 -0
  107. package/templates/tad/en/04-general.md +29 -0
  108. package/templates/tad/en/05-functional.md +7 -0
  109. package/templates/tad/en/06-data.md +71 -0
  110. package/templates/tad/en/07-application.md +49 -0
  111. package/templates/tad/en/08-ui.md +41 -0
  112. package/templates/tad/en/09-integrations.md +53 -0
  113. package/templates/tad/en/10-security.md +59 -0
  114. package/templates/tad/en/11-reporting.md +13 -0
  115. package/templates/tad/en/12-operations.md +25 -0
  116. package/templates/tad/en/13-nfr.md +9 -0
  117. package/templates/tad/en/14-decisions.md +3 -0
  118. package/templates/tad/en/15-outlook.md +3 -0
  119. package/templates/tad/en/99-appendices.md +119 -0
  120. package/templates/tad/fr/00-document-control.md +7 -0
  121. package/templates/tad/fr/01-summary.md +17 -0
  122. package/templates/tad/fr/02-purpose.md +35 -0
  123. package/templates/tad/fr/03-context.md +17 -0
  124. package/templates/tad/fr/04-general.md +29 -0
  125. package/templates/tad/fr/05-functional.md +7 -0
  126. package/templates/tad/fr/06-data.md +71 -0
  127. package/templates/tad/fr/07-application.md +49 -0
  128. package/templates/tad/fr/08-ui.md +41 -0
  129. package/templates/tad/fr/09-integrations.md +53 -0
  130. package/templates/tad/fr/10-security.md +59 -0
  131. package/templates/tad/fr/11-reporting.md +13 -0
  132. package/templates/tad/fr/12-operations.md +25 -0
  133. package/templates/tad/fr/13-nfr.md +9 -0
  134. package/templates/tad/fr/14-decisions.md +3 -0
  135. package/templates/tad/fr/15-outlook.md +3 -0
  136. package/templates/tad/fr/99-appendices.md +119 -0
  137. package/templates/vendor/LICENSE-html-docx-js.txt +21 -0
  138. package/templates/vendor/LICENSE-html2canvas.txt +22 -0
  139. package/templates/vendor/LICENSE-mermaid.txt +21 -0
  140. package/templates/vendor/html-docx.js +13216 -0
  141. package/templates/vendor/html2canvas.min.js +20 -0
  142. package/templates/vendor/mermaid.min.js +2314 -0
package/src/cli.js ADDED
@@ -0,0 +1,68 @@
1
+ import path from 'node:path';
2
+ import { parseArgs } from './util/args.js';
3
+ import { log, c, setQuiet } from './util/log.js';
4
+ import { readJson } from './util/fsx.js';
5
+ import { PKG_ROOT } from './util/paths.js';
6
+ import * as initCmd from './commands/init.js';
7
+ import * as doctorCmd from './commands/doctor.js';
8
+ import * as org from './commands/org.js';
9
+ import * as docs from './commands/docs.js';
10
+ import * as specCmd from './commands/spec.js';
11
+ import * as reviewCmd from './commands/review.js';
12
+ import * as updateCmd from './commands/update.js';
13
+
14
+ // The four commands of the run. Everything else stays available for experts and CI.
15
+ const MAIN = {
16
+ doctor: [doctorCmd.run, 'Everything before the writing, in one loop: tools, project, org login, metadata, questions'],
17
+ build: [docs.build, 'Build the document: HTML always, --pdf --docx on demand, then validate (--open to review it)'],
18
+ review: [reviewCmd.run, 'Open the document to comment it (Save writes to the same file) and list the open comments'],
19
+ update: [updateCmd.run, 'Months later: read the org again, archive the old facts, list the sections to rewrite, bump the version'],
20
+ };
21
+ const ADVANCED = {
22
+ init: [initCmd.run, 'Create the workspace and install the agent skills (doctor does it too)'],
23
+ auth: [org.auth, 'Authorize an org in the browser and save its alias (doctor does it too)'],
24
+ detect: [org.detect, 'Print org facts that answer the startup questions (JSON)'],
25
+ manifest: [org.manifest, 'Build the wildcard manifest filtered on what the org supports'],
26
+ retrieve: [org.retrieve, 'Retrieve the manifest into tadforce/.snapshot (force-app untouched)'],
27
+ facts: [docs.facts, 'Extract facts.json from the metadata (--org adds aggregated org facts)'],
28
+ prepare: [docs.prepare, 'manifest → retrieve → facts --org → scaffold, in one go (CI)'],
29
+ scaffold: [docs.scaffold, 'Copy the chapter skeletons into tadforce/src (existing files kept)'],
30
+ next: [specCmd.next, 'Next section to write, with its specification and the facts to use (--json)'],
31
+ spec: [specCmd.spec, 'Specification of one section (tadforce spec data.domains) — no id lists them all'],
32
+ validate: [docs.validate, 'Check the document: pending sections, placeholders, identifiers, glossary, secrets, anchors'],
33
+ comments: [docs.comments, 'List the review comments saved in a built HTML file'],
34
+ status: [docs.status, 'Where the workspace stands'],
35
+ 'install-skills': [async (a) => { const d = initCmd.installSkills(path.resolve(a.cwd || process.cwd()), a.agents ? String(a.agents).split(',') : null); log.ok(`Skills installed in ${d.join(', ')}`); return 0; }, 'Install or update the agent skills (--agents claude,agents,cursor…)'],
36
+ };
37
+ const COMMANDS = { ...MAIN, ...ADVANCED };
38
+
39
+ function help(all) {
40
+ const v = readJson(path.join(PKG_ROOT, 'package.json')).version;
41
+ console.log(`${c.violet('▲')} ${c.bold('TADForce')} ${v} — technical architecture documents for Salesforce orgs. ${c.dim('No evidence, no sentence.')}\n`);
42
+ console.log(`Usage: ${c.bold('npx tadforce <command> [options]')}\n`);
43
+ for (const [k, [, d]] of Object.entries(MAIN)) console.log(` ${c.cyan(k.padEnd(15))} ${d}`);
44
+ if (all) {
45
+ console.log('\nAdvanced (CI, experts):');
46
+ for (const [k, [, d]] of Object.entries(ADVANCED)) console.log(` ${c.cyan(k.padEnd(15))} ${d}`);
47
+ } else console.log(c.dim(`\n ${Object.keys(ADVANCED).length} advanced commands: tadforce help --all`));
48
+ console.log('\nCommon options: --cwd <dir> --org <alias> --doc tad|axes|all --json --quiet');
49
+ console.log(`Start here: ${c.bold('npx tadforce doctor')} then type ${c.bold('/tadforce')} in your AI agent.\n`);
50
+ }
51
+
52
+ export async function main(argv) {
53
+ const args = parseArgs(argv);
54
+ const cmd = args._[0];
55
+ if (args.quiet) setQuiet(true);
56
+ if (args.version || args.v) { console.log(readJson(path.join(PKG_ROOT, 'package.json')).version); return 0; }
57
+ if (!cmd || cmd === 'help' || args.help || args.h) { help(args.all); return 0; }
58
+ const entry = COMMANDS[cmd];
59
+ if (!entry) { log.fail(`Unknown command "${cmd}".`); help(); return 1; }
60
+ if (args.cwd) args.cwd = path.resolve(args.cwd);
61
+ try {
62
+ return (await entry[0](args)) || 0;
63
+ } catch (e) {
64
+ log.fail(e.message);
65
+ if (process.env.TADFORCE_DEBUG) console.error(e.stack);
66
+ return 1;
67
+ }
68
+ }
@@ -0,0 +1,191 @@
1
+ // facts, scaffold, build, validate, comments, status, prepare.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { exists, writeJson, readText, copyDir, stableStringify } from '../util/fsx.js';
5
+ import { loadWorkspace, sourceDirs } from '../util/config.js';
6
+ import { TEMPLATES } from '../util/paths.js';
7
+ import { extractFacts, readCodeAnalyzer, loadFacts } from '../facts/index.js';
8
+ import { collectOrgFacts } from '../facts/org.js';
9
+ import { fingerprints } from '../facts/fingerprints.js';
10
+ import { buildDocument, readComments, outputName, DOCS } from '../build/render.js';
11
+ import { exportPdf, exportDocx } from '../build/export.js';
12
+ import { closeBrowser } from '../build/browser.js';
13
+ import { validateDocument } from '../validate/index.js';
14
+ import { log, c } from '../util/log.js';
15
+ import * as org from './org.js';
16
+
17
+ const docsOf = (args) => (!args.doc || args.doc === 'all' ? DOCS : String(args.doc).split(','));
18
+
19
+ export async function facts(args) {
20
+ const ctx = loadWorkspace(args.cwd || process.cwd());
21
+ const dirs = args.source ? [path.resolve(args.source)] : sourceDirs(ctx);
22
+ if (!dirs.length) throw new Error('No metadata found: run `tadforce retrieve` (or point --source at a force-app folder).');
23
+ log.banner('facts');
24
+ log.step(`Reading metadata from ${dirs.map((d) => path.relative(ctx.project, d) || '.').join(', ')}`);
25
+ let orgFacts = exists(ctx.p('facts', 'org.json')) ? JSON.parse(readText(ctx.p('facts', 'org.json'))) : null;
26
+ const knowledge = { exclusions: ctx.exclusions(), glossary: ctx.glossary(), scope: ctx.cfg.scope };
27
+ let f = extractFacts(dirs, knowledge, { base: ctx.project, org: orgFacts, codeAnalyzer: readCodeAnalyzer(ctx.ws) });
28
+ if (args.org) {
29
+ const alias = typeof args.org === 'string' ? args.org : ctx.cfg.org.alias;
30
+ if (!alias) throw new Error('No org alias for --org.');
31
+ log.step('Collecting aggregated org facts (counts, users per profile, jobs, coverage)…');
32
+ orgFacts = await collectOrgFacts(alias, f, { onProgress: (s) => log.info(s) });
33
+ writeJson(ctx.p('facts', 'org.json'), orgFacts);
34
+ f = extractFacts(dirs, knowledge, { base: ctx.project, org: orgFacts, codeAnalyzer: readCodeAnalyzer(ctx.ws) });
35
+ if (orgFacts.errors.length) log.warn(`${orgFacts.errors.length} org query(ies) failed — see facts/org.json`);
36
+ }
37
+ writeJson(ctx.p('facts', 'facts.json'), f);
38
+ // baseline for `tadforce update`: what this version of the document was written from
39
+ writeJson(ctx.p('facts', 'fingerprints.json'), fingerprints(dirs));
40
+ const questions = buildQuestions(f, orgFacts);
41
+ writeJson(ctx.p('facts', 'questions.json'), questions);
42
+ const k = f.counts;
43
+ log.ok(`facts.json: ${k.apex.classes} classes (+${k.apex.tests} tests), ${k.apex.triggers} triggers, ${k.flows.active} active flows, ${k.ui.lwc} LWC, ${k.objects.custom} custom objects, ${k.security.permissionSetGroups} PSG`);
44
+ log.info(`modules: ${Object.entries(f.modules).filter(([kk, v]) => v === true).map(([kk]) => kk).join(', ') || 'none'}${f.modules.products.length ? ' · products: ' + f.modules.products.join(', ') : ''}`);
45
+ if (questions.systems.length) log.human(`${questions.systems.length} external endpoint(s) need a system name → knowledge/glossary.yml (see facts/questions.json).`);
46
+ if (questions.exclusions.total) log.human(`${questions.exclusions.total} component(s) look unused → confirm or reject in knowledge/exclusions.yml.`);
47
+ return 0;
48
+ }
49
+
50
+ /** What only a human can answer, grouped so it can be confirmed in one pass. */
51
+ export function buildQuestions(f, orgFacts) {
52
+ const byReason = {};
53
+ for (const cnd of f.exclusions.candidates) {
54
+ const key = cnd.reasons[0].replace(/name suggests .*/, 'name suggests test / demo / POC / sample');
55
+ (byReason[key] ||= []).push(`${cnd.type}:${cnd.name}`);
56
+ }
57
+ if (orgFacts && orgFacts.volumes) {
58
+ const empty = orgFacts.volumes.filter((v) => v.records === 0).map((v) => `object:${v.object}`);
59
+ if (empty.length) byReason['object with no record in the org'] = empty;
60
+ }
61
+ const hosts = new Map();
62
+ for (const e of f.systems.unnamed) { const key = e.host || `${e.kind}:${e.name}`; if (!hosts.has(key)) hosts.set(key, []); hosts.get(key).push(`${e.kind}:${e.name}`); }
63
+ return {
64
+ systems: [...hosts.entries()].map(([key, endpoints]) => ({ key, endpoints, question: 'Which system is this? (name as the client calls it)' })),
65
+ exclusions: { total: f.exclusions.candidates.length, groups: Object.entries(byReason).map(([reason, items]) => ({ reason, count: items.length, items })) },
66
+ modules: f.modules,
67
+ language: orgFacts && orgFacts.organization ? orgFacts.organization.language : null,
68
+ };
69
+ }
70
+
71
+ export async function scaffold(args) {
72
+ const ctx = loadWorkspace(args.cwd || process.cwd());
73
+ const lang = ctx.cfg.language === 'fr' ? 'fr' : 'en';
74
+ for (const doc of docsOf(args)) {
75
+ const src = path.join(TEMPLATES, doc, lang);
76
+ const dst = ctx.p('src', doc);
77
+ const before = exists(dst) ? fs.readdirSync(dst).length : 0;
78
+ copyDir(src, dst, { overwrite: !!args.force });
79
+ const after = fs.readdirSync(dst).length;
80
+ log.ok(`src/${doc}: ${after} chapter file(s)${before && !args.force ? ' (existing files kept)' : ''}`);
81
+ }
82
+ return 0;
83
+ }
84
+
85
+ export async function build(args) {
86
+ const ctx = loadWorkspace(args.cwd || process.cwd());
87
+ log.banner('build');
88
+ // HTML always; PDF and Word on demand (asked once in a terminal when neither flag nor config says).
89
+ if (args.pdf === undefined && args.docx === undefined && process.stdin.isTTY && !args.json && !args.yes) {
90
+ const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout });
91
+ try {
92
+ const a = (await rl.question(' Formats besides HTML — pdf, docx, both or none? [both] ')).trim().toLowerCase() || 'both';
93
+ args.pdf = a === 'both' || a.includes('pdf');
94
+ args.docx = a === 'both' || a.includes('doc');
95
+ } finally { rl.close(); }
96
+ }
97
+ let failed = 0;
98
+ const built = [];
99
+ try {
100
+ for (const doc of docsOf(args)) {
101
+ if (!exists(ctx.p('src', doc))) { log.warn(`src/${doc} missing — skipped`); continue; }
102
+ const r = await buildDocument(ctx, doc, { render: args.render !== false });
103
+ built.push(r);
104
+ if (r.pending.length) log.warn(`${r.pending.length} section(s) of ${doc} still to write — your agent writes them (tadforce next), then build again.`);
105
+ log.ok(`${path.relative(ctx.project, r.html)} (${r.sizeKB} KB, ${r.figures} figures)`);
106
+ const wantPdf = args.pdf ?? ctx.cfg.outputs.pdf;
107
+ const wantDocx = args.docx ?? ctx.cfg.outputs.docx;
108
+ const archive = path.join(path.dirname(r.html), outputName(ctx.cfg, doc, { versioned: true }));
109
+ if (wantPdf && args.render !== false) { const p = archive + '.pdf'; const s = await exportPdf(r.html, p); log.ok(`${path.relative(ctx.project, p)} (${Math.round(s / 1024)} KB)`); }
110
+ if (wantDocx && args.render !== false) { const p = archive + '.docx'; const s = await exportDocx(r.html, p); log.ok(`${path.relative(ctx.project, p)} (${Math.round(s / 1024)} KB)`); }
111
+ const v = validateDocument(ctx, doc, { buildReport: r });
112
+ report(v, doc);
113
+ if (!v.ok) failed++;
114
+ }
115
+ } finally {
116
+ await closeBrowser();
117
+ }
118
+ if (args.open && built[0]) { const { open } = await import('./review.js'); await open(built[0].html); }
119
+ return args.strict && failed ? 1 : 0;
120
+ }
121
+
122
+ function report(v, doc) {
123
+ const icon = v.ok ? c.green('✔') : c.red('✖');
124
+ console.log(`${icon} validate ${doc}: score ${v.score}/100 — ${v.errors.length} error(s), ${v.warnings.length} warning(s)`);
125
+ const show = [...v.errors, ...v.warnings.slice(0, Math.max(0, 25 - v.errors.length))];
126
+ for (const i of show.slice(0, 40)) console.log(` ${i.level === 'error' ? c.red('error') : c.yellow('warn ')} ${c.dim(i.code)} ${i.message}${i.where ? c.dim(' ' + i.where) : ''}`);
127
+ const hidden = v.errors.length + v.warnings.length - Math.min(40, show.length);
128
+ if (hidden > 0) console.log(c.dim(` … ${hidden} more (tadforce validate --json)`));
129
+ }
130
+
131
+ export async function validate(args) {
132
+ const ctx = loadWorkspace(args.cwd || process.cwd());
133
+ const results = {};
134
+ try {
135
+ for (const doc of docsOf(args)) {
136
+ if (!exists(ctx.p('src', doc))) continue;
137
+ // dry build (no browser) into the cache, so that anchors and placeholders are checked too
138
+ const cfgOut = { ...ctx, cfg: { ...ctx.cfg, outputs: { ...ctx.cfg.outputs, dir: '.cache/validate' } } };
139
+ const r = await buildDocument(cfgOut, doc, { render: false });
140
+ results[doc] = validateDocument(ctx, doc, { buildReport: r });
141
+ if (!args.json) report(results[doc], doc);
142
+ }
143
+ } finally { await closeBrowser(); }
144
+ if (args.json) console.log(JSON.stringify(results, null, 2));
145
+ return Object.values(results).every((r) => r.ok) ? 0 : 1;
146
+ }
147
+
148
+ export async function comments(args) {
149
+ const ctx = loadWorkspace(args.cwd || process.cwd());
150
+ const doc = args.doc && args.doc !== 'all' ? args.doc : 'tad';
151
+ const file = args._[1] ? path.resolve(args._[1]) : path.join(ctx.p(ctx.cfg.outputs.dir || 'out'), outputName(ctx.cfg, doc) + '.html');
152
+ if (!exists(file)) throw new Error(`No such file: ${file}`);
153
+ const list = readComments(readText(file)).filter((x) => args.all || x.status !== 'resolved');
154
+ if (args.json) { console.log(stableStringify(list.map((x) => ({ ...x, image: x.image ? '[image attached]' : null })))); return 0; }
155
+ log.banner(`comments · ${path.basename(file)}`);
156
+ if (!list.length) { log.ok('No open comment.'); return 0; }
157
+ list.forEach((x, i) => {
158
+ console.log(`${c.bold('#' + (i + 1))} ${c.dim(x.id)} [${x.section}] ${x.quote ? '« ' + x.quote.slice(0, 160) + ' »' : c.dim('(captured area' + (x.image ? ', image attached' : '') + ')')}`);
159
+ console.log(` → ${x.comment}`);
160
+ });
161
+ return 0;
162
+ }
163
+
164
+ export async function status(args) {
165
+ const ctx = loadWorkspace(args.cwd || process.cwd());
166
+ const f = loadFacts(ctx.ws);
167
+ const line = (ok, s) => console.log(`${ok ? c.green('✔') : c.dim('○')} ${s}`);
168
+ log.banner('status');
169
+ line(!!ctx.cfg.org.alias, `org: ${ctx.cfg.org.alias || 'not set'}`);
170
+ line(exists(ctx.p('.snapshot', 'package.xml')), 'manifest');
171
+ line(exists(ctx.p(ctx.cfg.source.dir)), 'snapshot retrieved');
172
+ line(!!f, `facts${f ? ` (${f.inventory.length} components${f.org ? ', org facts' : ''})` : ''}`);
173
+ for (const doc of DOCS) {
174
+ const dir = ctx.p('src', doc);
175
+ if (!exists(dir)) { line(false, `src/${doc}`); continue; }
176
+ const text = fs.readdirSync(dir).filter((x) => x.endsWith('.md')).map((x) => readText(path.join(dir, x))).join('\n');
177
+ const pending = (text.match(/<!--\s*tad:pending/g) || []).length;
178
+ line(pending === 0, `src/${doc}: ${pending ? pending + ' section(s) to write' : 'written'}`);
179
+ }
180
+ return 0;
181
+ }
182
+
183
+ /** Deterministic part of the pipeline in one go: manifest → retrieve → facts (--org) → scaffold. */
184
+ export async function prepare(args) {
185
+ const steps = [];
186
+ if (!args.skipRetrieve) { steps.push(['manifest', org.manifest], ['retrieve', org.retrieve]); }
187
+ for (const [, fn] of steps) { const code = await fn(args); if (code) return code; }
188
+ const code = await facts({ ...args, org: args.org === false ? false : (args.org || true) });
189
+ if (code) return code;
190
+ return scaffold(args);
191
+ }
@@ -0,0 +1,366 @@
1
+ // `tadforce doctor`: everything before the writing, in one loop that runs until all is green —
2
+ // tools, project, org connection, metadata, the framing questions and the questions only a human can answer.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import readline from 'node:readline/promises';
6
+ import { spawnSync } from 'node:child_process';
7
+ import YAML from 'yaml';
8
+ import { exists, readJson, readText, writeText } from '../util/fsx.js';
9
+ import { findWorkspace, defaultConfig, DEFAULT_BRAND, loadWorkspace, sourceDirs } from '../util/config.js';
10
+ import { CONFIG_FILE } from '../util/paths.js';
11
+ import { LANGUAGES } from '../util/i18n.js';
12
+ import { sfVersion, sfJson, sfInteractive, soql } from '../sf/sf.js';
13
+ import { findBrowser, closeBrowser } from '../build/browser.js';
14
+ import { findLogoCandidates, orgThemeColors, imageColors, paletteFrom } from '../build/brand.js';
15
+ import { log, c, setQuiet } from '../util/log.js';
16
+ import { ensureWorkspace, installSkills } from './init.js';
17
+ import * as org from './org.js';
18
+ import * as docs from './docs.js';
19
+
20
+ /** Tool prerequisites. Each check says what to do: fixed by TADForce, or a human action. */
21
+ export async function checks(cwd) {
22
+ const out = [];
23
+ const add = (id, ok, message, fix = null, human = false, level = ok ? 'ok' : 'error') => out.push({ id, ok, level, message, fix, human });
24
+
25
+ const major = Number(process.versions.node.split('.')[0]);
26
+ add('node', major >= 20, `Node.js ${process.versions.node}`, major >= 20 ? null : 'Install Node.js 20 or later (https://nodejs.org).', true);
27
+
28
+ const sf = sfVersion();
29
+ add('sf-cli', !!sf, sf ? `Salesforce CLI ${sf}` : 'Salesforce CLI not found', sf ? null : 'npm install --global @salesforce/cli', false);
30
+
31
+ const browser = findBrowser();
32
+ add('browser', !!browser, browser ? `Browser for rendering: ${browser}` : 'No Chrome / Edge / Chromium found (needed for diagrams, PDF and DOCX)', browser ? null : 'npx @puppeteer/browsers install chrome-headless-shell@stable (then set TADFORCE_CHROME)', false);
33
+
34
+ const git = spawnSync('git', ['--version'], { encoding: 'utf8' });
35
+ add('git', git.status === 0, git.status === 0 ? git.stdout.trim() : 'git not found (optional: version your workspace)', null, false, git.status === 0 ? 'ok' : 'warn');
36
+
37
+ const sfdx = path.join(cwd, 'sfdx-project.json');
38
+ const hasProject = exists(sfdx);
39
+ add('sfdx-project', hasProject, hasProject ? `SFDX project: ${readJson(sfdx).name || path.basename(cwd)}` : 'No sfdx-project.json in this folder', hasProject ? null : 'tadforce doctor creates a minimal SFDX project here', false, hasProject ? 'ok' : 'warn');
40
+ return out;
41
+ }
42
+
43
+ /** The org to document: --org, the workspace, then the project's target-org (sf config). */
44
+ async function resolveAlias(project, args, cfg) {
45
+ if (typeof args.org === 'string') return args.org;
46
+ if (cfg && cfg.org && cfg.org.alias) return cfg.org.alias;
47
+ try {
48
+ const r = await sfJson(['config', 'get', 'target-org'], { cwd: project, timeoutMs: 60000 });
49
+ const v = Array.isArray(r) && r[0] && r[0].value;
50
+ return v || null;
51
+ } catch { return null; }
52
+ }
53
+
54
+ async function orgStatus(alias) {
55
+ if (!alias) return { ok: false, reason: 'none' };
56
+ try {
57
+ const d = await sfJson(['org', 'display', '--target-org', alias], { timeoutMs: 120000 });
58
+ if (d.connectedStatus && !/^connected$/i.test(d.connectedStatus) && !/^active$/i.test(d.status || '')) return { ok: false, reason: 'expired', detail: d.connectedStatus };
59
+ return { ok: true, username: d.username, instanceUrl: d.instanceUrl, sandbox: /test\.salesforce|sandbox|\.cs\d+\./i.test(d.instanceUrl || '') };
60
+ } catch (e) { return { ok: false, reason: 'expired', detail: e.message.slice(0, 160) }; }
61
+ }
62
+
63
+ /** Org facts that give a recommended answer to the framing questions. */
64
+ async function proposals(alias) {
65
+ const out = {};
66
+ try {
67
+ const o = ((await soql('SELECT Name, IsSandbox, LanguageLocaleKey FROM Organization', alias)).records || [])[0] || {};
68
+ out.client = o.Name; out.language = /^fr/i.test(o.LanguageLocaleKey || '') ? 'fr' : 'en'; out.environment = o.IsSandbox ? 'Sandbox' : 'Production';
69
+ } catch { /* proposals stay empty */ }
70
+ return out;
71
+ }
72
+
73
+ const listArg = (v) => (typeof v !== 'string' ? undefined : v.split(',').map((x) => x.trim()).filter(Boolean));
74
+
75
+ /** Answers given as flags (the agent's way of answering) are written to the config. */
76
+ async function applyAnswers(ctx, args) {
77
+ const cfg = ctx.cfg;
78
+ const set = (cond, fn) => { if (cond) fn(); };
79
+ set(args.lang, () => { cfg.language = LANGUAGES.includes(args.lang) ? args.lang : 'en'; });
80
+ set(args.client, () => { cfg.client.name = String(args.client); if (!args.short && (cfg.client.short === 'CLIENT' || !cfg.client.short)) cfg.client.short = String(args.client).replace(/[^A-Za-z0-9]/g, '').slice(0, 8).toUpperCase(); });
81
+ set(args.short, () => { cfg.client.short = String(args.short); });
82
+ set(args.integrator !== undefined, () => { cfg.integrator.name = args.integrator === true || args.integrator === '' || args.integrator === 'none' ? null : String(args.integrator); });
83
+ set(args.author !== undefined, () => { cfg.document.author = args.author === true || args.author === '' || args.author === 'none' ? null : String(args.author); });
84
+ set(args.title, () => { cfg.document.title = String(args.title); });
85
+ set(args.environment, () => { cfg.document.environment = String(args.environment); });
86
+ set(listArg(args.scope), () => { cfg.scope.include = listArg(args.scope); });
87
+ set(listArg(args.packageDirs), () => { cfg.scope.packageDirectories = listArg(args.packageDirs); });
88
+ set(args.source && ['org', 'local', 'both'].includes(args.source), () => { cfg.source.mode = args.source; });
89
+ if (typeof args.org === 'string') cfg.org.alias = args.org;
90
+ if (args.logo && args.logo !== true) cfg.client.logo = await importAsset(ctx, String(args.logo), 'client-logo');
91
+ if (args.integratorLogo && args.integratorLogo !== true) cfg.integrator.logo = await importAsset(ctx, String(args.integratorLogo), 'integrator-logo');
92
+ if (listArg(args.specs)) {
93
+ const kept = [];
94
+ for (const s of listArg(args.specs)) {
95
+ const src = path.resolve(s);
96
+ if (!exists(src)) { log.warn(`Specification not found: ${s}`); continue; }
97
+ const dst = ctx.p('knowledge', 'inputs', 'specs', path.basename(src));
98
+ if (path.resolve(src) !== path.resolve(dst)) { fs.mkdirSync(path.dirname(dst), { recursive: true }); fs.copyFileSync(src, dst); }
99
+ kept.push(path.relative(ctx.ws, dst).split(path.sep).join('/'));
100
+ }
101
+ cfg.inputs.specs = [...new Set([...(cfg.inputs.specs || []), ...kept])];
102
+ }
103
+ if (args.specs !== undefined) cfg.setup.specsAsked = true; // --no-specs: none provided
104
+ if (args.confirm || args.yes) cfg.setup.confirmed = true;
105
+ if (args.factsReviewed) cfg.setup.factsReviewed = true;
106
+ ctx.save(cfg);
107
+ }
108
+
109
+ /** Copy a local file, or download a URL (only when the human gave it), into knowledge/assets/. */
110
+ async function importAsset(ctx, src, name) {
111
+ let ext, bytes;
112
+ if (/^https?:\/\//i.test(src)) {
113
+ const r = await fetch(src);
114
+ if (!r.ok) throw new Error(`Logo download failed (${r.status}): ${src}`);
115
+ const type = (r.headers.get('content-type') || '').split(';')[0];
116
+ ext = { 'image/svg+xml': '.svg', 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' }[type] || path.extname(new URL(src).pathname) || '.png';
117
+ bytes = Buffer.from(await r.arrayBuffer());
118
+ } else {
119
+ const p = path.resolve(src);
120
+ if (!exists(p)) throw new Error(`Logo not found: ${src}`);
121
+ ext = path.extname(p).toLowerCase(); bytes = fs.readFileSync(p);
122
+ }
123
+ const rel = `knowledge/assets/${name}${ext}`;
124
+ fs.mkdirSync(ctx.p('knowledge', 'assets'), { recursive: true });
125
+ fs.writeFileSync(ctx.p(rel), bytes);
126
+ return rel;
127
+ }
128
+
129
+ /** Logo from the snapshot and colours from evidence (org theme, then logo), unless the human set them. */
130
+ async function brand(ctx) {
131
+ const cfg = ctx.cfg;
132
+ const dirs = sourceDirs(ctx);
133
+ const notes = [];
134
+ if (!cfg.client.logo && !cfg.setup.logoSearched) {
135
+ const hit = findLogoCandidates(dirs)[0];
136
+ cfg.setup.logoSearched = true;
137
+ if (hit) {
138
+ const rel = `knowledge/assets/client-logo${hit.ext}`;
139
+ fs.mkdirSync(ctx.p('knowledge', 'assets'), { recursive: true });
140
+ fs.copyFileSync(hit.file, ctx.p(rel));
141
+ cfg.client.logo = rel;
142
+ notes.push(`Client logo found in the org (${hit.source}: ${path.basename(hit.file)}) → ${rel}`);
143
+ }
144
+ }
145
+ const untouched = JSON.stringify(cfg.brand) === JSON.stringify(DEFAULT_BRAND) || cfg.brand.source === 'logo' && cfg.brand.logo !== cfg.client.logo;
146
+ if (untouched) {
147
+ const theme = orgThemeColors(dirs);
148
+ let colors = theme.length ? [...new Set(theme.map((x) => x.value))] : [];
149
+ let source = colors.length ? `org theme (${theme[0].file})` : null;
150
+ if (!colors.length && cfg.client.logo && exists(ctx.p(cfg.client.logo)) && findBrowser()) {
151
+ try { colors = await imageColors(ctx.p(cfg.client.logo)); source = 'logo'; } catch (e) { notes.push(`Colours could not be read from the logo (${e.message.slice(0, 80)})`); } finally { await closeBrowser(); }
152
+ }
153
+ if (colors.length) {
154
+ cfg.brand = { ...paletteFrom(colors[0], colors[1]), source, logo: cfg.client.logo || null };
155
+ notes.push(`Document colours from the ${source}: ${colors.slice(0, 2).join(', ')} (edit tadforce.config.json › brand to change)`);
156
+ }
157
+ }
158
+ ctx.save(cfg);
159
+ return notes;
160
+ }
161
+
162
+ /** Questions only a human can answer, each with a recommended answer. */
163
+ function framingQuestions(cfg, prop, alias, hasLocal) {
164
+ const q = [];
165
+ const ask = (id, question, recommended, extra = {}) => q.push({ id, question, recommended, ...extra });
166
+ if (!cfg.setup.confirmed) {
167
+ ask('lang', 'Document language', prop.language || cfg.language, { options: LANGUAGES });
168
+ ask('client', 'Client name, as it must appear in the document', prop.client || cfg.client.name);
169
+ ask('short', 'Client short name (file names, references)', cfg.client.short !== 'CLIENT' ? cfg.client.short : String(prop.client || cfg.client.name).replace(/[^A-Za-z0-9]/g, '').slice(0, 8).toUpperCase());
170
+ ask('integrator', 'Integrator, if any (blank: the document only names the client)', cfg.integrator.name || '', { optional: true });
171
+ ask('author', 'Author of the document (blank: no author shown)', cfg.document.author || '', { optional: true });
172
+ ask('title', 'Document title', cfg.document.title);
173
+ ask('scope', 'Part of the org to document — name patterns such as NWB_*,Loan* (blank: the whole org)', (cfg.scope.include || []).join(','), { optional: true });
174
+ ask('logo', `Client logo — a file or a URL${cfg.client.logo ? ` (current: ${cfg.client.logo})` : ' (blank: searched in the org, then a badge with initials)'}`, cfg.client.logo || '', { optional: true });
175
+ }
176
+ if (!cfg.setup.specsAsked && !(cfg.inputs.specs || []).length) ask('specs', 'Functional specifications or other project documents (PDF, DOCX, MD) — comma-separated paths (blank: none)', '', { optional: true });
177
+ if (!cfg.source.mode) {
178
+ ask('source', 'Metadata to document', alias ? 'org' : 'local', { options: ['org', 'local', 'both'], help: 'org: retrieve every metadata type from the org (recommended — the org is the truth); local: the force-app sources already here' + (hasLocal ? '' : ' (none found)') + '; both: org first, local sources added' });
179
+ }
180
+ return q;
181
+ }
182
+
183
+ /** Systems to name and exclusions to confirm, from facts/questions.json. */
184
+ function factQuestions(ctx) {
185
+ const p = ctx.p('facts', 'questions.json');
186
+ if (!exists(p) || ctx.cfg.setup.factsReviewed) return [];
187
+ const qs = readJson(p);
188
+ const out = [];
189
+ for (const s of qs.systems || []) out.push({ id: `system:${s.key}`, kind: 'system', question: `Which system is ${s.key}? (name as the client calls it; blank: to be confirmed)`, endpoints: s.endpoints, writeTo: 'knowledge/glossary.yml › systems' });
190
+ for (const g of (qs.exclusions && qs.exclusions.groups) || []) out.push({ id: `exclude:${g.reason}`, kind: 'exclusion', question: `${g.count} component(s): ${g.reason}. Exclude from the document? (all / none / list of names)`, recommended: 'none', items: g.items, writeTo: 'knowledge/exclusions.yml › exclude' });
191
+ out.push({ id: 'features', kind: 'features', question: 'Any feature decommissioned or switched off that the document must not describe? (blank: none)', recommended: '', writeTo: 'knowledge/exclusions.yml › features' });
192
+ return out;
193
+ }
194
+
195
+ function editYaml(file, key, items) {
196
+ const doc = exists(file) ? YAML.parseDocument(readText(file)) : new YAML.Document({});
197
+ const seq = doc.get(key);
198
+ if (!seq || !YAML.isSeq(seq)) doc.set(key, doc.createNode(items));
199
+ else for (const it of items) seq.add(doc.createNode(it));
200
+ writeText(file, doc.toString());
201
+ }
202
+
203
+ /** Interactive answers to the facts questions, written to the knowledge files. */
204
+ async function askFacts(ctx, rl, questions) {
205
+ const systems = [], exclude = [], features = [];
206
+ for (const q of questions) {
207
+ const a = (await rl.question(` ${q.question}${q.items ? '\n ' + c.dim(q.items.slice(0, 12).join(', ') + (q.items.length > 12 ? ` … +${q.items.length - 12}` : '')) : ''}${q.endpoints ? '\n ' + c.dim(q.endpoints.join(', ')) : ''}\n > `)).trim();
208
+ if (!a) continue;
209
+ if (q.kind === 'system') systems.push({ id: a.toLowerCase().replace(/[^a-z0-9]+/g, '-'), name: a, match: [q.id.slice(7)] });
210
+ if (q.kind === 'exclusion' && a !== 'none') {
211
+ const names = a === 'all' ? q.items : a.split(',').map((x) => x.trim()).filter(Boolean);
212
+ for (const it of names) { const [type, ...rest] = it.includes(':') ? it.split(':') : [null, it]; exclude.push({ name: rest.join(':') || it, ...(type ? { type: type.replace(/^object$/, 'object') } : {}), reason: q.id.slice(8) }); }
213
+ }
214
+ if (q.kind === 'features') features.push({ name: a, note: 'Decommissioned (confirmed during doctor)', components: [], keywords: [] });
215
+ }
216
+ if (systems.length) editYaml(ctx.p('knowledge', 'glossary.yml'), 'systems', systems);
217
+ if (exclude.length) editYaml(ctx.p('knowledge', 'exclusions.yml'), 'exclude', exclude);
218
+ if (features.length) editYaml(ctx.p('knowledge', 'exclusions.yml'), 'features', features);
219
+ return systems.length + exclude.length + features.length;
220
+ }
221
+
222
+ /** One pass: where the workspace stands, and what remains. */
223
+ async function evaluate(project, args) {
224
+ const tools = await checks(project);
225
+ const ws = findWorkspace(project);
226
+ const cfg = ws ? defaultConfig(readJson(path.join(ws, CONFIG_FILE))) : defaultConfig();
227
+ const alias = tools.find((t) => t.id === 'sf-cli').ok ? await resolveAlias(project, args, cfg) : null;
228
+ const status = alias ? await orgStatus(alias) : { ok: false, reason: 'none' };
229
+ const list = [...tools];
230
+ const add = (id, ok, message, fix = null, human = false, level = ok ? 'ok' : 'error') => list.push({ id, ok, level, message, fix, human });
231
+ add('workspace', !!ws, ws ? `Workspace: ${path.relative(project, ws) || '.'}` : 'No TADForce workspace yet', ws ? null : 'created by doctor', false, ws ? 'ok' : 'warn');
232
+ const wantOrg = cfg.source.mode !== 'local';
233
+ if (wantOrg) {
234
+ if (status.ok) add('org', true, `Org: ${alias} (${status.instanceUrl})`);
235
+ else if (status.reason === 'none') add('org', false, 'No org connected to this project', 'log in once in the browser (doctor opens it), or `--source local` to document the local sources only', true);
236
+ else add('org', false, `Org "${alias}" is not connected (${status.detail || 'session expired'})`, 'log in again in the browser (doctor opens it)', true);
237
+ }
238
+ let facts = false, snapshot = false;
239
+ if (ws) {
240
+ const ctx = loadWorkspace(project);
241
+ snapshot = exists(ctx.p(ctx.cfg.source.dir));
242
+ facts = exists(ctx.p('facts', 'facts.json'));
243
+ add('metadata', facts, facts ? `Metadata: ${snapshot ? 'retrieved from the org' : 'local sources'} → facts/facts.json` : 'Metadata not collected yet', facts ? null : 'doctor retrieves it once the questions are answered');
244
+ }
245
+ return { tools, ws, cfg, alias, status, list, facts, snapshot };
246
+ }
247
+
248
+ export async function run(args) {
249
+ const project = path.resolve(args.cwd || process.cwd());
250
+ const interactive = process.stdin.isTTY && !args.json && !args.yes;
251
+ const rl = interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null;
252
+ const say = (fn) => { if (!args.json) fn(); };
253
+ if (args.json) setQuiet(true);
254
+ say(() => log.banner('doctor'));
255
+ try {
256
+ for (let round = 1; round <= 8; round++) {
257
+ let st = await evaluate(project, args);
258
+ // 1. Tools: TADForce installs what it may, the rest is a human action.
259
+ const sfMissing = st.tools.find((t) => t.id === 'sf-cli' && !t.ok);
260
+ if (sfMissing && rl && /^(y|o|yes|oui|)$/i.test((await rl.question(` Salesforce CLI missing. Install it now with npm? [Y/n] `)).trim())) {
261
+ spawnSync('npm', ['install', '--global', '@salesforce/cli'], { stdio: 'inherit', shell: process.platform === 'win32' });
262
+ continue;
263
+ }
264
+ const blocking = st.tools.filter((t) => t.level === 'error');
265
+ if (blocking.length) return finish(args, st, [], [], 'install the missing tools, then run tadforce doctor again');
266
+
267
+ // 2. Workspace (with the skills for the agents of this project)
268
+ if (!st.ws) {
269
+ await quietly(args.json, () => { ensureWorkspace(project, defaultConfig({ org: { alias: st.alias || null } })); installSkills(project, null); });
270
+ say(() => log.ok('Workspace created: tadforce/ (skills installed for your agents)'));
271
+ st = await evaluate(project, args);
272
+ }
273
+ const ctx = loadWorkspace(project);
274
+ ctx.cfg.setup = ctx.cfg.setup || {};
275
+ const hasLocal = (readJson(path.join(project, 'sfdx-project.json')).packageDirectories || []).some((d) => exists(path.join(project, d.path)) && fs.readdirSync(path.join(project, d.path), { recursive: true }).some((f) => String(f).endsWith('-meta.xml')));
276
+ await applyAnswers(ctx, args);
277
+
278
+ // 3. Org connection: reuse the project's target-org, log in only when needed.
279
+ if (ctx.cfg.source.mode !== 'local' && !st.status.ok) {
280
+ if (!rl) return finish(args, st, [], [], 'the human logs in: run `tadforce doctor` in a terminal (it opens the browser), or `sf org login web --alias <alias> --set-default`');
281
+ if (hasLocal && /^l/i.test((await rl.question(' No org connected. Log in now, or document the local sources only? [Login/local] ')).trim())) { ctx.cfg.source.mode = 'local'; ctx.save(ctx.cfg); continue; }
282
+ const def = st.alias || 'tadforce-org';
283
+ const alias = (await rl.question(` Org alias to log in [${def}]: `)).trim() || def;
284
+ const sandbox = /^(y|o|yes|oui)$/i.test((await rl.question(' Is it a sandbox? [y/N] ')).trim());
285
+ log.human('a browser window opens: log in to the org, then click « Allow ».');
286
+ if (sfInteractive(['org', 'login', 'web', '--alias', alias, '--set-default', ...(sandbox ? ['--instance-url', 'https://test.salesforce.com'] : [])])) { ctx.cfg.org.alias = alias; ctx.save(ctx.cfg); }
287
+ continue;
288
+ }
289
+ if (st.alias && ctx.cfg.org.alias !== st.alias && st.status.ok) { ctx.cfg.org.alias = st.alias; ctx.save(ctx.cfg); }
290
+
291
+ // 4. Framing questions (one batch, each with a recommendation)
292
+ const prop = st.status.ok ? await proposals(st.alias) : {};
293
+ const framing = framingQuestions(ctx.cfg, prop, st.status.ok ? st.alias : null, hasLocal);
294
+ if (framing.length) {
295
+ if (!rl) return finish(args, st, framing, [], 'ask the human these questions in one message, then run `tadforce doctor --json --confirm --<id> "<answer>"…`');
296
+ log.step('A few facts only you can confirm (Enter keeps the recommendation):');
297
+ const answers = {};
298
+ for (const q of framing) {
299
+ const a = (await rl.question(` ${q.question}${q.options ? ` (${q.options.join('/')})` : ''}${q.recommended ? ` [${q.recommended}]` : ''}: `)).trim();
300
+ answers[q.id] = a || q.recommended || (q.optional ? '' : undefined);
301
+ }
302
+ const flags = { confirm: true, specs: answers.specs || false };
303
+ for (const [k, v] of Object.entries(answers)) if (v !== undefined) flags[k === 'logo' ? 'logo' : k] = v === '' && ['integrator', 'author'].includes(k) ? 'none' : v === '' ? undefined : v;
304
+ await applyAnswers(loadWorkspace(project), flags);
305
+ continue;
306
+ }
307
+
308
+ // 5. Metadata: retrieve from the org (curated `*` manifest) and/or read the local sources, then facts.
309
+ const mode = ctx.cfg.source.mode || 'org';
310
+ if (!st.facts || args.refresh) {
311
+ const a = { ...args, cwd: project, org: mode === 'local' ? false : ctx.cfg.org.alias, quiet: args.json };
312
+ if (mode !== 'local') {
313
+ say(() => log.step('Retrieving the metadata from the org (read-only, into tadforce/.snapshot)…'));
314
+ for (const fn of [org.manifest, org.retrieve]) { const code = await quietly(args.json, () => fn(a)); if (code) return finish(args, st, [], [], 'retrieve failed: see the messages above'); }
315
+ }
316
+ await quietly(args.json, () => docs.facts(a));
317
+ await quietly(args.json, () => docs.scaffold(a));
318
+ args.refresh = false;
319
+ continue;
320
+ }
321
+
322
+ // 6. Branding from evidence
323
+ for (const n of await brand(loadWorkspace(project))) say(() => log.ok(n));
324
+
325
+ // 7. Facts questions (systems, exclusions, decommissioned features)
326
+ const fq = factQuestions(loadWorkspace(project));
327
+ if (fq.length) {
328
+ if (!rl) return finish(args, st, [], fq, 'ask the human, write the answers to the knowledge files, then run `tadforce doctor --json --facts-reviewed`');
329
+ log.step('What the metadata cannot tell (Enter: skip / to be confirmed):');
330
+ const changed = await askFacts(loadWorkspace(project), rl, fq);
331
+ const c2 = loadWorkspace(project); c2.cfg.setup.factsReviewed = true; c2.save(c2.cfg);
332
+ if (changed) await quietly(args.json, () => docs.facts({ cwd: project, org: c2.cfg.source.mode === 'local' ? false : c2.cfg.org.alias }));
333
+ continue;
334
+ }
335
+
336
+ st = await evaluate(project, args);
337
+ return finish(args, st, [], [], null);
338
+ }
339
+ log.fail('doctor stopped after 8 rounds: run it again.');
340
+ return 1;
341
+ } finally {
342
+ if (rl) rl.close();
343
+ }
344
+ }
345
+
346
+ async function quietly(json, fn) {
347
+ if (!json) return fn();
348
+ const orig = console.log; console.log = () => {};
349
+ try { return await fn(); } finally { console.log = orig; }
350
+ }
351
+
352
+ function finish(args, st, framing, facts, next) {
353
+ const ok = !next && st.list.every((x) => x.level !== 'error');
354
+ if (args.json) {
355
+ console.log(JSON.stringify({ ok, checks: st.list, questions: [...framing, ...facts], next: next || 'tadforce build' }, null, 2));
356
+ return ok ? 0 : 1;
357
+ }
358
+ for (const x of st.list) {
359
+ const icon = x.level === 'ok' ? c.green('✔') : x.level === 'warn' ? c.yellow('⚠') : c.red('✖');
360
+ console.log(`${icon} ${x.message}`);
361
+ if (x.fix && x.level !== 'ok') console.log(` ${x.human ? c.cyan('☞ your move: ') : c.dim('fix: ')}${x.fix}`);
362
+ }
363
+ if (ok) log.ok('Ready. Next: `tadforce build` (or /tadforce in your agent). Evidence first.');
364
+ else log.human(next || 'fix the points above, then run tadforce doctor again.');
365
+ return ok ? 0 : 1;
366
+ }