@0dai-dev/cli 4.2.0 → 4.3.5

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 (52) hide show
  1. package/README.md +98 -10
  2. package/bin/0dai.js +298 -60
  3. package/lib/commands/audit.js +13 -0
  4. package/lib/commands/auth.js +344 -98
  5. package/lib/commands/boneyard.js +44 -0
  6. package/lib/commands/ci.js +329 -0
  7. package/lib/commands/compliance.js +20 -0
  8. package/lib/commands/doctor.js +39 -1
  9. package/lib/commands/experience.js +5 -1
  10. package/lib/commands/feedback.js +92 -5
  11. package/lib/commands/gh.js +506 -0
  12. package/lib/commands/graph.js +78 -10
  13. package/lib/commands/heatmap.js +17 -0
  14. package/lib/commands/import_claude_code_agents.js +367 -0
  15. package/lib/commands/init.js +504 -28
  16. package/lib/commands/loop.js +108 -0
  17. package/lib/commands/mcp.js +410 -0
  18. package/lib/commands/models.js +27 -3
  19. package/lib/commands/paste.js +114 -0
  20. package/lib/commands/play.js +173 -0
  21. package/lib/commands/provider.js +69 -0
  22. package/lib/commands/quota.js +76 -0
  23. package/lib/commands/receipt.js +53 -0
  24. package/lib/commands/report.js +29 -2
  25. package/lib/commands/run.js +104 -7
  26. package/lib/commands/runner.js +527 -0
  27. package/lib/commands/session.js +1 -7
  28. package/lib/commands/standup.js +40 -0
  29. package/lib/commands/status.js +30 -1
  30. package/lib/commands/swarm.js +97 -4
  31. package/lib/commands/tui.js +81 -13
  32. package/lib/commands/upgrade.js +58 -0
  33. package/lib/commands/usage.js +87 -0
  34. package/lib/commands/vault.js +246 -0
  35. package/lib/onboarding.js +9 -3
  36. package/lib/shared.js +29 -14
  37. package/lib/utils/activation_telemetry.js +156 -0
  38. package/lib/utils/auth.js +1 -0
  39. package/lib/utils/canonical-counts.js +54 -0
  40. package/lib/utils/constants.js +7 -0
  41. package/lib/utils/diff-preview.js +192 -0
  42. package/lib/utils/identity.js +76 -18
  43. package/lib/utils/mcp-auth.js +607 -0
  44. package/lib/utils/plan.js +47 -2
  45. package/lib/utils/run_cost.js +91 -0
  46. package/lib/vault/cipher.js +125 -0
  47. package/lib/vault/identity.js +122 -0
  48. package/lib/vault/index.js +184 -0
  49. package/lib/vault/storage.js +84 -0
  50. package/lib/wizard.js +19 -12
  51. package/package.json +8 -4
  52. package/lib/tui/index.mjs +0 -34610
