ltcai 10.6.2 → 10.7.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 (85) hide show
  1. package/README.md +44 -59
  2. package/docs/CHANGELOG.md +56 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/LAYOUT_REBUILD_SPEC.md +810 -0
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +29 -1
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/runtime/multi_agent.py +1 -1
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +72 -0
  15. package/latticeai/api/automation_intelligence.py +112 -0
  16. package/latticeai/api/knowledge_graph.py +133 -0
  17. package/latticeai/api/static_routes.py +35 -1
  18. package/latticeai/core/legacy_compatibility.py +1 -1
  19. package/latticeai/core/marketplace.py +1 -1
  20. package/latticeai/core/workspace_os.py +3 -0
  21. package/latticeai/core/workspace_os_constants.py +1 -1
  22. package/latticeai/core/workspace_runs.py +111 -0
  23. package/latticeai/services/architecture_readiness.py +1 -1
  24. package/latticeai/services/product_readiness.py +1 -1
  25. package/package.json +5 -3
  26. package/scripts/capture_release_evidence.mjs +335 -1
  27. package/scripts/check_current_release_docs.mjs +1 -1
  28. package/scripts/check_release_evidence_bound.mjs +116 -0
  29. package/scripts/check_screenshot_pixel_delta.py +435 -0
  30. package/scripts/measure_brain_home_fill.mjs +141 -0
  31. package/src-tauri/Cargo.lock +1 -1
  32. package/src-tauri/Cargo.toml +1 -1
  33. package/src-tauri/tauri.conf.json +1 -1
  34. package/static/app/asset-manifest.json +37 -39
  35. package/static/app/assets/{Act-aNud-lKL.js → Act-DIbkoJqs.js} +1 -1
  36. package/static/app/assets/AdminConsole-Cbi6Jre6.js +1 -0
  37. package/static/app/assets/{Brain-CMFh5q6k.js → Brain-BPfuXZ71.js} +2 -2
  38. package/static/app/assets/BrainHome-87XaAS4V.js +2 -0
  39. package/static/app/assets/{BrainSignals-RVMlmTGC.js → BrainSignals-ExpcdiUi.js} +1 -1
  40. package/static/app/assets/Capture-Bxe4WiVX.js +1 -0
  41. package/static/app/assets/{CommandPalette-bW5WxWWZ.js → CommandPalette-B1PV5Yd4.js} +1 -1
  42. package/static/app/assets/Library-BRlJJmwZ.js +1 -0
  43. package/static/app/assets/{LivingBrain-FlHFfu9i.js → LivingBrain-D61qG3WY.js} +1 -1
  44. package/static/app/assets/ProductFlow-BxjxI8Aa.js +1 -0
  45. package/static/app/assets/{ReviewCard-BHj86h2Z.js → ReviewCard-BoiWv0Y1.js} +2 -2
  46. package/static/app/assets/System-Ci0HEtTJ.js +1 -0
  47. package/static/app/assets/arrow-left-DArdBUxd.js +1 -0
  48. package/static/app/assets/{bot-D1gX4xks.js → bot-BaPt4PPn.js} +1 -1
  49. package/static/app/assets/brain-BSkWaj3i.js +1 -0
  50. package/static/app/assets/{button-DPFZ9lGw.js → button-CKwtO1O0.js} +1 -1
  51. package/static/app/assets/{circle-pause-B2P72YOO.js → circle-pause-CiUc5RRk.js} +1 -1
  52. package/static/app/assets/{circle-play-BiC8z2vg.js → circle-play-BaTDxzR9.js} +1 -1
  53. package/static/app/assets/{cpu-Cx-wRR_V.js → cpu-Dm1gHuP1.js} +1 -1
  54. package/static/app/assets/{download-CKxlzxsK.js → download-C32oGU-C.js} +1 -1
  55. package/static/app/assets/{folder-open-Bbjju0tt.js → folder-open-Ch2jLVdD.js} +1 -1
  56. package/static/app/assets/{hard-drive-Bon1VBvq.js → hard-drive-BBjA5st8.js} +1 -1
  57. package/static/app/assets/index-BV7Yf6Eq.css +2 -0
  58. package/static/app/assets/{index-mLP0-YNO.js → index-J6h01X78.js} +4 -4
  59. package/static/app/assets/{input-DrMc0Xns.js → input-y6ABdKWY.js} +1 -1
  60. package/static/app/assets/{permissionCopy-BGUsI7vw.js → permissionCopy-BEtc0Ihd.js} +1 -1
  61. package/static/app/assets/{primitives-CEMTjBz1.js → primitives-GSFXF2Pd.js} +1 -1
  62. package/static/app/assets/search-CaIZKyo_.js +1 -0
  63. package/static/app/assets/{share-2-D7THHq5K.js → share-2-BgGq2A63.js} +1 -1
  64. package/static/app/assets/{shield-alert-BoU4_8r9.js → shield-alert-BYA491cj.js} +1 -1
  65. package/static/app/assets/{textarea-rU1Lb7ia.js → textarea-BAcim3fs.js} +1 -1
  66. package/static/app/assets/{useFocusTrap-BgvK4Nkx.js → useFocusTrap-qLg24jiv.js} +1 -1
  67. package/static/app/assets/{useQuery-CT2ChyuU.js → useQuery-xkAu9eDX.js} +1 -1
  68. package/static/app/assets/utils-CogdofAA.js +4 -0
  69. package/static/app/assets/workspace-BMvfx9_R.js +1 -0
  70. package/static/app/index.html +4 -4
  71. package/static/sw.js +1 -1
  72. package/static/app/assets/AdminConsole-QiCTH68K.js +0 -1
  73. package/static/app/assets/BrainHome-B4Ar3atu.js +0 -2
  74. package/static/app/assets/Capture-DEBo-0vZ.js +0 -1
  75. package/static/app/assets/Library-5gFexm83.js +0 -1
  76. package/static/app/assets/ProductFlow-CFUNDOHu.js +0 -1
  77. package/static/app/assets/System-CeNHoZRu.js +0 -1
  78. package/static/app/assets/activity-7_ZmZqN0.js +0 -1
  79. package/static/app/assets/arrow-left-Dv2Tiwhe.js +0 -1
  80. package/static/app/assets/brain-Dn_bDfl4.js +0 -1
  81. package/static/app/assets/index-DYUs0cWy.css +0 -2
  82. package/static/app/assets/search-HsIji1wY.js +0 -1
  83. package/static/app/assets/users-DlbfHBQV.js +0 -1
  84. package/static/app/assets/utils-fEGWreKB.js +0 -7
  85. package/static/app/assets/workspace-ClDBz_0f.js +0 -1
