@ijfw/memory-server 1.5.4 → 1.5.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 (37) hide show
  1. package/package.json +15 -1
  2. package/src/brain/dream-pipeline.js +77 -14
  3. package/src/brain/dump-ingest.js +32 -0
  4. package/src/brain/entity-collapse.js +2 -2
  5. package/src/brain/export.js +60 -6
  6. package/src/brain/extractors/markdown.js +28 -2
  7. package/src/brain/layout-sentinel.js +19 -14
  8. package/src/brain/path-guard.js +17 -0
  9. package/src/brain/wiki-compiler.js +35 -39
  10. package/src/codex-agents.js +25 -2
  11. package/src/cross-orchestrator-cli.js +176 -18
  12. package/src/dashboard-server.js +36 -3
  13. package/src/dispatch/override.js +18 -2
  14. package/src/dispatch/signer-cli.js +14 -9
  15. package/src/dream/stage-runner.js +17 -0
  16. package/src/dream/state-file.js +15 -1
  17. package/src/extension-installer.js +91 -2
  18. package/src/extension-registry.js +15 -4
  19. package/src/handlers/brain-handler.js +44 -5
  20. package/src/lib/atomic-io.js +69 -12
  21. package/src/lib/shasum-verify.js +46 -22
  22. package/src/lib/ui-review-runner.js +7 -2
  23. package/src/lib/uispec-drift.js +8 -3
  24. package/src/lib/uispec-intake.js +5 -2
  25. package/src/memory/layout-migrations/001-visible-layer.js +71 -7
  26. package/src/memory/reader.js +111 -58
  27. package/src/orchestrator/merge-block-aware.js +75 -37
  28. package/src/orchestrator/post-done-runner.js +6 -1
  29. package/src/orchestrator/state-sdk.js +242 -14
  30. package/src/orchestrator/wave-state.js +22 -69
  31. package/src/recovery/checkpoint.js +30 -6
  32. package/src/recovery/code-fixer.js +52 -7
  33. package/src/runtime-mediator.js +2 -2
  34. package/src/server.js +57 -8
  35. package/src/swarm/planner.js +46 -1
  36. package/src/update-apply.js +27 -35
  37. package/src/update-check.js +6 -2
@@ -1614,6 +1614,111 @@ function writeStateFields(updates) {
1614
1614
  });
1615
1615
  }
1616
1616
 