@@ -0,0 +1,506 @@
1
+ "use strict";
2
+
3
+ const shared = require("../shared");
4
+ const { log, D, R, fs, path, spawnSync } = shared;
5
+
6
+ const BRANCH_PROTECTION_RULES = {
7
+ required_status_checks: {
8
+ strict: true,
9
+ contexts: [],
10
+ checks: [
11
+ { context: "dirty-tree", app_id: -1 },
12
+ { context: "lint", app_id: -1 },
13
+ { context: "test", app_id: -1 },
14
+ { context: "file-size", app_id: -1 },
15
+ ],
16
+ },
17
+ enforce_admins: false,
18
+ required_pull_request_reviews: {
19
+ dismiss_stale_reviews: true,
20
+ require_code_owner_reviews: false,
21
+ required_approving_review_count: 1,
22
+ require_last_push_approval: true,
23
+ },
24
+ restrictions: {
25
+ users: [],
26
+ teams: [],
27
+ apps: [],
28
+ },
29
+ required_linear_history: true,
30
+ allow_force_pushes: false,
31
+ allow_deletions: false,
32
+ block_creations: false,
33
+ required_conversation_resolution: true,
34
+ lock_branch: false,
35
+ allow_fork_syncing: false,
36
+ };
37
+
38
+ const POLICY_FILES = [
39
+ {
40
+ rel: ".github/.0dai/branch-protection-rules.json",
41
+ content: () => JSON.stringify(BRANCH_PROTECTION_RULES, null, 2) + "\n",
42
+ },
43
+ {
44
+ rel: ".github/PULL_REQUEST_TEMPLATE.md",
45
+ content: () => [
46
+ "## What Changed",
47
+ "",
48
+ "- ",
49
+ "",
50
+ "## Linked Work",
51
+ "",
52
+ "- Closes #",
53
+ "",
54
+ "## Validation",
55
+ "",
56
+ "- [ ] Lint passed",
57
+ "- [ ] Tests passed",
58
+ "- [ ] Dirty-tree check passed",
59
+ "",
60
+ "## Release Impact",
61
+ "",
62
+ "- [ ] No release note needed",
63
+ "- [ ] Update `CHANGELOG.md`",
64
+ "- [ ] Add or update `release-notes/`",
65
+ "",
66
+ "## Rollback",
67
+ "",
68
+ "- Reversal: `git revert <merge-sha>`",
69
+ "- Data migration: none",
70
+ "",
71
+ ].join("\n"),
72
+ },
73
+ {
74
+ rel: ".github/workflows/ci.yml",
75
+ content: () => [
76
+ "name: CI",
77
+ "",
78
+ "on:",
79
+ " pull_request:",
80
+ " branches: [main]",
81
+ " push:",
82
+ " branches: [main]",
83
+ "",
84
+ "permissions:",
85
+ " contents: read",
86
+ "",
87
+ "jobs:",
88
+ " dirty-tree:",
89
+ " runs-on: ubuntu-latest",
90
+ " steps:",
91
+ " - uses: actions/checkout@v4",
92
+ " - name: Generated file guard",
93
+ " run: git diff --exit-code",
94
+ "",
95
+ " lint:",
96
+ " runs-on: ubuntu-latest",
97
+ " steps:",
98
+ " - uses: actions/checkout@v4",
99
+ " - uses: actions/setup-node@v4",
100
+ " if: hashFiles('package.json') != ''",
101
+ " with:",
102
+ " node-version: '20'",
103
+ " - uses: actions/setup-python@v5",
104
+ " if: hashFiles('pyproject.toml', 'requirements.txt', '**/*.py') != ''",
105
+ " with:",
106
+ " python-version: '3.12'",
107
+ " - name: Lint",
108
+ " run: |",
109
+ " if [ -f package.json ]; then npm run lint --if-present; fi",
110
+ " if find . -path './.git' -prune -o -name '*.py' -print -quit | grep -q .; then",
111
+ " python3 -m compileall -q .",
112
+ " fi",
113
+ "",
114
+ " test:",
115
+ " runs-on: ubuntu-latest",
116
+ " steps:",
117
+ " - uses: actions/checkout@v4",
118
+ " - uses: actions/setup-node@v4",
119
+ " if: hashFiles('package.json') != ''",
120
+ " with:",
121
+ " node-version: '20'",
122
+ " - uses: actions/setup-python@v5",
123
+ " if: hashFiles('pyproject.toml', 'requirements.txt', 'tests/**/*.py') != ''",
124
+ " with:",
125
+ " python-version: '3.12'",
126
+ " - name: Test",
127
+ " run: |",
128
+ " if [ -f package.json ]; then npm test --if-present; fi",
129
+ " if [ -d tests ] && find tests -name '*.py' -print -quit | grep -q .; then",
130
+ " python3 -m pytest",
131
+ " fi",
132
+ "",
133
+ " file-size:",
134
+ " runs-on: ubuntu-latest",
135
+ " steps:",
136
+ " - uses: actions/checkout@v4",
137
+ " - name: File size guard",
138
+ " run: |",
139
+ " if [ -x .githooks/check-file-size.sh ]; then",
140
+ " bash .githooks/check-file-size.sh",
141
+ " else",
142
+ " echo \"No .githooks/check-file-size.sh present; skipping.\"",
143
+ " fi",
144
+ "",
145
+ ].join("\n"),
146
+ },
147
+ {
148
+ rel: ".githooks/check-file-size.sh",
149
+ executable: true,
150
+ content: () => [
151
+ "#!/usr/bin/env bash",
152
+ "set -euo pipefail",
153
+ "",
154
+ "MAX_WARN=\"${MAX_WARN:-800}\"",
155
+ "MAX_FAIL=\"${MAX_FAIL:-2000}\"",
156
+ "WAIVER_TAG=\"# pragma: loc-waiver\"",
157
+ "mode=\"${1:-all}\"",
158
+ "",
159
+ "file_has_waiver() {",
160
+ " local file=\"$1\"",
161
+ " [ -f \"$file\" ] && head -n 32 \"$file\" 2>/dev/null | grep -qF \"$WAIVER_TAG\"",
162
+ "}",
163
+ "",
164
+ "if [ \"$mode\" = \"--staged\" ]; then",
165
+ " files=\"$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\\.(py|js|ts|tsx|jsx|sh)$' || true)\"",
166
+ "else",
167
+ " files=\"$(find . -path './.git' -prune -o -path './node_modules' -prune -o -type f \\( -name '*.py' -o -name '*.js' -o -name '*.ts' -o -name '*.tsx' -o -name '*.jsx' -o -name '*.sh' \\) -print 2>/dev/null || true)\"",
168
+ "fi",
169
+ "",
170
+ "[ -n \"$files\" ] || exit 0",
171
+ "",
172
+ "failed=0",
173
+ "while IFS= read -r file; do",
174
+ " [ -n \"$file\" ] || continue",
175
+ " [ -f \"$file\" ] || continue",
176
+ " lines=\"$(wc -l < \"$file\")\"",
177
+ " if [ \"$lines\" -ge \"$MAX_FAIL\" ] && ! file_has_waiver \"$file\"; then",
178
+ " printf 'FAIL %s: %s LOC >= %s\\n' \"$file\" \"$lines\" \"$MAX_FAIL\"",
179
+ " failed=$((failed + 1))",
180
+ " fi",
181
+ "done <<< \"$files\"",
182
+ "",
183
+ "[ \"$failed\" -eq 0 ] || exit 1",
184
+ "",
185
+ ].join("\n"),
186
+ },
187
+ {
188
+ rel: ".githooks/pre-commit",
189
+ executable: true,
190
+ content: () => [
191
+ "#!/usr/bin/env bash",
192
+ "set -euo pipefail",
193
+ "",
194
+ "repo_root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 0",
195
+ "git diff --cached --check",
196
+ "if [ -x \"$repo_root/.githooks/check-file-size.sh\" ]; then",
197
+ " bash \"$repo_root/.githooks/check-file-size.sh\" --staged",
198
+ "fi",
199
+ "if [ -f \"$repo_root/scripts/scan_secrets.py\" ]; then",
200
+ " python3 \"$repo_root/scripts/scan_secrets.py\" --target \"$repo_root\"",
201
+ "fi",
202
+ "",
203
+ ].join("\n"),
204
+ },
205
+ {
206
+ rel: "CHANGELOG.md",
207
+ content: () => [
208
+ "# Changelog",
209
+ "",
210
+ "All notable changes to this project are documented here.",
211
+ "",
212
+ "## Unreleased",
213
+ "",
214
+ "### Added",
215
+ "",
216
+ "- Initial 0dai project policy scaffold.",
217
+ "",
218
+ ].join("\n"),
219
+ },
220
+ {
221
+ rel: "CONTRIBUTING.md",
222
+ content: () => [
223
+ "# Contributing",
224
+ "",
225
+ "## Branch Flow",
226
+ "",
227
+ "1. Branch from `main`.",
228
+ "2. Keep changes focused and reviewable.",
229
+ "3. Open a pull request back to `main`.",
230
+ "4. Wait for required CI checks and review before merge.",
231
+ "",
232
+ "Use descriptive branch names such as `feat/<issue>-short-topic`,",
233
+ "`fix/<issue>-short-topic`, or `docs/<issue>-short-topic`.",
234
+ "",
235
+ "## Commits",
236
+ "",
237
+ "- Use an imperative summary under 72 characters.",
238
+ "- Reference the issue or task when one exists.",
239
+ "- Include the why in the body for non-trivial changes.",
240
+ "",
241
+ "## Local Checks",
242
+ "",
243
+ "Run the project lint and test commands from `ai/manifest/commands.yaml` when",
244
+ "they are present. The generated `.githooks/` path is activated by `0dai init`",
245
+ "with:",
246
+ "",
247
+ "```bash",
248
+ "git config core.hooksPath .githooks",
249
+ "```",
250
+ "",
251
+ ].join("\n"),
252
+ },
253
+ {
254
+ rel: "RELEASE_NOTES_TEMPLATE.md",
255
+ content: () => [
256
+ "# Release Notes: vX.Y.Z",
257
+ "",
258
+ "## Summary",
259
+ "",
260
+ "- ",
261
+ "",
262
+ "## Changes",
263
+ "",
264
+ "- ",
265
+ "",
266
+ "## Validation",
267
+ "",
268
+ "- ",
269
+ "",
270
+ "## Rollback",
271
+ "",
272
+ "- Revert the release commit or tag.",
273
+ "",
274
+ ].join("\n"),
275
+ },
276
+ { rel: "VERSION", content: () => "0.1.0\n" },
277
+ { rel: "release-notes/.gitkeep", content: () => "" },
278
+ ];
279
+
280
+ function parseOptions(args) {
281
+ const opts = { branch: "main", rules: "", dryRun: false };
282
+ for (let i = 0; i < args.length; i++) {
283
+ const arg = String(args[i] || "");
284
+ if (arg === "--branch" && args[i + 1]) {
285
+ opts.branch = String(args[++i]);
286
+ } else if ((arg === "--rules" || arg === "--input") && args[i + 1]) {
287
+ opts.rules = String(args[++i]);
288
+ } else if (arg === "--dry-run") {
289
+ opts.dryRun = true;
290
+ }
291
+ }
292
+ return opts;
293
+ }
294
+
295
+ function ensureDir(filePath) {
296
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
297
+ }
298
+
299
+ function writeIfMissing(target, rel, content, executable) {
300
+ const dest = path.join(target, rel);
301
+ if (fs.existsSync(dest)) return false;
302
+ ensureDir(dest);
303
+ fs.writeFileSync(dest, content, "utf8");
304
+ if (executable) fs.chmodSync(dest, 0o755);
305
+ return true;
306
+ }
307
+
308
+ function scaffoldGithubFlowPolicy(target) {
309
+ let created = 0;
310
+ for (const file of POLICY_FILES) {
311
+ if (writeIfMissing(target, file.rel, file.content(), !!file.executable)) created += 1;
312
+ }
313
+ return created;
314
+ }
315
+
316
+ function isGitRepo(target) {
317
+ const result = spawnSync("git", ["-C", target, "rev-parse", "--is-inside-work-tree"], {
318
+ encoding: "utf8",
319
+ stdio: ["ignore", "pipe", "ignore"],
320
+ });
321
+ return result.status === 0 && String(result.stdout || "").trim() === "true";
322
+ }
323
+
324
+ function hasGithubRemote(target) {
325
+ const result = spawnSync("git", ["-C", target, "remote", "get-url", "origin"], {
326
+ encoding: "utf8",
327
+ stdio: ["ignore", "pipe", "ignore"],
328
+ });
329
+ if (result.status !== 0) return false;
330
+ return /github\.com[:/]/.test(String(result.stdout || ""));
331
+ }
332
+
333
+ function ensureHooksPath(target) {
334
+ if (!isGitRepo(target)) return false;
335
+ const result = spawnSync("git", ["-C", target, "config", "core.hooksPath", ".githooks"], {
336
+ encoding: "utf8",
337
+ stdio: ["ignore", "pipe", "pipe"],
338
+ });
339
+ return result.status === 0;
340
+ }
341
+
342
+ function getHooksPath(target) {
343
+ if (!isGitRepo(target)) return null;
344
+ const result = spawnSync("git", ["-C", target, "config", "--get", "core.hooksPath"], {
345
+ encoding: "utf8",
346
+ stdio: ["ignore", "pipe", "ignore"],
347
+ });
348
+ if (result.status !== 0) return "";
349
+ return String(result.stdout || "").trim();
350
+ }
351
+
352
+ function warnHooksPathDrift(target) {
353
+ const hooksPath = getHooksPath(target);
354
+ if (hooksPath === null || hooksPath === ".githooks") return false;
355
+ const current = hooksPath || "not set";
356
+ log(`warning: core.hooksPath is ${current}; expected .githooks for 0dai git policy`);
357
+ console.log(` ${D}Run: git config core.hooksPath .githooks${R}`);
358
+ return true;
359
+ }
360
+
361
+ function defaultRulesPath(target) {
362
+ return path.join(target, ".github", ".0dai", "branch-protection-rules.json");
363
+ }
364
+
365
+ function findInstallGitPolicyScript(target) {
366
+ const repoRoot = path.resolve(__dirname, "..", "..", "..", "..");
367
+ const candidates = [
368
+ path.join(target, "bootstrap", "install_git_policy.sh"),
369
+ path.join(process.cwd(), "bootstrap", "install_git_policy.sh"),
370
+ path.join(repoRoot, "bootstrap", "install_git_policy.sh"),
371
+ ];
372
+ return candidates.find((candidate) => fs.existsSync(candidate)) || "";
373
+ }
374
+
375
+ function runBootstrapInstaller(target) {
376
+ if (process.env.ODAI_GIT_POLICY_JS_ONLY === "1") return false;
377
+ const script = findInstallGitPolicyScript(target);
378
+ if (!script) return false;
379
+ const result = spawnSync("bash", [script, "--target", target], {
380
+ encoding: "utf8",
381
+ stdio: ["ignore", "pipe", "pipe"],
382
+ });
383
+ if (result.status !== 0) {
384
+ const reason = String(result.stderr || result.stdout || "unknown error").trim().split("\n").pop();
385
+ log(`git policy installer skipped: ${reason}`);
386
+ return false;
387
+ }
388
+ return true;
389
+ }
390
+
391
+ function applyBranchProtection(target, options = {}) {
392
+ const branch = options.branch || "main";
393
+ const rules = options.rules || defaultRulesPath(target);
394
+ const failOnError = !!options.failOnError;
395
+ const dryRun = !!options.dryRun;
396
+ const inherit = !!options.inherit;
397
+
398
+ if (!fs.existsSync(rules)) {
399
+ const message = `branch protection rules not found: ${rules}`;
400
+ if (failOnError) throw new Error(message);
401
+ return { ok: false, skipped: true, reason: message };
402
+ }
403
+ if (!hasGithubRemote(target)) {
404
+ const message = "origin is not a GitHub remote";
405
+ if (failOnError) throw new Error(message);
406
+ return { ok: false, skipped: true, reason: message };
407
+ }
408
+ if (spawnSync("gh", ["--version"], { stdio: "ignore" }).status !== 0) {
409
+ const message = "gh CLI not found";
410
+ if (failOnError) throw new Error(message);
411
+ return { ok: false, skipped: true, reason: message };
412
+ }
413
+
414
+ const endpoint = `repos/:owner/:repo/branches/${branch}/protection`;
415
+ if (dryRun) {
416
+ console.log(`gh api ${endpoint} -X PUT --input ${rules}`);
417
+ return { ok: true, dryRun: true };
418
+ }
419
+
420
+ const result = spawnSync("gh", ["api", endpoint, "-X", "PUT", "--input", rules], {
421
+ cwd: target,
422
+ encoding: "utf8",
423
+ stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"],
424
+ });
425
+ if (result.status === 0) return { ok: true };
426
+
427
+ const message = String(result.stderr || result.stdout || "gh api failed").trim();
428
+ if (failOnError) throw new Error(message);
429
+ return { ok: false, skipped: false, reason: message };
430
+ }
431
+
432
+ function printBranchProtection(target, options = {}) {
433
+ const branch = options.branch || "main";
434
+ if (!hasGithubRemote(target)) throw new Error("origin is not a GitHub remote");
435
+ if (spawnSync("gh", ["--version"], { stdio: "ignore" }).status !== 0) throw new Error("gh CLI not found");
436
+ const result = spawnSync("gh", ["api", `repos/:owner/:repo/branches/${branch}/protection`, "-X", "GET"], {
437
+ cwd: target,
438
+ encoding: "utf8",
439
+ stdio: ["ignore", "pipe", "pipe"],
440
+ });
441
+ if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || "gh api failed").trim());
442
+ process.stdout.write(result.stdout);
443
+ }
444
+
445
+ function ensureGithubFlowPolicy(target, options = {}) {
446
+ if (process.env.ODAI_GIT_POLICY_SKIP === "1") return { installed: false, skipped: true };
447
+ if (runBootstrapInstaller(target)) return { installed: true, via: "bootstrap" };
448
+
449
+ const created = scaffoldGithubFlowPolicy(target);
450
+ ensureHooksPath(target);
451
+ let protection = null;
452
+ if (!options.skipApply) {
453
+ protection = applyBranchProtection(target, {
454
+ branch: options.branch || "main",
455
+ rules: options.rules || defaultRulesPath(target),
456
+ failOnError: false,
457
+ });
458
+ }
459
+ return { installed: true, via: "js", created, protection };
460
+ }
461
+
462
+ async function cmdGh(target, sub, args = []) {
463
+ if (sub !== "branch-protection") {
464
+ console.log("Usage: 0dai gh branch-protection [print|apply] [--branch main] [--rules FILE] [--dry-run]");
465
+ process.exitCode = 1;
466
+ return;
467
+ }
468
+
469
+ const rest = args.slice(2);
470
+ const action = rest[0] && !String(rest[0]).startsWith("-") ? String(rest[0]) : "print";
471
+ const options = parseOptions(rest);
472
+
473
+ try {
474
+ if (action === "apply") {
475
+ scaffoldGithubFlowPolicy(target);
476
+ ensureHooksPath(target);
477
+ applyBranchProtection(target, { ...options, failOnError: true, inherit: !options.dryRun });
478
+ if (!options.dryRun) log(`applied branch protection to ${options.branch || "main"}`);
479
+ return;
480
+ }
481
+ if (action === "print" || action === "show" || action === "current") {
482
+ printBranchProtection(target, options);
483
+ return;
484
+ }
485
+ if (action === "install") {
486
+ const result = ensureGithubFlowPolicy(target, { skipApply: true });
487
+ log(`installed git policy files${result.created ? ` (${result.created} created)` : ""}`);
488
+ return;
489
+ }
490
+ console.log("Usage: 0dai gh branch-protection [print|apply|install] [--branch main] [--rules FILE] [--dry-run]");
491
+ process.exitCode = 1;
492
+ } catch (err) {
493
+ log(`branch-protection ${action} failed: ${err.message || err}`);
494
+ console.log(` ${D}Rules: ${options.rules || defaultRulesPath(target)}${R}`);
495
+ process.exitCode = 1;
496
+ }
497
+ }
498
+
499
+ module.exports = {
500
+ BRANCH_PROTECTION_RULES,
501
+ cmdGh,
502
+ ensureGithubFlowPolicy,
503
+ scaffoldGithubFlowPolicy,
504
+ applyBranchProtection,
505
+ warnHooksPathDrift,
506
+ };
@@ -2,6 +2,63 @@
2
2
  const shared = require("../shared");