@@ -434,6 +434,117 @@ class WorkspaceRuns:
434
434
  runs = [run for run in runs if run.get("workflow_id") == workflow_id]
435
435
  return {"runs": list(reversed(runs[-max(1, min(limit, 300)):]))}
436
436
 
437
+ def list_combined_runs(
438
+ self,
439
+ *,
440
+ limit: int = 20,
441
+ workspace_id: Optional[str] = None,
442
+ ) -> Dict[str, Any]:
443
+ """Merge agent + workflow runs into one ``started_at``-descending timeline.
444
+
445
+ Response shape is the product contract for Act screen 09 and the
446
+ layout-rebuild activity feed: each row carries ``source``, a human
447
+ ``title``, stop/resume affordances, and normalized timestamps.
448
+ """
449
+ capped = max(1, min(int(limit or 20), 100))
450
+ state = self.load_state()
451
+ agent_runs = self._scoped(_listify(state.get("agent_runs")), workspace_id)
452
+ workflow_runs = self._scoped(_listify(state.get("workflow_runs")), workspace_id)
453
+ combined: List[Dict[str, Any]] = []
454
+ for run in agent_runs:
455
+ combined.append(self.activity_run_row(run, source="agent"))
456
+ for run in workflow_runs:
457
+ combined.append(self.activity_run_row(run, source="workflow"))
458
+ combined.sort(key=lambda row: str(row.get("started_at") or ""), reverse=True)
459
+ total = len(combined)
460
+ return {
461
+ "runs": combined[:capped],
462
+ "total": total,
463
+ "truncated": total > capped,
464
+ }
465
+
466
+ @staticmethod
467
+ def _first_string(*values: Any) -> str:
468
+ for value in values:
469
+ if isinstance(value, str) and value.strip():
470
+ return value.strip()
471
+ return ""
472
+
473
+ @classmethod
474
+ def activity_run_title(cls, run: Dict[str, Any]) -> str:
475
+ """Public title resolver shared by list_combined_runs and router fallbacks."""
476
+ direct = cls._first_string(
477
+ run.get("workflow_name"),
478
+ run.get("name"),
479
+ run.get("goal"),
480
+ run.get("title"),
481
+ run.get("query"),
482
+ )
483
+ if direct:
484
+ return direct
485
+ raw_input = run.get("input")
486
+ if isinstance(raw_input, str) and raw_input.strip():
487
+ return raw_input.strip()
488
+ if isinstance(raw_input, dict):
489
+ nested = cls._first_string(
490
+ raw_input.get("goal"),
491
+ raw_input.get("name"),
492
+ raw_input.get("title"),
493
+ raw_input.get("query"),
494
+ raw_input.get("text"),
495
+ raw_input.get("prompt"),
496
+ )
497
+ if nested:
498
+ return nested
499
+ return ""
500
+
501
+ @classmethod
502
+ def activity_run_row(cls, run: Dict[str, Any], *, source: str) -> Dict[str, Any]:
503
+ """Normalize an agent or workflow run into the Act activity-feed row shape.
504
+
505
+ Public so API routers (e.g. automation_intelligence fallback) can reuse
506
+ the same contract without reaching for a private method.
507
+
508
+ ``source`` is the product discriminator for the 레시피/목표 badge.
509
+ Legacy agent runs may lack ``agent_id`` (and both ids may be None) —
510
+ those rows must still leave this method with ``source="agent"`` so the
511
+ Act feed does not collapse every goal row into a recipe badge.
512
+ """
513
+ status = str(run.get("status") or "")
514
+ started_at = (
515
+ run.get("started_at")
516
+ or run.get("created_at")
517
+ or run.get("updated_at")
518
+ or ""
519
+ )
520
+ finished_at = (
521
+ run.get("finished_at")
522
+ or run.get("completed_at")
523
+ or run.get("resolved_at")
524
+ or run.get("interrupted_at")
525
+ )
526
+ run_id = run.get("id") or run.get("run_id")
527
+ normalized_source = str(source or "").strip().lower()
528
+ if normalized_source not in {"agent", "workflow"}:
529
+ # Infer only when the caller failed to pin a valid source; prefer
530
+ # workflow_id when present, otherwise agent (including bare legacy).
531
+ if run.get("workflow_id"):
532
+ normalized_source = "workflow"
533
+ else:
534
+ normalized_source = "agent"
535
+ return {
536
+ "id": run_id,
537
+ "source": normalized_source,
538
+ "title": cls.activity_run_title(run),
539
+ "status": status,
540
+ "started_at": started_at,
541
+ "finished_at": finished_at,
542
+ "can_stop": status in RUN_ACTIVE_STATUSES,
543
+ "can_resume": status in {"awaiting_approval", "paused", "waiting_approval"},
544
+ "workflow_id": run.get("workflow_id"),
545
+ "agent_id": run.get("agent_id"),
546
+ }
547
+
437
548
  def mark_workflow_run_resolved(
438
549
  self, run_id: str, *, resumed_run_id: str, approved: bool,
439
550
  workspace_id: Optional[str] = None,
@@ -16,7 +16,7 @@ from typing import Any, Dict, List
16
16
 
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
- ARCHITECTURE_VERSION_TARGET = "10.6.2"
19
+ ARCHITECTURE_VERSION_TARGET = "10.7.0"
20
20
 
21
21
  PREFERRED_REFACTORING_ORDER = [
22
22
  "agent-runtime",
@@ -18,7 +18,7 @@ from typing import Any, Dict, List
18
18
 
19
19
  from latticeai.services.architecture_readiness import architecture_readiness
20
20
 
21
- PRODUCT_VERSION_TARGET = "10.6.2"
21
+ PRODUCT_VERSION_TARGET = "10.7.0"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "10.6.2",
3
+ "version": "10.7.0",
4
4
  "description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -24,7 +24,8 @@
24
24
  "build:assets": "vite build && node scripts/build_frontend_assets.mjs",
25
25
  "build:python": "node scripts/run_python.mjs -m build",
26
26
  "check:python": "node scripts/run_python.mjs scripts/check_python.py",
27
- "lint": "npm run lint:python && node --check tests/visual/mock_server.cjs && node --check tests/visual/v3.spec.js && npm run lint:frontend && npm run frontend:openapi:check && node scripts/check_i18n_literals.mjs && npm run check:i18n-namespaces && npm run check:bundle && npm run check:legacy-debt && npm run test:browser-extension && npm run test:vscode-extension",
27
+ "lint": "npm run lint:python && node --check tests/visual/mock_server.cjs && node --check tests/visual/v3.spec.js && npm run lint:frontend && npm run frontend:openapi:check && node scripts/check_i18n_literals.mjs && npm run check:i18n-namespaces && npm run check:bundle && npm run check:legacy-debt && npm run test:browser-extension && npm run test:vscode-extension && node scripts/check_release_evidence_bound.mjs",
28
+ "check:release-evidence-bound": "node scripts/check_release_evidence_bound.mjs",
28
29
  "check:legacy-debt": "node scripts/check_legacy_debt.mjs",
29
30
  "lint:python": "node scripts/run_python.mjs -m ruff check . && npm run typecheck:python",
30
31
  "lint:frontend": "node scripts/lint_frontend.mjs",
@@ -48,7 +49,8 @@
48
49
  "desktop:tauri:check": "cd src-tauri && cargo check",
49
50
  "desktop:electron": "electron desktop/electron/main.cjs",
50
51
  "package:vsix": "node scripts/build_vsix.mjs",
51
- "release:evidence": "node scripts/capture_release_evidence.mjs && node scripts/prune_release_evidence.mjs",
52
+ "check:screenshot-delta": "node scripts/run_python.mjs scripts/check_screenshot_pixel_delta.py",
53
+ "release:evidence": "node scripts/capture_release_evidence.mjs && node scripts/prune_release_evidence.mjs && npm run check:screenshot-delta",
52
54
  "release:evidence:prune": "node scripts/prune_release_evidence.mjs",
53
55
  "release:artifacts": "node scripts/clean_release_artifacts.mjs $npm_package_version && npm run build:assets && npm run build:python && npm pack && npm run package:vsix && npm run desktop:tauri:build",
54
56
  "release:validate": "node scripts/run_python.mjs scripts/validate_release_artifacts.py $npm_package_version --require-vsix --require-tgz --require-dmg",
@@ -1,4 +1,5 @@
1
1
  import { chromium } from "playwright";
2
+ import { createHash } from "node:crypto";
2
3
  import { execFileSync, spawn } from "node:child_process";
3
4
  import fs from "node:fs";
4
5
  import path from "node:path";
@@ -7,10 +8,243 @@ const repoRoot = process.cwd();
7
8
  const version = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version;
8
9
  const port = Number(process.env.LTCAI_RELEASE_EVIDENCE_PORT || 4936);
9
10
  const baseURL = `http://127.0.0.1:${port}`;
10
- const root = path.join(repoRoot, "output", "release", `v${version}`);
11
+ // Defaults to the published directory for this version, but can be redirected.
12
+ // This script starts by deleting `root` outright, so anything that wants a
13
+ // preview capture — a reviewer checking whether the UI actually moved before
14
+ // the version is bumped — would otherwise have to overwrite the screenshots of
15
+ // an already-released version to get one.
16
+ const root =
17
+ process.env.LTCAI_RELEASE_EVIDENCE_DIR ||
18
+ path.join(repoRoot, "output", "release", `v${version}`);
11
19
  const screenshots = path.join(root, "screenshots");
12
20
  const videos = path.join(root, "videos");
13
21
  const gifs = path.join(root, "gifs");
22
+ const manifestPath = path.join(repoRoot, "static", "app", "asset-manifest.json");
23
+
24
+ function assetManifestFingerprint() {
25
+ if (!fs.existsSync(manifestPath)) {
26
+ return null;
27
+ }
28
+ const body = fs.readFileSync(manifestPath);
29
+ const stat = fs.statSync(manifestPath);
30
+ return {
31
+ sha256: createHash("sha256").update(body).digest("hex"),
32
+ mtime: stat.mtime.toISOString(),
33
+ bytes: body.length,
34
+ };
35
+ }
36
+
37
+ const mockServerPath = path.join(repoRoot, "tests", "visual", "mock_server.cjs");
38
+
39
+ function mockServerFingerprint() {
40
+ if (!fs.existsSync(mockServerPath)) {
41
+ return null;
42
+ }
43
+ const body = fs.readFileSync(mockServerPath);
44
+ const stat = fs.statSync(mockServerPath);
45
+ return {
46
+ sha256: createHash("sha256").update(body).digest("hex"),
47
+ mtime: stat.mtime.toISOString(),
48
+ bytes: body.length,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Refuse to overwrite published release screenshots for an already-tagged version.
54
+ *
55
+ * Failure mode this catches: package.json is still 10.6.3, tag v10.6.3 exists
56
+ * (the shipped release), and capture writes into output/release/v10.6.3/,
57
+ * silently replacing the public screenshots of a released version with WIP UI.
58
+ * Preview captures must use LTCAI_RELEASE_EVIDENCE_DIR (any path other than the
59
+ * default published dir). To land new evidence under output/release/, bump the
60
+ * package version first so the tag name no longer collides.
61
+ *
62
+ * Guard runs *before* wiping `root`, so a tagged release is never deleted by
63
+ * an unbumped recapture.
64
+ */
65
+ function assertNotOverwritingTaggedRelease() {
66
+ const defaultRoot = path.join(repoRoot, "output", "release", `v${version}`);
67
+ const writingToPublished = path.resolve(root) === path.resolve(defaultRoot);
68
+ if (!writingToPublished) {
69
+ // Explicit LTCAI_RELEASE_EVIDENCE_DIR redirect (preview) — allowed even
70
+ // when the version tag already exists.
71
+ return;
72
+ }
73
+
74
+ let tagExists = false;
75
+ try {
76
+ execFileSync("git", ["rev-parse", "--verify", `refs/tags/v${version}`], {
77
+ cwd: repoRoot,
78
+ stdio: "pipe",
79
+ });
80
+ tagExists = true;
81
+ } catch {
82
+ tagExists = false;
83
+ }
84
+ if (!tagExists) {
85
+ return;
86
+ }
87
+
88
+ console.error(
89
+ `release evidence: git tag v${version} already exists.\n` +
90
+ ` Refusing to overwrite ${path.relative(repoRoot, defaultRoot)}/ — that would\n` +
91
+ ` replace the published screenshots of an already-released version with WIP UI.\n` +
92
+ ` Bump the package version first (e.g. node scripts/run_python.mjs scripts/bump_version.py),\n` +
93
+ ` or set LTCAI_RELEASE_EVIDENCE_DIR to a non-published path for a preview capture.`,
94
+ );
95
+ process.exit(1);
96
+ }
97
+
98
+ /**
99
+ * Refuse to capture when the release lint gate is red.
100
+ *
101
+ * Failure mode this catches: lint is failing (e.g. ruff F841) but
102
+ * capture still runs and writes 12 screenshots + SCREENSHOT_INDEX.md
103
+ * bound to the asset manifest — evidence that looks green while the
104
+ * first CI stage would already have stopped. Capture is not proof that
105
+ * the tree is releasable; lint must pass first.
106
+ *
107
+ * Guard runs *before* wiping `root`, so a red lint never deletes the
108
+ * last good evidence directory.
109
+ */
110
+ function assertLintClean() {
111
+ console.log("release evidence: running npm run lint before capture…");
112
+ try {
113
+ // Skip the evidence↔build binding gate during intentional recapture:
114
+ // this run is about to wipe and rebind SCREENSHOT_INDEX.md. Day-to-day
115
+ // `npm run lint` still hard-fails when screenshots are stale.
116
+ execFileSync("npm", ["run", "lint"], {
117
+ cwd: repoRoot,
118
+ stdio: "inherit",
119
+ env: { ...process.env, LTCAI_SKIP_RELEASE_EVIDENCE_BOUND: "1" },
120
+ });
121
+ } catch (err) {
122
+ const code = err && typeof err === "object" && "status" in err ? err.status : 1;
123
+ console.error(
124
+ "release evidence: npm run lint failed — capture aborted.\n" +
125
+ " Fix lint (ruff / frontend / openapi / i18n gates) before release:evidence.\n" +
126
+ ` exit status: ${code}`,
127
+ );
128
+ process.exit(typeof code === "number" && code !== 0 ? code : 1);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Refuse to capture when static/app is older than frontend/src.
134
+ *
135
+ * Failure mode this catches: someone edits frontend/src, skips
136
+ * `npm run build:assets`, then runs release:evidence. Screenshots look
137
+ * "fresh" (new mtimes) but still show the previous build. That shipped
138
+ * stale evidence in 10.6.3 (asset mtime later than screenshot mtime by
139
+ * 42–50s). Warn-and-continue is useless — exit 1 so CI and humans stop.
140
+ *
141
+ * Guard runs *before* wiping `root`, so a stale build never deletes the
142
+ * last good evidence directory.
143
+ */
144
+ function assertBuiltAssetsFresh() {
145
+ const srcRoot = path.join(repoRoot, "frontend", "src");
146
+ if (!fs.existsSync(manifestPath)) {
147
+ console.error(
148
+ "release evidence: static/app/asset-manifest.json 이 없습니다.\n" +
149
+ "실행 전에 npm run build:assets 를 돌려라.",
150
+ );
151
+ process.exit(1);
152
+ }
153
+ if (!fs.existsSync(srcRoot)) {
154
+ console.error(`release evidence: frontend/src 가 없습니다: ${srcRoot}`);
155
+ process.exit(1);
156
+ }
157
+
158
+ let newestSrcMtime = 0;
159
+ let newestSrcPath = srcRoot;
160
+ const walk = (dir) => {
161
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
162
+ const full = path.join(dir, entry.name);
163
+ if (entry.isDirectory()) {
164
+ walk(full);
165
+ continue;
166
+ }
167
+ if (!entry.isFile()) continue;
168
+ const mtime = fs.statSync(full).mtimeMs;
169
+ if (mtime > newestSrcMtime) {
170
+ newestSrcMtime = mtime;
171
+ newestSrcPath = full;
172
+ }
173
+ }
174
+ };
175
+ walk(srcRoot);
176
+
177
+ const manifestMtime = fs.statSync(manifestPath).mtimeMs;
178
+ if (newestSrcMtime > manifestMtime) {
179
+ const rel = path.relative(repoRoot, newestSrcPath);
180
+ console.error(
181
+ "release evidence: frontend/src 가 static/app 빌드보다 더 새롭습니다.\n" +
182
+ ` 최신 소스: ${rel}\n` +
183
+ ` 소스 mtime: ${new Date(newestSrcMtime).toISOString()}\n` +
184
+ ` 빌드 mtime: ${new Date(manifestMtime).toISOString()} (static/app/asset-manifest.json)\n` +
185
+ "실행 전에 npm run build:assets 를 돌려라.",
186
+ );
187
+ process.exit(1);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Detect evidence that was captured against a *previous* asset build.
193
+ *
194
+ * Failure mode: capture runs (screenshots mtime T0), then someone rebuilds
195
+ * assets (manifest mtime T1 > T0). The pre-guard only compares src vs
196
+ * manifest, so it still passes — but screenshots no longer show the build
197
+ * that will ship. Recording the manifest sha256 in SCREENSHOT_INDEX.md at
198
+ * capture time, then refusing to trust a mismatched index, closes that hole.
199
+ *
200
+ * Day-to-day gate: `scripts/check_release_evidence_bound.mjs` (in npm run
201
+ * lint) hard-fails on sha mismatch. This pre-capture check is different —
202
+ * when someone deliberately runs release:evidence after build:assets, the
203
+ * existing index is *expected* to be stale; we continue, wipe, and rebind.
204
+ * Set LTCAI_RELEASE_EVIDENCE_REQUIRE_BOUND=1 to refuse wipe-and-rebind
205
+ * (useful in CI that should never recapture from a dirty tree).
206
+ */
207
+ function assertExistingEvidenceStillBoundToBuild() {
208
+ const indexPath = path.join(root, "SCREENSHOT_INDEX.md");
209
+ if (!fs.existsSync(indexPath)) return;
210
+ const index = fs.readFileSync(indexPath, "utf8");
211
+ const match = index.match(/asset-manifest\.sha256:\s*`?([0-9a-f]{64})`?/i);
212
+ if (!match) {
213
+ // Older captures predate binding — this run will rebind after wipe.
214
+ console.warn(
215
+ "release evidence: existing SCREENSHOT_INDEX.md has no asset-manifest.sha256 binding.\n" +
216
+ " Recapture will record the current build fingerprint.",
217
+ );
218
+ return;
219
+ }
220
+ const recorded = match[1].toLowerCase();
221
+ const current = assetManifestFingerprint();
222
+ if (!current) return;
223
+ if (recorded === current.sha256) return;
224
+
225
+ const msg =
226
+ "release evidence: SCREENSHOT_INDEX.md is bound to a different asset build.\n" +
227
+ ` recorded sha256: ${recorded}\n` +
228
+ ` current sha256: ${current.sha256}\n` +
229
+ ` current mtime: ${current.mtime}\n` +
230
+ "Evidence screenshots are stale relative to static/app.";
231
+ if (process.env.LTCAI_RELEASE_EVIDENCE_REQUIRE_BOUND === "1") {
232
+ console.error(
233
+ `${msg}\n LTCAI_RELEASE_EVIDENCE_REQUIRE_BOUND=1 — refusing wipe/rebind.\n` +
234
+ " Unset the flag and re-run release:evidence to recapture, or restore the matching build.",
235
+ );
236
+ process.exit(1);
237
+ }
238
+ console.warn(
239
+ `${msg}\n Continuing: this run will wipe and rebind evidence to the current build.\n` +
240
+ " (Day-to-day lint still fails on stale evidence via check_release_evidence_bound.mjs.)",
241
+ );
242
+ }
243
+
244
+ assertNotOverwritingTaggedRelease();
245
+ assertLintClean();
246
+ assertBuiltAssetsFresh();
247
+ assertExistingEvidenceStillBoundToBuild();
14
248
 
15
249
  // A release capture must be reproducible and must never retain partial videos
16
250
  // from an interrupted prior run.
@@ -42,6 +276,59 @@ async function shot(page, name) {
42
276
  await page.screenshot({ path: path.join(screenshots, name), fullPage: true });
43
277
  }
44
278
 
279
+ // The README's hero GIF is this session's screen recording, and 26 of its 79
280
+ // frames were the route-level Suspense fallback ("Brain 작업공간을 여는 중...",
281
+ // App.tsx PageLoader): every screen visited below is a separate lazy chunk, so a
282
+ // cold headless capture pays that chunk's parse cost on first visit and records
283
+ // an empty shell instead of the product. A third of the walkthrough published in
284
+ // the README was therefore a loading screen. Preloading the route chunks once,
285
+ // while the Brain home is already on screen, makes the recording show the
286
+ // screens a person came to see rather than the loader between them.
287
+ //
288
+ // The asset base is read from the module script the page already loaded instead
289
+ // of being hardcoded, so moving the static mount cannot quietly turn this into a
290
+ // no-op — and resolving zero chunks throws rather than shipping a blank GIF.
291
+ async function preloadRouteChunks(page) {
292
+ const chunks = [
293
+ ...new Set(fs.readFileSync(manifestPath, "utf8").match(/assets\/[A-Za-z0-9_-]+\.js/g) ?? []),
294
+ ];
295
+ if (!chunks.length) {
296
+ throw new Error(`release evidence: no route chunks found in ${manifestPath}`);
297
+ }
298
+ const loaded = await page.evaluate(async (names) => {
299
+ const entry = document.querySelector('script[type="module"][src]');
300
+ if (!entry) return 0;
301
+ const base = new URL(entry.src, location.href).href.replace(/assets\/[^/]+$/, "");
302
+ let done = 0;
303
+ await Promise.all(
304
+ names.map(
305
+ (name) =>
306
+ new Promise((resolve) => {
307
+ const link = document.createElement("link");
308
+ link.rel = "modulepreload";
309
+ link.crossOrigin = "anonymous";
310
+ link.href = base + name;
311
+ link.onload = () => {
312
+ done += 1;
313
+ resolve();
314
+ };
315
+ link.onerror = () => resolve();
316
+ document.head.appendChild(link);
317
+ }),
318
+ ),
319
+ );
320
+ return done;
321
+ }, chunks);
322
+ if (loaded === 0) {
323
+ throw new Error(
324
+ `release evidence: preloaded 0 of ${chunks.length} route chunks — the ` +
325
+ "walkthrough would record the loading fallback instead of the screens",
326
+ );
327
+ }
328
+ await page.waitForTimeout(250);
329
+ return { loaded, total: chunks.length };
330
+ }
331
+
45
332
  async function main() {
46
333
  await waitForServer();
47
334
 
@@ -93,6 +380,12 @@ async function main() {
93
380
  await page.locator("main[aria-label='Lattice Brain']").waitFor();
94
381
  await shot(page, "04-brain-chat-home.png");
95
382
 
383
+ const preload = await preloadRouteChunks(page);
384
+ console.log(
385
+ `release evidence: preloaded ${preload.loaded}/${preload.total} route chunks ` +
386
+ "so the walkthrough records screens, not the loading fallback",
387
+ );
388
+
96
389
  await page.goto(`${baseURL}/app#/knowledge-graph`, { waitUntil: "networkidle" });
97
390
  await page.locator("[data-testid='brain-cytoscape']").waitFor();
98
391
  const graphSearch = page.locator(
@@ -176,10 +469,35 @@ async function main() {
176
469
  gifTarget,
177
470
  ], { stdio: "ignore" });
178
471
 
472
+ const fingerprint = assetManifestFingerprint();
473
+ if (!fingerprint) {
474
+ throw new Error("asset-manifest.json missing after capture — cannot bind evidence");
475
+ }
476
+ const mockFp = mockServerFingerprint();
477
+ if (!mockFp) {
478
+ throw new Error("tests/visual/mock_server.cjs missing after capture — cannot bind evidence");
479
+ }
480
+ // Post-capture binding: if a later build:assets changes the manifest hash,
481
+ // or mock_server.cjs changes the capture payloads, check_release_evidence_bound.mjs
482
+ // will refuse to trust these screenshots without a recapture.
179
483
  const index = `# v${version} Release Evidence
180
484
 
181
485
  Captured from the built React/Vite app served by the release visual API on ${new Date().toISOString()}.
182
486
 
487
+ ## Build Binding
488
+
489
+ Evidence is only trustworthy while this fingerprint matches
490
+ \`static/app/asset-manifest.json\` **and** \`tests/visual/mock_server.cjs\`.
491
+ A later \`build:assets\` or mock-server edit without recapture invalidates the
492
+ screenshots even when their mtimes look fresh.
493
+
494
+ - asset-manifest.sha256: \`${fingerprint.sha256}\`
495
+ - asset-manifest.mtime: \`${fingerprint.mtime}\`
496
+ - asset-manifest.bytes: ${fingerprint.bytes}
497
+ - mock-server.sha256: \`${mockFp.sha256}\`
498
+ - mock-server.mtime: \`${mockFp.mtime}\`
499
+ - mock-server.bytes: ${mockFp.bytes}
500
+
183
501
  ## Screenshots
184
502
 
185
503
  | File | Flow |
@@ -203,6 +521,22 @@ Captured from the built React/Vite app served by the release visual API on ${new
203
521
  - [v${version}-living-brain-walkthrough.gif](gifs/v${version}-living-brain-walkthrough.gif)
204
522
  `;
205
523
  fs.writeFileSync(path.join(root, "SCREENSHOT_INDEX.md"), index);
524
+
525
+ // Post-hoc: re-hash the manifest *now* and refuse if it drifted mid-run
526
+ // (e.g. a parallel build:assets while Playwright was shooting).
527
+ const after = assetManifestFingerprint();
528
+ if (!after || after.sha256 !== fingerprint.sha256) {
529
+ console.error(
530
+ "release evidence: asset-manifest.json changed during capture.\n" +
531
+ ` bound: ${fingerprint.sha256}\n` +
532
+ ` current: ${after ? after.sha256 : "(missing)"}\n` +
533
+ "Rebuild was not stable — delete evidence and re-run after a single build:assets.",
534
+ );
535
+ process.exit(1);
536
+ }
537
+ console.log(
538
+ `release evidence bound to asset-manifest sha256=${fingerprint.sha256.slice(0, 12)}… mtime=${fingerprint.mtime}`,
539
+ );
206
540
  }
207
541
 
208
542
  try {
@@ -6,7 +6,7 @@ const root = process.cwd();
6
6
  const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
7
7
  const version = pkg.version;
8
8
  const releaseDir = `output/release/v${version}`;
9
- const releaseTheme = "Ask First";
9
+ const releaseTheme = "Plain Surface";
10
10
  const title = `${version} — ${releaseTheme}`;
11
11
  const escapedVersion = version.replaceAll(".", "\\.");
12
12
 
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Post-capture evidence ↔ build binding gate.
4
+ *
5
+ * Failure mode
6
+ * ------------
7
+ * 1. `npm run release:evidence` writes screenshots (mtime T0).
8
+ * 2. Someone runs `npm run build:assets` again (manifest mtime T1 > T0).
9
+ * 3. The pre-capture guard only compares frontend/src mtime vs manifest mtime,
10
+ * so it still passes — while screenshots show an older UI than the build.
11
+ *
12
+ * Also: capture hits tests/visual/mock_server.cjs for API payloads. Changing
13
+ * the mock after capture (e.g. taller Act panel → 1846px vs committed 1721px)
14
+ * left asset-manifest.sha256 alone, so this gate used to stay green while
15
+ * SCREENSHOT_INDEX.md described a UI the tree no longer produces.
16
+ *
17
+ * Capture records asset-manifest.sha256 and mock-server.sha256 in
18
+ * SCREENSHOT_INDEX.md. This script fails (exit 1) when either binding is
19
+ * missing or does not match the live file. Wired into `npm run lint` so a
20
+ * green lint cannot ship stale screenshots.
21
+ *
22
+ * Exit 0 bound, 1 stale/missing, 2 could not run.
23
+ *
24
+ * Escape hatch: LTCAI_SKIP_RELEASE_EVIDENCE_BOUND=1 (used only by
25
+ * release:evidence while it intentionally wipes and rebinds evidence).
26
+ */
27
+ import { createHash } from "node:crypto";
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+
31
+ const repoRoot = process.cwd();
32
+
33
+ if (process.env.LTCAI_SKIP_RELEASE_EVIDENCE_BOUND === "1") {
34
+ console.log(
35
+ "release evidence binding: skipped (LTCAI_SKIP_RELEASE_EVIDENCE_BOUND=1)",
36
+ );
37
+ process.exit(0);
38
+ }
39
+
40
+ const version = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version;
41
+ const root =
42
+ process.env.LTCAI_RELEASE_EVIDENCE_DIR ||
43
+ path.join(repoRoot, "output", "release", `v${version}`);
44
+ const indexPath = path.join(root, "SCREENSHOT_INDEX.md");
45
+ const manifestPath = path.join(repoRoot, "static", "app", "asset-manifest.json");
46
+ const mockServerPath = path.join(repoRoot, "tests", "visual", "mock_server.cjs");
47
+
48
+ function fail(message, code = 1) {
49
+ console.error(message);
50
+ process.exit(code);
51
+ }
52
+
53
+ function sha256File(filePath) {
54
+ return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
55
+ }
56
+
57
+ if (!fs.existsSync(indexPath)) {
58
+ fail(`release evidence binding: missing ${path.relative(repoRoot, indexPath)}`);
59
+ }
60
+ if (!fs.existsSync(manifestPath)) {
61
+ fail("release evidence binding: static/app/asset-manifest.json missing", 2);
62
+ }
63
+ if (!fs.existsSync(mockServerPath)) {
64
+ fail("release evidence binding: tests/visual/mock_server.cjs missing", 2);
65
+ }
66
+
67
+ const index = fs.readFileSync(indexPath, "utf8");
68
+ const match = index.match(/asset-manifest\.sha256:\s*`?([0-9a-f]{64})`?/i);
69
+ if (!match) {
70
+ fail(
71
+ "release evidence binding: SCREENSHOT_INDEX.md has no asset-manifest.sha256.\n" +
72
+ " Re-run npm run release:evidence so the index records the build fingerprint.",
73
+ );
74
+ }
75
+
76
+ const mockMatch = index.match(/mock-server\.sha256:\s*`?([0-9a-f]{64})`?/i);
77
+ if (!mockMatch) {
78
+ fail(
79
+ "release evidence binding: SCREENSHOT_INDEX.md has no mock-server.sha256.\n" +
80
+ " Capture data comes from tests/visual/mock_server.cjs; re-run\n" +
81
+ " npm run release:evidence so the index records the mock fingerprint.",
82
+ );
83
+ }
84
+
85
+ const recorded = match[1].toLowerCase();
86
+ const current = sha256File(manifestPath);
87
+ const mtime = fs.statSync(manifestPath).mtime.toISOString();
88
+
89
+ if (recorded !== current) {
90
+ fail(
91
+ "release evidence binding: screenshots are stale relative to static/app.\n" +
92
+ ` recorded sha256: ${recorded}\n` +
93
+ ` current sha256: ${current}\n` +
94
+ ` current mtime: ${mtime}\n` +
95
+ " Rebuild happened after capture. Run: npm run build:assets && npm run release:evidence",
96
+ );
97
+ }
98
+
99
+ const recordedMock = mockMatch[1].toLowerCase();
100
+ const currentMock = sha256File(mockServerPath);
101
+ const mockMtime = fs.statSync(mockServerPath).mtime.toISOString();
102
+
103
+ if (recordedMock !== currentMock) {
104
+ fail(
105
+ "release evidence binding: screenshots are stale relative to mock_server.cjs.\n" +
106
+ ` recorded mock-server.sha256: ${recordedMock}\n` +
107
+ ` current mock-server.sha256: ${currentMock}\n` +
108
+ ` current mtime: ${mockMtime}\n` +
109
+ " Mock API payloads changed after capture. Run: npm run release:evidence\n" +
110
+ " (bump version first if vX.Y.Z is already tagged — see docs/OPERATIONS.md).",
111
+ );
112
+ }
113
+
114
+ console.log(
115
+ `release evidence binding ok: asset=${current.slice(0, 12)}… mock=${currentMock.slice(0, 12)}… mtime=${mtime} dir=${path.relative(repoRoot, root)}`,
116
+ );