1617
+ // V155-001 (BLOCKER) — `ijfw update` was writing `installed_version` /
1618
+ // `last_applied_version` based on the install subprocess returning exit 0
1619
+ // across all 3 install methods (npm-global, git-clone, manual). Exit 0 from
1620
+ // `npm install -g @ijfw/install@X` is "npm did not crash" — NOT "@ijfw/install
1621
+ // on disk is now version X". On registry blips, npm-cache poisoning, or a
1622
+ // no-op install (already-installed, lockfile pin), `state.json` would advance
1623
+ // to a version that does not exist on the filesystem. Every subsequent
1624
+ // `ijfw update --check` then saw "we're up to date" and refused to retry.
1625
+ //
1626
+ // Fix: AFTER the install subprocess returns 0, re-read the *actual*
1627
+ // installed package.json (preferring npm-global root for the npm-global path,
1628
+ // repo-tree installer/package.json for git-clone, and either for manual)
1629
+ // and confirm `pkg.version === expectedVersion` before writing state.json.
1630
+ // If they disagree, refuse the state write and surface the discrepancy.
1631
+ //
1632
+ // Returns { ok: true, actualVersion } on success, { ok: false, actualVersion,
1633
+ // reason } on mismatch / unreadable.
1634
+ //
1635
+ // Exported (v1.5.5 V155-001 follow-up) so the regression test
1636
+ // `test-v155-update-flow.js` can drive the function directly without spawning
1637
+ // the full CLI. Behavior unchanged.
1638
+ export function verifyInstallSucceeded({ method, repoRoot, expectedVersion }) {
1639
+ // TR-006: type-validate `expectedVersion` at function entry. Without this,
1640
+ // a stray trailing newline (from sloppy `npmViewVersion` parse) makes the
1641
+ // strict `pkg.version === expectedVersion` check fail for the wrong reason
1642
+ // and surfaces operator-hostile mismatch messages like
1643
+ // "expected v1.5.5\n got v1.5.5". Normalize + validate before any probe.
1644
+ expectedVersion = String(expectedVersion || '').trim();
1645
+ if (!isVersionStringValid(expectedVersion)) {
1646
+ return {
1647
+ ok: false,
1648
+ actualVersion: null,
1649
+ reason: `expectedVersion not a valid semver string (got: ${JSON.stringify(expectedVersion)})`,
1650
+ };
1651
+ }
1652
+ // TR-002: probe candidates are now method-strict. The previous shape
1653
+ // silently fell back to `repoRoot/installer/package.json` for ALL methods,
1654
+ // so a stale git clone at the expected version would mask an npm-global
1655
+ // install that never happened. Each method now declares its own source of
1656
+ // truth; `manual` keeps the multi-candidate fallback because dev-mode
1657
+ // installs legitimately ride on either npm-global or a sibling git clone.
1658
+ const candidates = [];
1659
+ const probeNpmGlobal = () => {
1660
+ try {
1661
+ const r = spawnSync('npm', ['root', '-g'], {
1662
+ encoding: 'utf8',
1663
+ timeout: 5_000,
1664
+ shell: process.platform === 'win32',
1665
+ });
1666
+ if (r.status === 0) {
1667
+ const globalRoot = (r.stdout || '').trim();
1668
+ if (globalRoot) {
1669
+ candidates.push(join(globalRoot, '@ijfw', 'install', 'package.json'));
1670
+ }
1671
+ }
1672
+ } catch { /* ignore — surfaces as no-candidates */ }
1673
+ };
1674
+ if (method === 'npm-global') {
1675
+ // npm-global verification MUST read the npm-global package.json
1676
+ // exclusively. A sibling repo tree at the expected version is a category
1677
+ // error here — it would mean we record "npm-global install succeeded"
1678
+ // when the only thing on disk at that version is an unrelated git clone.
1679
+ probeNpmGlobal();
1680
+ } else if (method === 'git-clone' && repoRoot) {
1681
+ candidates.push(join(repoRoot, 'installer', 'package.json'));
1682
+ } else if (method === 'manual') {
1683
+ // Dev-mode: try npm-global first, then the repo tree. Either is a
1684
+ // legitimate source of truth for a manually-driven install.
1685
+ probeNpmGlobal();
1686
+ if (repoRoot) {
1687
+ const fallback = join(repoRoot, 'installer', 'package.json');
1688
+ if (!candidates.includes(fallback)) candidates.push(fallback);
1689
+ }
1690
+ }
1691
+
1692
+ if (candidates.length === 0) {
1693
+ return {
1694
+ ok: false,
1695
+ actualVersion: null,
1696
+ reason:
1697
+ method === 'npm-global'
1698
+ ? 'npm-global package.json not readable (npm root -g failed)'
1699
+ : `no package.json candidates for method=${method}`,
1700
+ };
1701
+ }
1702
+
1703
+ const seen = [];
1704
+ for (const p of candidates) {
1705
+ const pkg = readJsonSafe(p);
1706
+ if (!pkg) {
1707
+ seen.push(`${p} (unreadable)`);
1708
+ continue;
1709
+ }
1710
+ seen.push(`${p} (v${pkg.version || 'unknown'})`);
1711
+ if (pkg.version === expectedVersion) {
1712
+ return { ok: true, actualVersion: pkg.version, source: p };
1713
+ }
1714
+ }
1715
+ return {
1716
+ ok: false,
1717
+ actualVersion: null,
1718
+ reason: `no package.json reported version v${expectedVersion}. Probed: ${seen.join(', ')}`,
1719
+ };
1720
+ }
1721
+
1617
1722
  function cmdUpdateCheck() {
1618
1723
  const state = readState();
1619
1724
  const current = state.installed_version || '0.0.0';
@@ -1631,7 +1736,7 @@ function cmdUpdateCheck() {
1631
1736
  // rely on exit codes. Previously exited 3 which broke POSIX if-statements.
1632
1737
  console.log(`update-available: ${r.version}`);
1633
1738
  console.log(`Update available: v${current} -> v${r.version}`);
1634
- console.log(` Release notes: https://gitlab.com/therealseandonahoe/ijfw/-/releases/v${r.version}`);
1739
+ console.log(` Release notes: https://github.com/FerroxLabs/ijfw/releases/tag/v${r.version}`);
1635
1740
  console.log(` Run: ijfw update`);
1636
1741
  process.exit(0);
1637
1742
  }
@@ -1683,17 +1788,24 @@ function cmdUpdateChangelog() {
1683
1788
  console.error(`could not fetch latest version: ${r.message}`);
1684
1789
  process.exit(1);
1685
1790
  }
1686
- const url = `https://gitlab.com/api/v4/projects/therealseandonahoe%2Fijfw/releases/v${r.version}`;
1687
- const fetchRes = spawnSync('curl', ['-fsSL', '-H', 'User-Agent: ijfw', url], { encoding: 'utf8', timeout: 10_000 });
1791
+ // V155 rebrand: ported from GitLab API (releases/v<ver>, data.description)
1792
+ // to GitHub API (releases/tags/v<ver>, data.body). GitHub's repo path is
1793
+ // NOT URL-encoded, unlike GitLab's %2F-separated project id.
1794
+ const url = `https://api.github.com/repos/FerroxLabs/ijfw/releases/tags/v${r.version}`;
1795
+ const fetchRes = spawnSync(
1796
+ 'curl',
1797
+ ['-fsSL', '-H', 'User-Agent: ijfw', '-H', 'Accept: application/vnd.github+json', url],
1798
+ { encoding: 'utf8', timeout: 10_000 },
1799
+ );
1688
1800
  if (fetchRes.status !== 0) {
1689
1801
  console.log(`No release notes available for v${r.version}.`);
1690
- console.log(`Visit: https://gitlab.com/therealseandonahoe/ijfw/-/releases/v${r.version}`);
1802
+ console.log(`Visit: https://github.com/FerroxLabs/ijfw/releases/tag/v${r.version}`);
1691
1803
  process.exit(0);
1692
1804
  }
1693
1805
  let body = '';
1694
1806
  try {
1695
1807
  const data = JSON.parse(fetchRes.stdout || '{}');
1696
- body = data.description || '(no body)';
1808
+ body = data.body || '(no body)';
1697
1809
  } catch { body = '(could not parse release JSON)'; }
1698
1810
  // ANSI strip + cap 4KB. Control-char regex is intentional -- defangs
1699
1811
  // CHANGELOG bytes fetched over HTTPS so paste into the terminal can't
@@ -1707,7 +1819,7 @@ function cmdUpdateChangelog() {
1707
1819
  console.log(`Changelog for v${r.version}`);
1708
1820
  console.log('');
1709
1821
  console.log(stripped);
1710
- if (body.length > 4096) console.log(`\n... (truncated; full notes at https://gitlab.com/therealseandonahoe/ijfw/-/releases/v${r.version})`);
1822
+ if (body.length > 4096) console.log(`\n... (truncated; full notes at https://github.com/FerroxLabs/ijfw/releases/tag/v${r.version})`);
1711
1823
  process.exit(0);
1712
1824
  }
1713
1825
 
@@ -1734,7 +1846,7 @@ function cmdUpdateConfirm(token) {
1734
1846
  // Locate pending sentinel under any session in ~/.ijfw/run/
1735
1847
  const runRoot = join(ijfwHome(), 'run');
1736
1848
  if (!existsSync(runRoot)) {
1737
- console.error('No pending update sentinel found. Run `ijfw_update_apply` via your AI first.');
1849
+ console.error('No pending update sentinel found. Run `ijfw_update_check` via your AI first to issue a confirm token.');
1738
1850
  process.exit(1);
1739
1851
  }
1740
1852
  let sentinelPath = null;
@@ -1758,15 +1870,15 @@ function cmdUpdateConfirm(token) {
1758
1870
  if (!sentinelPath) {
1759
1871
  console.error(
1760
1872
  sawPending
1761
- ? 'Token mismatch -- run ijfw_update_check + ijfw_update_apply via your AI to issue a fresh token.'
1762
- : 'No pending update sentinel found. The MCP `ijfw_update_apply` tool issues sentinels.'
1873
+ ? 'Token mismatch -- run `ijfw_update_check` via your AI to issue a fresh token.'
1874
+ : 'No pending update sentinel found. Run `ijfw_update_check` via your AI to issue one.'
1763
1875
  );
1764
1876
  process.exit(1);
1765
1877
  }
1766
1878
 
1767
1879
  if (!pending || !isVersionStringValid(pending.target_version)) {
1768
1880
  try { rmSync(sentinelPath, { force: true }); } catch { /* */ }
1769
- console.error('Pending update sentinel is malformed -- run ijfw_update_check + ijfw_update_apply again.');
1881
+ console.error('Pending update sentinel is malformed -- run `ijfw_update_check` again to issue a fresh one.');
1770
1882
  process.exit(1);
1771
1883
  }
1772
1884
  const tokenCheck = validateToken(sessionId, token);
@@ -1777,7 +1889,7 @@ function cmdUpdateConfirm(token) {
1777
1889
  tokenCheck.error === 'already-consumed' ? 'Token already consumed' :
1778
1890
  tokenCheck.error === 'mismatch' ? 'Token mismatch' :
1779
1891
  'Token validation failed';
1780
- console.error(`${why} -- run ijfw_update_check + ijfw_update_apply to issue a fresh token.`);
1892
+ console.error(`${why} -- run \`ijfw_update_check\` to issue a fresh token.`);
1781
1893
  process.exit(1);
1782
1894
  }
1783
1895
 
@@ -1849,7 +1961,7 @@ function cmdUpdateInteractive(opts = {}) {
1849
1961
  }
1850
1962
  }
1851
1963
  // Shasum cross-verify (F-SEC-7): independent second factor on top of
1852
- // npm-side signatures. Fetches the GitLab release asset shasum and
1964
+ // npm-side signatures. Fetches the GitHub release asset shasum and
1853
1965
  // compares it against npm's dist.shasum for the same version. Mismatch
1854
1966
  // means we refuse to install; advisory (release shasum unavailable)
1855
1967
  // requires explicit --yes to proceed.
@@ -1860,7 +1972,7 @@ function cmdUpdateInteractive(opts = {}) {
1860
1972
  console.error(' Shasum: MISMATCH -- refusing install.');
1861
1973
  console.error(` npm : ${shasum.npmShasum}`);
1862
1974
  console.error(` release : ${shasum.releaseShasum}`);
1863
- console.error(' The npm tarball does NOT match the GitLab release asset.');
1975
+ console.error(' The npm tarball does NOT match the GitHub release asset.');
1864
1976
  console.error(' This could indicate a compromised registry or release. Aborting.');
1865
1977
  return 1;
1866
1978
  } else if (shasum.mode === 'error') {
@@ -1954,10 +2066,28 @@ function cmdUpdateInteractive(opts = {}) {
1954
2066
  console.error(`npm install did not complete (exit ${npmInstall.status}). Run \`npm install --omit=dev --ignore-scripts\` in ${repoRoot} manually.`);
1955
2067
  return 1;
1956
2068
  }
1957
- installRes = spawnSync('bash', [installSh], { stdio: 'inherit' });
2069
+ // V155-001 (H-002): hardcoded `bash` ENOENTs on Windows where the runtime
2070
+ // is `sh.exe` (Git-Bash) or absent. Use shell:true on Windows so the
2071
+ // .sh extension dispatches via the registered shebang handler; POSIX
2072
+ // keeps the explicit `bash` binary for portability across distros.
2073
+ //
2074
+ // TS-001 (v1.5.5 Trident security): with shell:true on Windows, the
2075
+ // first argument is interpreted by cmd.exe and tokenized on whitespace,
2076
+ // `&`, `(`, `)`, `;`, `%`, etc. Paths containing those characters are
2077
+ // common on Windows (e.g. `C:\Users\Bob & Alice\...` or
2078
+ // `C:\Program Files (x86)\ijfw\...`) — without quotes cmd will execute
2079
+ // an unrelated binary or fail confusingly. Same-uid attacker could plant
2080
+ // `C:\Users\Bob.exe` and have it picked up first. Quote the path so
2081
+ // cmd.exe treats it as a single token.
2082
+ installRes = process.platform === 'win32'
2083
+ ? spawnSync(`"${installSh}"`, [], { stdio: 'inherit', shell: true })
2084
+ : spawnSync('bash', [installSh], { stdio: 'inherit' });
1958
2085
  } else {
1959
2086
  console.log(' Manual install detected -- run: npx @ijfw/install');
1960
- installRes = spawnSync('npx', ['-y', `@ijfw/install@${r.version}`], { stdio: 'inherit' });
2087
+ installRes = spawnSync('npx', ['-y', `@ijfw/install@${r.version}`], {
2088
+ stdio: 'inherit',
2089
+ shell: process.platform === 'win32',
2090
+ });
1961
2091
  }
1962
2092
  if (!installRes) {
1963
2093
  console.error('Update did not complete (install command could not be spawned)');
@@ -1971,6 +2101,23 @@ function cmdUpdateInteractive(opts = {}) {
1971
2101
  console.error(`Update did not complete (exit ${installRes.status}). State not written.`);
1972
2102
  return 1;
1973
2103
  }
2104
+ // V155-001 (A-001 + C-001 + C-002): subprocess exit 0 is "the command did
2105
+ // not crash" — NOT "@ijfw/install on disk is now version r.version".
2106
+ // Re-verify by reading the installed package.json BEFORE writing state.json.
2107
+ // If the filesystem disagrees with what we asked for, refuse the state
2108
+ // write and surface a clear actionable error.
2109
+ const repoRoot = method === 'git-clone' ? repoRootFromCli() : null;
2110
+ const verify = verifyInstallSucceeded({
2111
+ method,
2112
+ repoRoot: repoRoot || repoRootFromCli(),
2113
+ expectedVersion: r.version,
2114
+ });
2115
+ if (!verify.ok) {
2116
+ console.error(`Update did NOT complete: install subprocess exited 0 but filesystem still reports v${verify.actualVersion || 'unknown'} (expected v${r.version}).`);
2117
+ console.error(` Details: ${verify.reason}`);
2118
+ console.error(' State not written. Inspect with `ijfw status`; rerun `ijfw update` after diagnosing.');
2119
+ return 1;
2120
+ }
1974
2121
  // Persist both fields atomically -- single write avoids concurrent-reader inconsistency.
1975
2122
  // last_good_shasum records the shasum we just successfully cross-verified +
1976
2123
  // installed (per docs/SECURITY.md "last_good_shasum is a one-way 'what did
@@ -2192,7 +2339,7 @@ function statuslineRecompute() {
2192
2339
  function cmdConfig(sub) {
2193
2340
  if (sub === 'audit') {
2194
2341
  console.log('ijfw config --audit -- this feature is queued for a later release.');
2195
- console.log('Track progress: https://gitlab.com/therealseandonahoe/ijfw/issues');
2342
+ console.log('Track progress: https://github.com/FerroxLabs/ijfw/issues');
2196
2343
  return;
2197
2344
  }
2198
2345
  console.log('Usage: ijfw config --audit');
@@ -2271,7 +2418,7 @@ async function handleGuide(useBrowser) {
2271
2418
  ];
2272
2419
  const guidePath = candidates.find(p => existsSync(p));
2273
2420
  if (!guidePath) {
2274
- console.error('[ijfw] Guide not found. Visit https://gitlab.com/therealseandonahoe/ijfw/-/blob/main/docs/GUIDE.md');
2421
+ console.error('[ijfw] Guide not found. Visit https://github.com/FerroxLabs/ijfw/blob/main/docs/GUIDE.md');
2275
2422
  process.exit(1);
2276
2423
  }
2277
2424
  const md = readFileSync(guidePath, 'utf8');
@@ -3315,7 +3462,18 @@ function cmdSwarm(sub) {
3315
3462
  const taskId = args[0];
3316
3463
  const owner = optionValue(args.slice(1), ['--owner', '-o']);
3317
3464
  const message = optionValue(args.slice(1), ['--message', '-m']) || positionalMessage(args.slice(1), ['--owner', '-o']);
3318
- const result = completeSwarmTask(process.cwd(), taskId, { owner, message });
3465
+ const commitSha = optionValue(args.slice(1), ['--commit', '--sha']);
3466
+ const filesChanged = Number(optionValue(args.slice(1), ['--files-changed']));
3467
+ const skipEvidence = args.includes('--skip-evidence');
3468
+ // V155-006: caller must produce a filesystem witness (sha OR diffStats) OR
3469
+ // explicitly opt out via --skip-evidence (admin overrides, dry-run flows).
3470
+ // The planner records `task.completed-no-evidence` for downstream audit.
3471
+ const evidence = commitSha
3472
+ ? { commitSha }
3473
+ : Number.isFinite(filesChanged) && filesChanged >= 1
3474
+ ? { diffStats: { filesChanged } }
3475
+ : undefined;
3476
+ const result = completeSwarmTask(process.cwd(), taskId, { owner, message, evidence, skipEvidence });
3319
3477
  if (!result.ok) {
3320
3478
  console.log(`Swarm complete halted: ${result.error}`);
3321
3479
  process.exit(1);
@@ -10,7 +10,7 @@ import { createServer } from 'node:http';
10
10
  import { existsSync, readFileSync, watch, writeFileSync, mkdirSync, readdirSync, statSync, realpathSync, renameSync, unlinkSync } from 'node:fs';
11
11
  import { readFile } from 'node:fs/promises';
12
12
  import { homedir } from 'node:os';
13
- import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
13
+ import { join, dirname, resolve, relative, isAbsolute, basename, sep } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
 
16
16
  import { buildCostReport, buildBreakdown, buildDailySeries, buildBlockUsage, getSavingsMethodology } from './cost/aggregator.js';
@@ -736,10 +736,15 @@ export async function startServer(options = {}) {
736
736
  try {
737
737
  const tierFilter = url.searchParams.get('tier') || null;
738
738
  const result = ttlCache(`memory:list:${tierFilter}`, COST_CACHE_TTL, memoryDirsMtimeKey, () => {
739
- const { files, total, root, tiers } = listMemoryFiles(REPO_ROOT, tierFilter);
739
+ // V155-026 (v1.5.5): surface unreadable / corrupt-frontmatter files
740
+ // so the dashboard can warn the operator. Previously silently dropped.
741
+ const { files, errors, total, root, tiers } = listMemoryFiles(REPO_ROOT, tierFilter);
740
742
  const { counts, weekCounts, totalThisWeek } = buildRecallCounts(ledgerPath);
741
743
  const enriched = mergeRecallCounts(files, counts, weekCounts);
742
- return { files: enriched, total, root, tiers, totalRecallsThisWeek: totalThisWeek };
744
+ return {
745
+ files: enriched, errors: errors || [], total, root, tiers,
746
+ totalRecallsThisWeek: totalThisWeek,
747
+ };
743
748
  });
744
749
  res.writeHead(200, { 'Content-Type': 'application/json' });
745
750
  res.end(JSON.stringify(result));
@@ -1254,7 +1259,35 @@ export async function startServer(options = {}) {
1254
1259
  const contentDir = join(homedir(), '.ijfw', 'design-companion', 'content');
1255
1260
  mkdirSync(contentDir, { recursive: true });
1256
1261
  const name = url.pathname.split('/').pop();
1262
+ // V155-037 (v1.5.5): the route regex `[^/]+\.html$` only forbids forward
1263
+ // slashes — Windows `path.join` treats backslashes as separators, so
1264
+ // `GET /design/files/..\..\..\windows\system32\hosts.html` previously
1265
+ // escaped contentDir at join time. Reject any name that contains a
1266
+ // backslash or a dot-segment, or starts with a dot, or doesn't survive
1267
+ // a basename() round-trip. Belt-and-braces: also verify the joined
1268
+ // filePath resolves under contentDir.
1269
+ const isUnsafeName = (
1270
+ name.includes('\\')
1271
+ || name.includes('/')
1272
+ || name.startsWith('.')
1273
+ || name.includes('..')
1274
+ // Null-byte truncates path strings on POSIX syscalls that honour
1275
+ // C-string null-terminators. Reject any path containing one.
1276
+ || name.includes('\u0000')
1277
+ || basename(name) !== name
1278
+ );
1257
1279
  const filePath = join(contentDir, name);
1280
+ const filePathResolved = resolve(filePath);
1281
+ const contentDirResolved = resolve(contentDir);
1282
+ const isContained = (
1283
+ filePathResolved === contentDirResolved
1284
+ || filePathResolved.startsWith(contentDirResolved + sep)
1285
+ );
1286
+ if (isUnsafeName || !isContained) {
1287
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1288
+ res.end('404 Not Found');
1289
+ return;
1290
+ }
1258
1291
  let html = null;
1259
1292
  try {
1260
1293
  if (existsSync(filePath)) html = readFileSync(filePath, 'utf8');
@@ -151,14 +151,30 @@ async function cmdAudit({ projectRoot }) {
151
151
  const paths = resolveOverridePaths(skill, projectRoot);
152
152
  let sections = 0;
153
153
  let extendsChain = [];
154
+ const errors = [];
154
155
  for (const p of paths) {
155
- const file = await loadOverrideFile(p).catch(() => null);
156
+ // V155-028 (v1.5.5): previously `.catch(() => null)` silently treated
157
+ // schema-invalid / unreadable overrides as 'not present'. The audit
158
+ // command exists precisely to surface override health, so swallowing
159
+ // these errors hid the exact failure mode operators care about.
160
+ let file;
161
+ try { file = await loadOverrideFile(p); }
162
+ catch (err) {
163
+ errors.push({ path: p, reason: 'load-fail', message: err?.message || String(err) });
164
+ continue;
165
+ }
156
166
  if (!file) continue;
157
167
  if (Array.isArray(file.manifest?.extends)) extendsChain = file.manifest.extends;
158
168
  const matches = file.body?.match(/<!--\s*ijfw-override:/g);
159
169
  sections += matches ? matches.length : 0;
160
170
  }
161
- items.push({ preset: entry.preset, scope: entry.scope, skill, sections, extends: extendsChain });
171
+ items.push({
172
+ preset: entry.preset, scope: entry.scope, skill, sections,
173
+ extends: extendsChain,
174
+ // Only include `errors` when there is something to report — avoids
175
+ // every clean item carrying a noisy empty array.
176
+ ...(errors.length > 0 ? { errors } : {}),
177
+ });
162
178
  }
163
179
  }
164
180
  return { ok: true, command: 'audit', result: { items } };
@@ -276,22 +276,27 @@ async function keygenHandler(args, ctx = {}) {
276
276
  }
277
277
 
278
278
  /**
279
- * keygen-fido2 handler — deferred stub.
279
+ * keygen-fido2 handler — unimplemented.
280
280
  *
281
- * Native libfido2 bindings would be IJFW's first native prod dep; that's
282
- * a v1.5.0+ architecture decision. For v1.4.3, FIDO2-backed signing is
283
- * available transitively via ssh-agent (modern YubiKey/Solokey speak
284
- * SSH agent natively).
281
+ * V155-057: previously returned `ok:true, deferred:true` JSON consumers
282
+ * (CI scripts, automated install flows) reading `r.ok` saw success and
283
+ * proceeded as if a key had been minted. That's truthfulness-of-state
284
+ * violation: nothing was minted, no key exists, but state recorded ok.
285
285
  *
286
- * @returns {Promise<{ ok: true, deferred: true, message: string }>}
286
+ * Now returns `ok:false, error:'unimplemented'` so callers fail closed.
287
+ * The transitive ssh-agent path (modern YubiKey/Solokey speak SSH agent
288
+ * natively) remains the recommended route; the hint stays the same but
289
+ * the verdict is honest.
290
+ *
291
+ * @returns {Promise<{ ok: false, error: 'unimplemented', message: string }>}
287
292
  */
288
293
  async function keygenFido2Handler(_args, ctx = {}) {
289
- const msg = 'FIDO2/libfido2 path deferred to v1.5.0; use --backend ssh-agent or default software backend';
294
+ const msg = 'FIDO2/libfido2 native backend is unimplemented use `keygen <author> --backend ssh-agent` (modern YubiKey/Solokey work transitively via ssh-agent)';
290
295
  // Write to stderr for CLI visibility without disturbing JSON-stdout
291
296
  // consumers. Optionally inject a writer via ctx for tests.
292
297
  const stderr = ctx.stderr || process.stderr;
293
298
  try { stderr.write(`${msg}\n`); } catch { /* ignore */ }
294
- return { ok: true, deferred: true, message: msg };
299
+ return { ok: false, error: 'unimplemented', message: msg };
295
300
  }
296
301
 
297
302
  export const handlers = Object.freeze({
@@ -301,7 +306,7 @@ export const handlers = Object.freeze({
301
306
 
302
307
  export const subcommandHelp = Object.freeze({
303
308
  keygen: 'keygen <author> [--backend software|ssh-agent] [--ssh-key-comment <c>] — generate or enrol a publisher signing key',
304
- 'keygen-fido2': 'keygen-fido2 <author> — deferred to v1.5.0; use --backend ssh-agent instead',
309
+ 'keygen-fido2': 'keygen-fido2 <author> — UNIMPLEMENTED (no native libfido2 backend); use `keygen <author> --backend ssh-agent` instead',
305
310
  });
306
311
 
307
312
  // Test-only exports.
@@ -27,6 +27,23 @@ export async function runStages(root, stages) {
27
27
  const extras = (await stage.run()) || {};
28
28
  markStageCompleted(root, stage.name, extras);
29
29
  completed.push({ name: stage.name, extras });
30
+ // TP-003 (v1.5.5 Trident): V155-005 closed the truthfulness-of-state
31
+ // hole (status:'completed_with_error' / 'skipped' written to
32
+ // .dream-state-v2.json), but operators don't read JSON unless they
33
+ // already suspect something's wrong. Surface the non-clean shapes on
34
+ // stderr at run time so the operator discovers the truth proactively
35
+ // (Sutherland: the system explains itself). The JSON state file
36
+ // remains the canonical record; this is the discoverability rail.
37
+ if (extras && extras.error) {
38
+ process.stderr.write(
39
+ `[dream] stage ${stage.name} completed with error: ${extras.error}. `
40
+ + 'Full envelope in .ijfw/state/.dream-state-v2.json.\n',
41
+ );
42
+ } else if (extras && extras.skipped) {
43
+ process.stderr.write(
44
+ `[dream] stage ${stage.name} skipped: ${extras.skipped}\n`,
45
+ );
46
+ }
30
47
  } catch (e) {
31
48
  markStageFailed(root, stage.name, e?.message || String(e));
32
49
  failed.push({ name: stage.name, reason: e?.message || String(e) });
@@ -56,9 +56,23 @@ export function markStageStarted(root, stage) {
56
56
 
57
57
  export function markStageCompleted(root, stage, extras = {}) {
58
58
  const s = readDreamState(root);
59
+ // V155-005 (HIGH): the previous shape unconditionally wrote
60
+ // `status:'completed'` even when the stage returned a payload like
61
+ // `{ skipped:'db-unavailable' }` or `{ error:'tier-promotion-failed' }`.
62
+ // Operators reading `.dream-state-v2.json` saw "all stages completed"
63
+ // perpetually even when wiki-compile had been skipped for weeks. Inspect
64
+ // the returned envelope and route to a more honest status:
65
+ // - `extras.skipped` → status:'skipped'
66
+ // - `extras.error` → status:'completed_with_error'
67
+ // - otherwise → status:'completed' (back-compat shape).
68
+ let status = 'completed';
69
+ if (extras && typeof extras === 'object') {
70
+ if (extras.skipped) status = 'skipped';
71
+ else if (extras.error) status = 'completed_with_error';
72
+ }
59
73
  s.stages[stage] = {
60
74
  ...s.stages[stage],
61
- status: 'completed',
75
+ status,
62
76
  completed_at: Date.now(),
63
77
  ...extras,
64
78
  };
@@ -736,6 +736,53 @@ export function extensionAuditBrief(manifest, skillBodies) {
736
736
 
737
737
  // --- install ----------------------------------------------------------------
738
738
 
739
+ /**
740
+ * V155-061 (v1.5.5): byte-for-byte equality check for two directory trees.
741
+ * Returns true only when every file in `a` is present in `b` with identical
742
+ * bytes (and vice versa). Bails on the first divergence for efficiency.
743
+ * Used by the installer's staged-flip path to short-circuit re-installing
744
+ * the same version (avoids rewriting `installed_at` for no behavior change).
745
+ */
746
+ async function dirsAreByteIdentical(a, b) {
747
+ let entriesA;
748
+ let entriesB;
749
+ try { entriesA = await readdir(a, { withFileTypes: true }); }
750
+ catch { return false; }
751
+ try { entriesB = await readdir(b, { withFileTypes: true }); }
752
+ catch { return false; }
753
+ const namesA = entriesA.map((e) => e.name).sort();
754
+ const namesB = entriesB.map((e) => e.name).sort();
755
+ if (namesA.length !== namesB.length) return false;
756
+ for (let i = 0; i < namesA.length; i += 1) {
757
+ if (namesA[i] !== namesB[i]) return false;
758
+ }
759
+ const byNameA = new Map(entriesA.map((e) => [e.name, e]));
760
+ const byNameB = new Map(entriesB.map((e) => [e.name, e]));
761
+ for (const name of namesA) {
762
+ const ea = byNameA.get(name);
763
+ const eb = byNameB.get(name);
764
+ const aPath = join(a, name);
765
+ const bPath = join(b, name);
766
+ if (ea.isDirectory() !== eb.isDirectory()) return false;
767
+ if (ea.isDirectory()) {
768
+ if (!(await dirsAreByteIdentical(aPath, bPath))) return false;
769
+ continue;
770
+ }
771
+ // Treat anything that isn't a regular file as "not identical" — symlinks,
772
+ // sockets etc. don't survive npm-publish round-trips, so this is rare.
773
+ if (!ea.isFile() || !eb.isFile()) return false;
774
+ let bufA;
775
+ let bufB;
776
+ try {
777
+ bufA = await readFile(aPath);
778
+ bufB = await readFile(bPath);
779
+ } catch { return false; }
780
+ if (bufA.length !== bufB.length) return false;
781
+ if (!bufA.equals(bufB)) return false;
782
+ }
783
+ return true;
784
+ }
785
+
739
786
  /**
740
787
  * Install an extension from npm, local path, or https git URL.
741
788
  *
@@ -747,7 +794,7 @@ export function extensionAuditBrief(manifest, skillBodies) {
747
794
  * accept_degraded_trident?: boolean,
748
795
  * tridentExecutor?: Function, // test seam — forwarded as runTrident({executor})
749
796
  * }} opts
750
- * @returns {Promise<{ok: boolean, name?: string, version?: string, scope?: string, gate_result_block?: string, errors?: string[]}>}
797
+ * @returns {Promise<{ok: boolean, name?: string, version?: string, scope?: string, gate_result_block?: string, errors?: string[], unchanged?: boolean}>}
751
798
  */
752
799
  export async function installExtension(source, opts = {}) {
753
800
  if (!opts || typeof opts !== 'object') {
@@ -1015,6 +1062,29 @@ export async function installExtension(source, opts = {}) {
1015
1062
  await cp(s.absPath, dst, { force: true });
1016
1063
  }
1017
1064
 
1065
+ // V155-061 (v1.5.5): byte-identical short-circuit. Re-installing the
1066
+ // same version was unconditional rm+rename → registry rewrite with a
1067
+ // fresh installed_at timestamp. That defeats staleness audits and
1068
+ // makes downstream watchers + hot-reloaders churn for nothing. If the
1069
+ // staged tmpScopeDir is byte-identical to the existing scopeDir, skip
1070
+ // both the swap AND the registry update so installed_at stays pinned
1071
+ // to the original install. Mirrors `syncCodexAgents`'s `existing ===
1072
+ // rendered` pattern. We still need the staged dir cleaned up if we
1073
+ // bail out.
1074
+ const identical = await dirsAreByteIdentical(tmpScopeDir, scopeDir);
1075
+ if (identical) {
1076
+ await rm(tmpScopeDir, { recursive: true, force: true }).catch(() => {});
1077
+ return {
1078
+ ok: true,
1079
+ name: manifest.name,
1080
+ version: manifest.version,
1081
+ scope: opts.scope,
1082
+ gate_result_block: gateResultBlock,
1083
+ // Diagnostic so callers can tell a no-op install from a fresh one.
1084
+ unchanged: true,
1085
+ };
1086
+ }
1087
+
1018
1088
  // Atomic flip: drop old scopeDir, rename tmp -> scope. fs.rename is
1019
1089
  // atomic on POSIX when source/dest are on the same filesystem (they are
1020
1090
  // — same parent directory). On Windows rename to existing dir fails, so
@@ -1129,18 +1199,37 @@ export async function installExtension(source, opts = {}) {
1129
1199
  }
1130
1200
  }
1131
1201
 
1202
+ // V155-003 / TP-002 (v1.5.5 Trident): when a project-scope install only
1203
+ // partially deploys to platforms (e.g. some skill writes failed), the
1204
+ // registry has already been updated but the user's editor world is
1205
+ // incomplete. The prior shape returned `ok: 'partial'` — a string —
1206
+ // which is TRUTHY under JS-idiomatic `if (r.ok)` checks, so legacy
1207
+ // callers still saw the partial deploy as success. That's the very bug
1208
+ // class V155-003 was meant to retire.
1209
+ //
1210
+ // TP-002 closes the loop: `ok` is now STRICTLY BOOLEAN (false on
1211
+ // partial), and a new `status` field carries the tri-state for callers
1212
+ // that want to differentiate full success vs partial vs failure.
1213
+ // Legacy `if (r.ok) succeed()` consumers now correctly refuse on
1214
+ // partial; new callers can branch on `r.status === 'partial'`.
1215
+ const partialFailed = opts.scope === 'project' && deployPartial === true;
1132
1216
  return {
1133
- ok: true,
1217
+ ok: !partialFailed,
1218
+ status: partialFailed ? 'partial' : 'success',
1134
1219
  name: manifest.name,
1135
1220
  version: manifest.version,
1136
1221
  scope: opts.scope,
1137
1222
  gate_result_block: gateResultBlock,
1138
1223
  deploy: deployInfo,
1139
1224
  deploy_partial: deployPartial,
1225
+ ...(partialFailed
1226
+ ? { error: 'deploy-partial', partial_failures: partialDeployFailures }
1227
+ : {}),
1140
1228
  };
1141
1229
  } catch (err) {
1142
1230
  return {
1143
1231
  ok: false,
1232
+ status: 'failed',
1144
1233
  errors: [err && err.message ? err.message : String(err)],
1145
1234
  gate_result_block: gateResultBlock,
1146
1235
  };