3
3
  const { log, T, R, D, fs, path, apiCall, AUTH_FILE, buildProjectIdentity, collectMetadata, recordExperienceEvent } = shared;
4
4
 
5
+ function graphAuthToken(auth) {
6
+ return String((auth && (auth.api_key || auth.access_token || auth.token)) || "").trim();
7
+ }
8
+
9
+ function isPaidGraphPlan(auth) {
10
+ const plan = String((auth && auth.plan) || "free").toLowerCase();
11
+ return ["pro", "team", "enterprise"].includes(plan);
12
+ }
13
+
14
+ function graphPayloadForPush(localGraph, auth) {
15
+ const nodes = localGraph.nodes || {};
16
+ const localEdges = localGraph.edges || [];
17
+ const edges = isPaidGraphPlan(auth) ? localEdges : [];
18
+ return {
19
+ nodes,
20
+ edges,
21
+ localEdgeCount: localEdges.length,
22
+ edgesLocalOnly: localEdges.length - edges.length,
23
+ };
24
+ }
25
+
26
+ function readStructuralMemorySummary(target) {
27
+ const script = shared.findRepoScript(target, "structural_memory.py");
28
+ if (!script) return null;
29
+ const result = shared.spawnSync("python3", [script, "--target", target, "--json"], {
30
+ encoding: "utf8",
31
+ timeout: 10000,
32
+ });
33
+ if (result.status !== 0 || !result.stdout) return null;
34
+ try {
35
+ return JSON.parse(result.stdout);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function formatStructuralMemorySummary(summary) {
42
+ if (!summary || typeof summary !== "object") return [];
43
+ const surfaces = summary.surfaces || {};
44
+ const roles = summary.roles || {};
45
+ const parts = [
46
+ `records=${summary.total_records || 0}`,
47
+ `memory=${surfaces.memory || 0}`,
48
+ `graph=${surfaces.graph || 0}`,
49
+ `experience=${surfaces.experience || 0}`,
50
+ ];
51
+ const roleParts = Object.entries(roles)
52
+ .filter(([, count]) => Number(count) > 0)
53
+ .slice(0, 6)
54
+ .map(([role, count]) => `${role}:${count}`);
55
+ const lines = [`Structural memory: ${parts.join(", ")}`];
56
+ if (roleParts.length) lines.push(` roles: ${roleParts.join(", ")}`);
57
+ const driftCount = Number(summary.drift_warning_count || 0);
58
+ if (driftCount > 0) lines.push(` drift warnings: ${driftCount}`);
59
+ return lines;
60
+ }
61
+
5
62
  async function cmdGraph(target, sub, args) {
6
63
  const graphFile = path.join(target, "ai", "manifest", "project_graph.json");
7
64
 
@@ -9,7 +66,7 @@ async function cmdGraph(target, sub, args) {
9
66
  // Check auth
10
67
  let auth;
11
68
  try { auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8")); } catch {}
12
- if (!auth || !auth.access_token) {
69
+ if (!graphAuthToken(auth)) {
13
70
  log("Graph push requires an account. Run: 0dai auth login");
14
71
  return;
15
72
  }
@@ -25,11 +82,11 @@ async function cmdGraph(target, sub, args) {
25
82
  return;
26
83
  }
27
84
 
28
- const nodes = localGraph.nodes || {};
29
- const edges = localGraph.edges || [];
85
+ const { nodes, edges, localEdgeCount, edgesLocalOnly } = graphPayloadForPush(localGraph, auth);
30
86
  const identity = buildProjectIdentity(target, collectMetadata(target));
31
87
 
32
- log(`Pushing graph (${Object.keys(nodes).length} nodes, ${edges.length} edges)...`);
88
+ const edgeSuffix = edgesLocalOnly > 0 ? `, ${edgesLocalOnly} local-only edges held back` : "";
89
+ log(`Pushing graph (${Object.keys(nodes).length} nodes, ${edges.length} edges${edgeSuffix})...`);
33
90
  const result = await apiCall("/v1/graph/sync", {
34
91
  project_id: identity.project_id,
35
92
  nodes,
@@ -58,7 +115,7 @@ async function cmdGraph(target, sub, args) {
58
115
  model: "0dai-cli",
59
116
  effort: "medium",
60
117
  task: { goal: "push graph to server", task_type: "feat", result: "success", elapsed_seconds: 0, cost_usd: 0 },
61
- context: { stack: "unknown", files_touched: 1, tests_passed: true, graph_nodes_used: Object.keys(nodes).length, graph_edges_used: edges.length },
118
+ context: { stack: "unknown", files_touched: 1, tests_passed: true, graph_nodes_used: Object.keys(nodes).length, graph_edges_used: edges.length, graph_edges_local_only: localEdgeCount - edges.length },
62
119
  });
63
120
  return;
64
121
  }
@@ -66,7 +123,7 @@ async function cmdGraph(target, sub, args) {
66
123
  if (sub === "pull") {
67
124
  let auth;
68
125
  try { auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8")); } catch {}
69
- if (!auth || !auth.access_token) {
126
+ if (!graphAuthToken(auth)) {
70
127
  log("Graph pull requires an account. Run: 0dai auth login");
71
128
  return;
72
129
  }
@@ -148,11 +205,15 @@ async function cmdGraph(target, sub, args) {
148
205
  }
149
206
 
150
207
  log(`Local graph: ${localNodes} nodes, ${localEdges} edges`);
208
+ const structuralSummary = readStructuralMemorySummary(target);
209
+ for (const line of formatStructuralMemorySummary(structuralSummary)) {
210
+ log(line);
211
+ }
151
212
 
152
213
  // Check plan for edge sync status
153
214
  let auth;
154
215
  try { auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8")); } catch {}
155
- if (auth && auth.access_token) {
216
+ if (graphAuthToken(auth)) {
156
217
  const plan = (auth.plan || "free").toLowerCase();
157
218
  const edgeStatus = plan === "free" ? `${D}nodes only (Pro for edges)${R}` : `${T}full sync${R}`;
158
219
  log(`Server sync: ${edgeStatus}`);
@@ -174,7 +235,7 @@ async function cmdGraph(target, sub, args) {
174
235
  const nodeIdx = args.indexOf("--node");
175
236
  const nodeId = nodeIdx >= 0 ? args[nodeIdx + 1] : null;
176
237
 
177
- if (["pro", "team", "enterprise"].includes(plan) && auth.access_token) {
238
+ if (["pro", "team", "enterprise"].includes(plan) && graphAuthToken(auth)) {
178
239
  const identity = buildProjectIdentity(target, collectMetadata(target));
179
240
  const params = new URLSearchParams({
180
241
  project_id: identity.project_id,
@@ -236,7 +297,7 @@ async function cmdGraph(target, sub, args) {
236
297
  log(`${D}Outcomes require Pro plan. Upgrade: 0dai upgrade${R}`);
237
298
  return;
238
299
  }
239
- if (!auth.access_token) {
300
+ if (!graphAuthToken(auth)) {
240
301
  log("Graph outcomes requires an account. Run: 0dai auth login");
241
302
  return;
242
303
  }
@@ -267,4 +328,11 @@ async function cmdGraph(target, sub, args) {
267
328
  console.log(" outcomes Show outcome analytics (Pro only)");
268
329
  }
269
330
 
270
- module.exports = { cmdGraph };
331
+ module.exports = {
332
+ cmdGraph,
333
+ graphAuthToken,
334
+ graphPayloadForPush,
335
+ isPaidGraphPlan,
336
+ readStructuralMemorySummary,
337
+ formatStructuralMemorySummary,
338
+ };
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ const shared = require("../shared");
3
+ const { log, spawnSync, findRepoScript } = shared;
4
+
5
+ function cmdHeatmap(target, args) {
6
+ const script = findRepoScript(target, "heatmap.py");
7
+ if (!script) {
8
+ log("heatmap script unavailable in this environment");
9
+ return;
10
+ }
11
+
12
+ const forwarded = [script, "--target", target, ...(args || [])];
13
+ const result = spawnSync("python3", forwarded, { stdio: "inherit" });
14
+ if (typeof result.status === "number" && result.status !== 0) process.exit(result.status);
15
+ }
16
+
17
+ module.exports = { cmdHeatmap };