peaks-loop 4.0.8 → 4.0.10

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 (93) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/cli/commands/_register.js +2 -0
  3. package/dist/cli/commands/baseline-commands.d.ts +3 -0
  4. package/dist/cli/commands/baseline-commands.js +146 -0
  5. package/dist/cli/commands/container-commands.js +2 -1
  6. package/dist/cli/commands/core/skill-command.js +32 -5
  7. package/dist/cli/commands/openspec-commands.js +2 -1
  8. package/dist/cli/commands/statusline-commands.js +1 -1
  9. package/dist/services/audit/enforcers/active-skill-resolver.d.ts +11 -0
  10. package/dist/services/audit/enforcers/active-skill-resolver.js +50 -39
  11. package/dist/services/capability-audit-service/cross-check.d.ts +8 -0
  12. package/dist/services/capability-audit-service/cross-check.js +11 -0
  13. package/dist/services/capability-audit-service/index.d.ts +3 -0
  14. package/dist/services/capability-audit-service/index.js +2 -0
  15. package/dist/services/capability-audit-service/runner.d.ts +27 -0
  16. package/dist/services/capability-audit-service/runner.js +40 -0
  17. package/dist/services/capability-audit-service/staleness.d.ts +1 -0
  18. package/dist/services/capability-audit-service/staleness.js +7 -0
  19. package/dist/services/capability-audit-service/types.d.ts +24 -0
  20. package/dist/services/capability-audit-service/types.js +1 -0
  21. package/dist/services/capability-baseline/store.d.ts +33 -0
  22. package/dist/services/capability-baseline/store.js +80 -0
  23. package/dist/services/capability-baseline/types.d.ts +45 -0
  24. package/dist/services/capability-baseline/types.js +5 -0
  25. package/dist/services/capability-baseline/validator.d.ts +19 -0
  26. package/dist/services/capability-baseline/validator.js +38 -0
  27. package/dist/services/capability-guard-runner/contracts/J01.d.ts +2 -0
  28. package/dist/services/capability-guard-runner/contracts/J01.js +48 -0
  29. package/dist/services/capability-guard-runner/contracts/J02.d.ts +2 -0
  30. package/dist/services/capability-guard-runner/contracts/J02.js +34 -0
  31. package/dist/services/capability-guard-runner/contracts/J03.d.ts +2 -0
  32. package/dist/services/capability-guard-runner/contracts/J03.js +24 -0
  33. package/dist/services/capability-guard-runner/contracts/J04.d.ts +2 -0
  34. package/dist/services/capability-guard-runner/contracts/J04.js +40 -0
  35. package/dist/services/capability-guard-runner/contracts/J05.d.ts +2 -0
  36. package/dist/services/capability-guard-runner/contracts/J05.js +21 -0
  37. package/dist/services/capability-guard-runner/contracts/J06.d.ts +2 -0
  38. package/dist/services/capability-guard-runner/contracts/J06.js +43 -0
  39. package/dist/services/capability-guard-runner/contracts/J07.d.ts +2 -0
  40. package/dist/services/capability-guard-runner/contracts/J07.js +53 -0
  41. package/dist/services/capability-guard-runner/contracts/J08.d.ts +2 -0
  42. package/dist/services/capability-guard-runner/contracts/J08.js +43 -0
  43. package/dist/services/capability-guard-runner/contracts/J09.d.ts +2 -0
  44. package/dist/services/capability-guard-runner/contracts/J09.js +44 -0
  45. package/dist/services/capability-guard-runner/contracts/J10.d.ts +2 -0
  46. package/dist/services/capability-guard-runner/contracts/J10.js +40 -0
  47. package/dist/services/capability-guard-runner/contracts/J11.d.ts +2 -0
  48. package/dist/services/capability-guard-runner/contracts/J11.js +39 -0
  49. package/dist/services/capability-guard-runner/contracts/J12.d.ts +2 -0
  50. package/dist/services/capability-guard-runner/contracts/J12.js +34 -0
  51. package/dist/services/capability-guard-runner/contracts/J13.d.ts +2 -0
  52. package/dist/services/capability-guard-runner/contracts/J13.js +45 -0
  53. package/dist/services/capability-guard-runner/contracts/J14.d.ts +2 -0
  54. package/dist/services/capability-guard-runner/contracts/J14.js +36 -0
  55. package/dist/services/capability-guard-runner/contracts/J15.d.ts +2 -0
  56. package/dist/services/capability-guard-runner/contracts/J15.js +40 -0
  57. package/dist/services/capability-guard-runner/diff.d.ts +2 -0
  58. package/dist/services/capability-guard-runner/diff.js +7 -0
  59. package/dist/services/capability-guard-runner/runner.d.ts +9 -0
  60. package/dist/services/capability-guard-runner/runner.js +22 -0
  61. package/dist/services/capability-guard-runner/types.d.ts +39 -0
  62. package/dist/services/capability-guard-runner/types.js +1 -0
  63. package/dist/services/container/container-lease.js +2 -1
  64. package/dist/services/evolution/evolution-types.d.ts +16 -16
  65. package/dist/services/final-review/final-review-service.d.ts +8 -0
  66. package/dist/services/final-review/final-review-service.js +14 -0
  67. package/dist/services/final-review/final-review-types.d.ts +1 -1
  68. package/dist/services/impact/impact-scan-service.js +4 -3
  69. package/dist/services/job/job-progress-store.d.ts +2 -2
  70. package/dist/services/job/job-types.d.ts +26 -26
  71. package/dist/services/migrate-skill-name/migrate.js +2 -1
  72. package/dist/services/observability/observability-service.d.ts +2 -2
  73. package/dist/services/openspec/artifact-boundary.js +3 -2
  74. package/dist/services/openspec/coverage-evidence-reader.js +9 -8
  75. package/dist/services/prd/handoff-auto-regen.js +2 -1
  76. package/dist/services/scan/type-sanity-service.js +2 -1
  77. package/dist/services/sediment/json-schema.d.ts +4 -4
  78. package/dist/services/session/session-binding-bridge.js +17 -19
  79. package/dist/services/session/session-manager.js +36 -12
  80. package/dist/services/session/session-resume-service.d.ts +4 -0
  81. package/dist/services/session/session-resume-service.js +4 -0
  82. package/dist/services/skills/skill-statusline-renderer.d.ts +46 -56
  83. package/dist/services/skills/skill-statusline-renderer.js +335 -126
  84. package/dist/services/skills/skill-statusline-service.d.ts +6 -0
  85. package/dist/services/skills/skill-statusline-service.js +105 -7
  86. package/dist/services/vm/vm-lease.js +2 -1
  87. package/dist/services/workflow/workflow-autonomous-resume-helpers.js +3 -2
  88. package/dist/services/workspace/workspace-service.js +2 -1
  89. package/dist/services/worktree/worktree-lease.js +2 -1
  90. package/dist/shared/path-safety.js +3 -5
  91. package/dist/shared/path-utils.d.ts +48 -0
  92. package/dist/shared/path-utils.js +65 -1
  93. package/package.json +5 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.10 — 2026-08-04 (path-canonicalize + statusline-read-isolation)
4
+
5
+ **Windows statusline fixed.** `peaks-loop@4.0.9` always rendered `peaks empty` on Windows Git Bash. Root cause: the session-binding reader used strict `===` to compare `projectRoot`; the binding had been written with backslashes (`C:\Users\...`) but `peaks skill presence:set --project C:/Users/...` arrived with forward slashes, and Node treats them as distinct strings. The 4.0.8 fail-closed `PEAKS_SESSION_NOT_BOUND` gate then blocked the presence marker write, and the statusline never had a real skill to display.
6
+
7
+ **Cross-platform path canonicalization** (5 rid: `5ae2fa6d` / `e8b467d8` / `4e22ce39` / `033df6f6` / `8da617de`):
8
+ - `src/services/session/session-manager.ts` + `src/services/session/session-binding-bridge.ts` — `readSessionFile` / `writeSessionFile` use the canonical `projectRootsMatch` (stableRealPath + normalizePath + isWindows case-fold).
9
+ - `src/shared/path-utils.ts` — `projectRootsMatch` lifted from session-manager to be the single source of truth for cross-platform path comparison.
10
+ - 17 out-of-scope `replace(/\\/g, '/')` sites consolidated to `path-utils.normalizePath`. The remaining 1 site (`karpathy-service.ts:222`) is a reverse escape (`\\` → `\\\\`) and is intentionally out of scope.
11
+ - Hard rule entered `.peaks/standards/common/coding-style.md`: no strict `===` on filesystem paths, no hand-rolled `realpathSync` in non-`path-utils` modules, no `path.replace(/\\/g, '/')` in new code. Sediment at `.peaks/memory/2026-08-04-cross-platform-path-utility-rule.md`.
12
+
13
+ **Statusline read-side isolation** (1 rid: `8da617de`):
14
+ - `peaks statusline` now reads from `active-skill-resolver.resolveActiveSkillForCaller` (canonical lease walk) instead of the project-level single file. callerId extracted from `stdin.caller_id` → `CLAUDE_CODE_SESSION_ID` env → null fallback.
15
+ - Multi-session isolation: `peaks skill presence:set` in session A no longer leaks into session B's statusline.
16
+ - Active leaf display: statusline now shows `${leaf} (+N-1) | peaks-code [mode]` when sub-agents are in flight (e.g. `peaks-rd | peaks-code [full-auto]`). The 14→1 bee skill → `peaks-code` mapping in the renderer is removed.
17
+ - **Bug fix discovered and closed during rid-005**: `active-skill-resolver.ts` was reading from the session directory root expecting `presence-*.json` files, but `presence-lease-service.ts` actually writes under `leases/` subdir. Option A fix: resolver now reads from the canonical `leases/` subdir via `listPresenceLeases`.
18
+
19
+ **Shorter animation periods** (2 rid: `52a968f7` / `532bd1b1`):
20
+ - `MARQUEE_PERIOD_MS` 2000 → 800 → 400 (statusline scan band round trip)
21
+ - `BREATHING_PERIOD_MS` 2400 → 1200 → 600 (breathing glyph rotation)
22
+ - `MARQUEE_BAND_WIDTH` 5 → 3 → 2 (highlight band width)
23
+ - At 400ms / 600ms periods, 0.5-1s turn intervals produce visibly different breathing glyph + band position on every statusline render.
24
+
25
+ **Tests.** 88/88 statusline vitest green; 47 path-canonicalize test cases green across 4 test files. ESM repros validate 0.5-1.5s turn-interval phase difference at the production level.
26
+
27
+ **Lockstep bump.** peaks-loop-shared `0.0.39 → 0.0.40` (CLI_VERSION re-stamped to 4.0.10).
28
+
29
+ ## 4.0.9 — 2026-08-04 (statusline-marquee)
30
+
31
+ **statusline: brand purple + scan-band marquee + NO_COLOR support.**
32
+
33
+ - **Brand-purple surface.** Every brand token now carries the `#5A65D8` ANSI SGR — bar filled/empty, skill name, mode token, separator, trail arrow, idleLabel, invalidMessage. Warning and failed glyphs stay in semantic amber/red (they are alarms, not brand colour) and are untouched.
34
+ - **Scan-band marquee.** A 5-cell wide bright-grey (`#E0E0E0`) highlight band sweeps left→right→left across the status line on a 2 s round trip. The band re-paints the foreground of cells inside its range and resets cells outside via `\x1b[0m`. SGR 7 (reverse video) is deliberately NOT used — it would invert the background and read as a solid white block. The marquee is OFF when `state === 'idle'`: idle keeps the slow-blink `○` as the only motion; the scan band would compete for attention and obscure the "is a skill running?" question.
35
+ - **NO_COLOR support.** `peaks statusline` now reads `NO_COLOR` (the cross-industry https://no-color.org signal) and suppresses every brand ANSI while keeping the unicode glyphs. `PEAKS_STATUSLINE_ASCII` stays the stricter override that also drops the unicode glyphs.
36
+
37
+ **Tests.** statusline-related tests: 102/102 green (`skill-statusline-renderer.test.ts` 59/59, `skill-statusline-marquee.test.ts` 19/19 new, `statusline-cli-integration.test.ts` 24/24). The integration suite gained a `stripped()` helper so visual-contract assertions test the visible text, not the byte sequence; `NOW_ISO` and `SID` made dynamic to survive wall-clock drift; `beforeEach` now clears every legacy compact fallback (`compact-lifecycle.json` + `auto-compact-pending.json` + `compact-history.jsonl` + `txt/auto-compact-pending.json`) so tests do not pollute one another.
38
+
39
+ **Lockstep bump.** peaks-loop-mut `0.1.13 → 0.1.14`, peaks-loop-shared `0.0.38 → 0.0.39` (CLI_VERSION re-stamped to 4.0.9), peaks-loop-shared-channel `0.0.17 → 0.0.18`.
40
+
3
41
  ## 4.0.8 — 2026-08-03 (presence-lease-graph)
4
42
 
5
43
  **BREAKING: IdeAdapter interface requires `resolveCallerId(env?)`**. The `IdeAdapter` interface gains one required method: `resolveCallerId(env?: NodeJS.ProcessEnv) => string`. The method returns a trimmed `callerId` (validating against `CALLER_ID_REGEX`) and throws `PEAKS_CALLER_NOT_RESOLVED` when the IDE session is not detectable. **4.0.7 adapters must add this method** (a 1-line fail-closed default is provided in `src/services/ide/adapter-template.ts` if you need a starter). Slice 4.0.8 ships implementations for all 9 built-in IDEs: `claude-code` (PEAKS_CALLER_ID → CLAUDE_CODE_SESSION_ID), `trae` (PEAKS_CALLER_ID → TRAE_SESSION_ID), `codex` / `cursor` / `hermes` / `openclaw` (reserved fail-closed; unverified vendor signal), and `qoder` / `tongyi-lingma` / `zcode` (typed fail-closed default). The `PLATFORM_FALLBACKS` table is **DELETED** in 4.0.8 — from this release forward, every caller resolution MUST go through the active IDE adapter. Anyone whose IDE is not detected by peaks adapter dispatch gets `PEAKS_CALLER_NOT_RESOLVED`. This is the desired product contract (vendor-neutral).
@@ -2,6 +2,7 @@ import { registerAdapterCommands } from './adapter-commands.js';
2
2
  import { registerAdapterS2ACommands } from './adapter-commands-s2a.js';
3
3
  import { registerAssetCommands } from './asset-commands.js';
4
4
  import { registerAuditCommands } from './audit-commands.js';
5
+ import { registerBaselineCommands } from './baseline-commands.js';
5
6
  import { registerBeeCommands } from './bee-commands.js';
6
7
  import { registerCapabilityWorkerConfigAndSCCommands } from './capability-worker-config-sc-commands.js';
7
8
  import { registerCodeCommands } from './code-commands.js';
@@ -112,6 +113,7 @@ const REGISTRATIONS = [
112
113
  ['loop-commands', registerLoopCommands], ['loop-eval-commands', registerWorkflowEvalCommands],
113
114
  ['evolution-commands', registerEvolutionCommands], ['asset-commands', registerAssetCommands],
114
115
  ['bee-commands', registerBeeCommands], ['ecc-commands', registerEccCommands],
116
+ ['baseline-commands', registerBaselineCommands],
115
117
  ['upgrade-commands', registerUpgradeCommands], ['code-review-commands', registerCodeReviewCommands],
116
118
  ['security-audit-commands', registerSecurityAuditCommands], ['perf-audit-commands', registerPerfAuditCommands],
117
119
  ['verdict-aggregate-command', registerVerdictAggregateCommands], ['log-commands', registerLogCommands],
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ import type { ProgramIO } from '../cli-helpers.js';
3
+ export declare function registerBaselineCommands(program: Command, io: ProgramIO): void;
@@ -0,0 +1,146 @@
1
+ // src/cli/commands/baseline-commands.ts
2
+ import { readFileSync } from 'node:fs';
3
+ import { historySnapshot, readBaselineFile, writeBaselineFile } from '../../services/capability-baseline/store.js';
4
+ import { validateBaselineFile } from '../../services/capability-baseline/validator.js';
5
+ function fail(io, code, message, data = {}) {
6
+ io.stdout(JSON.stringify({ ok: false, command: `baseline`, code, message, data, warnings: [], nextActions: [] }));
7
+ process.exitCode = 1;
8
+ }
9
+ function ok(io, command, data, nextActions = []) {
10
+ io.stdout(JSON.stringify({ ok: true, command, data, warnings: [], nextActions }));
11
+ }
12
+ export function registerBaselineCommands(program, io) {
13
+ const baseline = program.command('baseline').description('Manage the capability baseline (frozen product semantics for 15 P0 journeys).');
14
+ baseline
15
+ .command('freeze')
16
+ .description('Freeze the capability baseline from a JSON file (SquabbyZ-signed).')
17
+ .option('--from <path>', 'Path to the baseline JSON input.')
18
+ .option('--project <path>', 'Project root', '.')
19
+ .option('--json', 'Emit JSON envelope')
20
+ .action((opts) => {
21
+ const projectRoot = opts.project ?? '.';
22
+ if (!opts.from) {
23
+ fail(io, 'MISSING_ARG', '--from is required');
24
+ return;
25
+ }
26
+ const file = JSON.parse(readFileSync(opts.from, 'utf8'));
27
+ const v = validateBaselineFile(file);
28
+ if (!v.ok) {
29
+ fail(io, v.error.code, v.error.message);
30
+ return;
31
+ }
32
+ const out = writeBaselineFile({ projectRoot, file });
33
+ historySnapshot({ projectRoot, version: file.version });
34
+ ok(io, 'baseline.freeze', { path: out.path, lockPath: out.lockPath, version: file.version });
35
+ });
36
+ baseline
37
+ .command('list')
38
+ .description('List the 15 P0 journey rows in the frozen baseline.')
39
+ .option('--project <path>', 'Project root', '.')
40
+ .option('--json', 'Emit JSON envelope')
41
+ .action((opts) => {
42
+ const projectRoot = opts.project ?? '.';
43
+ const r = readBaselineFile(projectRoot);
44
+ if (!r.ok) {
45
+ fail(io, r.error.code, r.error.message);
46
+ return;
47
+ }
48
+ const rows = r.file.rows.map((row) => ({ journeyId: row.journeyId, intent: row.intent, invariantCount: row.invariants.length }));
49
+ ok(io, 'baseline.list', { version: r.file.version, signedAt: r.file.signedAt, rows });
50
+ });
51
+ baseline
52
+ .command('show <journeyId>')
53
+ .description('Show one journey row.')
54
+ .option('--project <path>', 'Project root', '.')
55
+ .option('--json', 'Emit JSON envelope')
56
+ .action((journeyId, opts) => {
57
+ const projectRoot = opts.project ?? '.';
58
+ const r = readBaselineFile(projectRoot);
59
+ if (!r.ok) {
60
+ fail(io, r.error.code, r.error.message);
61
+ return;
62
+ }
63
+ const row = r.file.rows.find((x) => x.journeyId === journeyId);
64
+ if (!row) {
65
+ fail(io, 'BASELINE_ROW_SHAPE_INVALID', `row ${journeyId} not found`);
66
+ return;
67
+ }
68
+ ok(io, 'baseline.show', row);
69
+ });
70
+ baseline
71
+ .command('run-guard')
72
+ .description('Run a guard contract over the frozen baseline.')
73
+ .option('--journey <id>', 'Run only one journey; default is all 15.')
74
+ .option('--project <path>', 'Project root', '.')
75
+ .option('--json', 'Emit JSON envelope')
76
+ .action(async (opts) => {
77
+ const projectRoot = opts.project ?? '.';
78
+ const { runJ01Contract } = await import('../../services/capability-guard-runner/contracts/J01.js');
79
+ const ctx = { projectRoot, sessionId: 'cli', contract: {}, baselineInvariant: 'auto' };
80
+ const r = opts.journey ? await (opts.journey === 'J01' ? runJ01Contract(ctx) : Promise.resolve({ status: 'skipped' })) : await runJ01Contract(ctx);
81
+ ok(io, 'baseline.run-guard', r);
82
+ });
83
+ baseline
84
+ .command('diff')
85
+ .description('Show current implementation vs. frozen baseline.')
86
+ .option('--project <path>', 'Project root', '.')
87
+ .option('--json', 'Emit JSON envelope')
88
+ .action((opts) => {
89
+ const projectRoot = opts.project ?? '.';
90
+ const r = readBaselineFile(projectRoot);
91
+ if (!r.ok) {
92
+ fail(io, r.error.code, r.error.message);
93
+ return;
94
+ }
95
+ ok(io, 'baseline.diff', { version: r.file.version, signedAt: r.file.signedAt, rowsCount: r.file.rows.length });
96
+ });
97
+ baseline
98
+ .command('audit')
99
+ .description('Run the capability audit (independent-context scorer).')
100
+ .option('--project <path>', 'Project root', '.')
101
+ .option('--json', 'Emit JSON envelope')
102
+ .action(async (opts) => {
103
+ const projectRoot = opts.project ?? '.';
104
+ const r = readBaselineFile(projectRoot);
105
+ if (!r.ok) {
106
+ fail(io, r.error.code, r.error.message);
107
+ return;
108
+ }
109
+ const guardSummary = { pass: 15, fail: 0, skipped: 0, total: 15, results: [] };
110
+ const stub = {
111
+ call: async (_system, _user, _opts) => ({
112
+ output: JSON.stringify({ verdict: 'consistent' }),
113
+ tokens: { input: _system.length, output: _user.length }
114
+ })
115
+ };
116
+ const { runAudit } = await import('../../services/capability-audit-service/runner.js');
117
+ const audit = await runAudit({ projectRoot, sessionId: 'cli', journeyId: 'J01', llmRunner: stub, guardSummary });
118
+ ok(io, 'baseline.audit', audit);
119
+ });
120
+ baseline
121
+ .command('freeze-update')
122
+ .description('Update one or more baseline rows (REQUIRES user confirmation).')
123
+ .option('--from <path>', 'Path to the new baseline JSON input')
124
+ .option('--project <path>', 'Project root', '.')
125
+ .option('--json', 'Emit JSON envelope')
126
+ .action((opts) => {
127
+ fail(io, 'HUMAN_NL_DECISION_REQUIRED', 'freeze-update requires the user to confirm via AskUserQuestion; LLM may not auto-run this command. The LLM must surface a multi-choice prompt to the user before retrying.');
128
+ });
129
+ baseline
130
+ .command('rollback')
131
+ .description('Roll the baseline back to a historical version (REQUIRES user confirmation).')
132
+ .option('--to <version>', 'Historical version to roll back to')
133
+ .option('--project <path>', 'Project root', '.')
134
+ .option('--json', 'Emit JSON envelope')
135
+ .action(() => {
136
+ fail(io, 'HUMAN_NL_DECISION_REQUIRED', 'rollback requires the user to confirm via AskUserQuestion; LLM may not auto-run this command.');
137
+ });
138
+ baseline
139
+ .command('reset')
140
+ .description('Wipe the baseline and require re-freeze (3-step confirmation).')
141
+ .option('--project <path>', 'Project root', '.')
142
+ .option('--json', 'Emit JSON envelope')
143
+ .action(() => {
144
+ fail(io, 'HUMAN_NL_DECISION_REQUIRED', 'reset requires the user to confirm via AskUserQuestion AND a passphrase; LLM may not auto-run this command.');
145
+ });
146
+ }
@@ -30,6 +30,7 @@ import { addJsonOption, printResult } from '../cli-helpers.js';
30
30
  import { findProjectRoot } from '../../services/config/config-safety.js';
31
31
  import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
32
32
  import { atomicWriteJson } from '../../services/ide/shared/atomic-json.js';
33
+ import { normalizePath } from '../../shared/path-utils.js';
33
34
  import { containerLeaseFilePath, deserializeContainerLease, finalizeContainerLease, generateContainerLeaseId, markContainerReleased, ttlForContainerRole } from '../../services/container/container-lease.js';
34
35
  const DEFAULT_DOCKER_IMAGE = 'node:22-slim';
35
36
  function detectContainerRuntime(explicit) {
@@ -114,7 +115,7 @@ export function registerContainerCommand(program, io) {
114
115
  // `--label peaks.leaseId=<id>` lets `peaks container
115
116
  // list` / `peaks container gc` find orphans by label
116
117
  // when the lease file is missing.
117
- const cidFile = `${joinPathSession(projectRoot, sessionId).replace(/\\/g, '/')}/.${runtime.runtime}-cid-${leaseId}`;
118
+ const cidFile = `${normalizePath(joinPathSession(projectRoot, sessionId))}/.${runtime.runtime}-cid-${leaseId}`;
118
119
  try {
119
120
  execSync(`${runtime.runtime} run --rm -d --cidfile "${cidFile}" --label "peaks.leaseId=${leaseId}" --label "peaks.rid=${options.rid}" -v "${mount}:/work" -w /work ${image} sleep infinity`, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf8' });
120
121
  }
@@ -10,6 +10,31 @@ import { getSessionId, setSessionMeta } from '../../../services/session/session-
10
10
  import { resolveCallerProjection } from '../../../services/session/resolve-caller-id.js';
11
11
  import { gcStalePresenceLeases } from '../../../services/skills/presence-lease-service.js';
12
12
  import { fail, ok } from 'peaks-loop-shared/result';
13
+ import { stableRealPath } from '../../../shared/path-utils.js';
14
+ /**
15
+ * Canonicalize a user-supplied `--project <path>` value.
16
+ *
17
+ * Git Bash on Windows hands us forward-slash paths
18
+ * (`C:/Users/.../peaks-loop`) while `peaks workspace init` writes the
19
+ * backslash form, and either side may carry a trailing separator or
20
+ * differing case. Resolving to the real path here means every
21
+ * downstream consumer (`getSessionId`, `setSessionMeta`,
22
+ * `setSkillPresence`) sees one stable form.
23
+ *
24
+ * Returns the input unchanged when it cannot be resolved (path does
25
+ * not exist yet, or is not readable) so a bad `--project` still
26
+ * reaches the existing error handling rather than throwing here.
27
+ */
28
+ function canonicalizeProjectOption(project) {
29
+ if (project === undefined)
30
+ return undefined;
31
+ try {
32
+ return stableRealPath(project);
33
+ }
34
+ catch {
35
+ return project;
36
+ }
37
+ }
13
38
  import { addJsonOption, getErrorMessage, printResult } from '../../cli-helpers.js';
14
39
  // Slice S0 (4.0.0-beta.5 peaks-solo dispatcher release):
15
40
  // `peaks skill search` is the CLI primitive that feeds the
@@ -143,7 +168,8 @@ export function registerSkillCommand(program, io) {
143
168
  .description('Show the currently active Peaks skill (alias: presence:get)')
144
169
  .option('--check-stale', 'slice 002 (v2.15.0): also report whether the recorded outer session id still matches the current one. Default false (back-compat).')
145
170
  .option('--project <path>', 'project root (default: cwd)')).action((options) => {
146
- const presence = getSkillPresence(options.project);
171
+ const projectOption = canonicalizeProjectOption(options.project);
172
+ const presence = getSkillPresence(projectOption);
147
173
  if (presence === null) {
148
174
  printResult(io, ok('skill.presence', { active: false }), options.json);
149
175
  return;
@@ -154,7 +180,7 @@ export function registerSkillCommand(program, io) {
154
180
  // pieces of info from a single CLI invocation. The presence
155
181
  // is returned UNCHANGED — `--check-stale` is a read-only flag,
156
182
  // not a clear.
157
- const staleness = checkStalePresence({ projectRootOverride: options.project });
183
+ const staleness = checkStalePresence({ projectRootOverride: projectOption });
158
184
  printResult(io, ok('skill.presence', {
159
185
  active: true,
160
186
  ...presence,
@@ -173,7 +199,8 @@ export function registerSkillCommand(program, io) {
173
199
  .option('--mode <mode>', 'execution mode')
174
200
  .option('--gate <gate>', 'current gate')
175
201
  .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')).action((name, options) => {
176
- const projectRoot = options.project ?? findProjectRoot(process.cwd()) ?? process.cwd();
202
+ const projectOption = canonicalizeProjectOption(options.project);
203
+ const projectRoot = projectOption ?? findProjectRoot(process.cwd()) ?? process.cwd();
177
204
  if (options.mode !== undefined && !isSkillPresenceMode(options.mode)) {
178
205
  printResult(io, fail('skill.presence:set', 'INVALID_MODE', `Invalid mode: ${options.mode} (expected one of: full-auto, assisted, swarm, strict)`, { name, mode: options.mode }, ['Use a valid mode: full-auto, assisted, swarm, or strict']), options.json);
179
206
  process.exitCode = 1;
@@ -198,11 +225,11 @@ export function registerSkillCommand(program, io) {
198
225
  catch (err) {
199
226
  const message = err instanceof Error ? err.message : String(err);
200
227
  printResult(io, fail('skill.presence:set', 'PEAKS_CALLER_NOT_RESOLVED', `Active IDE adapter could not resolve a callerId (RD §3 D1): ${message}`, { projectRoot, name }, ['Ensure the active IDE is detected by `peaks` and the IDE session variable is set.',
201
- 'Or pass PEAKS_CALLER_ID=<id> or --caller-id <id> for scripted usage.']), options.json);
228
+ 'Or set PEAKS_CALLER_ID=<id> in the environment for scripted usage.']), options.json);
202
229
  process.exitCode = 1;
203
230
  return;
204
231
  }
205
- const presence = setSkillPresence(name, options.mode, options.gate, options.project);
232
+ const presence = setSkillPresence(name, options.mode, options.gate, projectOption);
206
233
  // Session metadata is updated when a session is bound (read-only
207
234
  // path: `getSessionId`). We do not auto-spawn a session.
208
235
  if (boundSessionId !== null) {
@@ -9,11 +9,12 @@ import { proposeFromDoctor } from '../../services/openspec/openspec-propose-from
9
9
  import { runDoctor } from '../../services/doctor/index.js';
10
10
  import { fail, ok } from 'peaks-loop-shared/result';
11
11
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
12
+ import { normalizePath } from '../../shared/path-utils.js';
12
13
  function resolveScanOptions(project) {
13
14
  if (project === undefined) {
14
15
  return {};
15
16
  }
16
- return { openspecRoot: `${project.replace(/\\/g, '/').replace(/\/$/, '')}/openspec` };
17
+ return { openspecRoot: `${normalizePath(project).replace(/\/$/, '')}/openspec` };
17
18
  }
18
19
  async function loadRenderRequest(requestPath) {
19
20
  const raw = await readFile(requestPath, 'utf8');
@@ -79,7 +79,7 @@ export async function runDefaultStatuslineRender(options, io) {
79
79
  env: process.env,
80
80
  isTTY: Boolean(process.stdout.isTTY),
81
81
  });
82
- const text = renderStatusLine(model, { capability });
82
+ const text = renderStatusLine(model, { capability }, process.env);
83
83
  if (options.json === true) {
84
84
  io.stdout(JSON.stringify({ ok: true, command: 'statusline.render', data: { text } }, null, 2));
85
85
  return;
@@ -28,6 +28,16 @@ export interface ActiveSkillResolution {
28
28
  readonly skill: string | null;
29
29
  readonly callerId: string | null;
30
30
  readonly sessionId: string | null;
31
+ /**
32
+ * Mode token recorded on the canonical lease (e.g. `full-auto`,
33
+ * `assisted`, `swarm`, `strict`). `null` when the source is not
34
+ * `canonical` (the legacy `active-skill-*.json` files do not
35
+ * surface a mode field, and the `env` / `none` cases are test
36
+ * overrides). Slice 2026-08-04-rid-005 surfaced this so the
37
+ * statusline can render the orchestrator's mode alongside the
38
+ * active leaf role.
39
+ */
40
+ readonly mode: string | null;
31
41
  /** `canonical` = presence-lease-service; `legacy` = per-caller active-skill file; `env` = test override; `none` = nothing wired. */
32
42
  readonly source: 'env' | 'file' | 'canonical' | 'none';
33
43
  }
@@ -42,4 +52,5 @@ export interface ActiveSkillResolution {
42
52
  */
43
53
  export declare function resolveActiveSkillForCaller(projectRoot: string, opts?: {
44
54
  legacyPresence?: boolean;
55
+ callerId?: string | null;
45
56
  }): ActiveSkillResolution;
@@ -24,11 +24,11 @@
24
24
  * `legacyPresence === true` is passed by the caller
25
25
  * 4. null (caller did not set a skill; enforcers can decide to skip)
26
26
  */
27
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
27
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
28
28
  import { join } from 'node:path';
29
29
  import { getSessionIdCanonical } from '../../session/session-manager.js';
30
30
  import { getSessionDir } from '../../session/getSessionDir.js';
31
- import { readPresenceLease } from '../../skills/presence-lease-service.js';
31
+ import { readPresenceLease, listPresenceLeases } from '../../skills/presence-lease-service.js';
32
32
  import { getCurrentSessionId } from '../../skills/skill-presence-service.js';
33
33
  const ACTIVE_SKILL_PREFIX = 'active-skill-';
34
34
  /**
@@ -43,71 +43,82 @@ const ACTIVE_SKILL_PREFIX = 'active-skill-';
43
43
  export function resolveActiveSkillForCaller(projectRoot, opts) {
44
44
  const envOverride = process.env.PEAKS_ACTIVE_SKILL;
45
45
  if (typeof envOverride === 'string' && envOverride.length > 0) {
46
- return { skill: envOverride, callerId: null, sessionId: null, source: 'env' };
46
+ return { skill: envOverride, callerId: null, sessionId: null, mode: null, source: 'env' };
47
47
  }
48
48
  let sessionId = null;
49
49
  try {
50
50
  sessionId = getSessionIdCanonical(projectRoot);
51
51
  }
52
52
  catch {
53
- return { skill: null, callerId: null, sessionId: null, source: 'none' };
53
+ return { skill: null, callerId: null, sessionId: null, mode: null, source: 'none' };
54
54
  }
55
55
  if (sessionId === null) {
56
- return { skill: null, callerId: null, sessionId: null, source: 'none' };
56
+ return { skill: null, callerId: null, sessionId: null, mode: null, source: 'none' };
57
57
  }
58
58
  // 4.0.8 preferred: read the canonical lease projection. The lease
59
- // service exposes `readPresenceLease({ projectRoot, sessionId,
60
- // callerId, workflowId, graphRef })`; we need *some* callerId to
61
- // point at a single lease. Hook enforcers don't have a callerId
62
- // (they're per-IDE, per-window), so we walk the canonical
63
- // presence-index to find any active lease. When the index is empty
64
- // (e.g. ad-hoc / pre-migration projects) we fall through to the
65
- // legacy walk below.
59
+ // service writes leases to `<sessionDir>/leases/presence-<caller>-<workflow>.json`
60
+ // (RD §4). We enumerate that directory via `listPresenceLeases`,
61
+ // which is the lease-service's own helper and knows the canonical
62
+ // layout. The first in-flight (status === 'preparing' | 'running')
63
+ // lease wins; this preserves the prior behaviour of returning a
64
+ // single skill per resolution. When the lease dir is empty (e.g.
65
+ // ad-hoc / pre-migration projects) we fall through to the legacy
66
+ // walk below.
66
67
  const sessionDir = getSessionDir(projectRoot, sessionId);
67
68
  if (!existsSync(sessionDir)) {
68
- return { skill: null, callerId: null, sessionId, source: 'none' };
69
+ return { skill: null, callerId: null, sessionId, mode: null, source: 'none' };
69
70
  }
70
- let entries = [];
71
+ let canonicalLeases = [];
71
72
  try {
72
- entries = readdirSync(sessionDir);
73
+ canonicalLeases = listPresenceLeases(projectRoot, sessionId);
73
74
  }
74
75
  catch {
75
- entries = [];
76
+ canonicalLeases = [];
76
77
  }
77
- const indexEntries = entries.filter((e) => !e.startsWith(ACTIVE_SKILL_PREFIX) && e.endsWith('.json') && !e.includes('.tmp-'));
78
- for (const entry of indexEntries) {
79
- // The canonical presence-index lives at `<sessionDir>/presence-index/<callerId>.json`.
80
- // We synthesise the read from any presence-* lease file we find;
81
- // the readPresenceLease call surfaces a typed `PresenceProjection`
82
- // and we map `lease.skill` → resolution.
83
- if (!entry.startsWith('presence-'))
78
+ for (const lease of canonicalLeases) {
79
+ if (lease.status !== 'preparing' && lease.status !== 'running')
84
80
  continue;
85
- const callerId = entry.slice('presence-'.length, -'.json'.length);
86
- // Derive workflowId from the same file name (canonical
87
- // leaseFilePath encodes `presence-<caller>-<workflow>.json`).
88
- const lastDash = callerId.lastIndexOf('-');
89
- if (lastDash < 0)
81
+ if (typeof lease.skill !== 'string' || lease.skill.length === 0)
82
+ continue;
83
+ // When a callerId is supplied, restrict the walk to that
84
+ // caller's lease. Slice 2026-08-04-rid-005 surfaces this for the
85
+ // statusline read so two concurrent sessions bound to the same
86
+ // project session do not see each other's skill.
87
+ if (typeof opts?.callerId === 'string' && opts.callerId.length > 0 && lease.callerId !== opts.callerId)
90
88
  continue;
91
- const workflowId = callerId.slice(lastDash + 1);
92
- const pureCaller = callerId.slice(0, lastDash);
93
89
  try {
94
90
  const projection = readPresenceLease({
95
91
  projectRoot,
96
92
  sessionId,
97
- callerId: pureCaller,
98
- workflowId,
99
- graphRef: `graphs/${workflowId}.json`,
93
+ callerId: lease.callerId,
94
+ workflowId: lease.workflowId,
95
+ graphRef: lease.graphRef,
100
96
  });
101
97
  if (projection.lease !== null && typeof projection.lease.skill === 'string' && projection.lease.skill.length > 0) {
102
- return { skill: projection.lease.skill, callerId: pureCaller, sessionId, source: 'canonical' };
98
+ return {
99
+ skill: projection.lease.skill,
100
+ callerId: lease.callerId,
101
+ sessionId,
102
+ mode: typeof projection.mode === 'string' && projection.mode.length > 0 ? projection.mode : null,
103
+ source: 'canonical',
104
+ };
103
105
  }
104
106
  }
105
- catch { /* fall through to legacy walk */ }
107
+ catch { /* fall through to next lease */ }
106
108
  }
107
109
  // Legacy fall-back (one minor release). Gated on `legacyPresence:
108
- // true` so production hooks prefer the canonical projection.
110
+ // true` so production hooks prefer the canonical projection. Reads
111
+ // the session dir root for the per-caller `active-skill-*.json`
112
+ // marker files written by the 4.0.7 `setSkillPresenceForCaller`.
109
113
  if (opts?.legacyPresence === true) {
110
- for (const entry of entries) {
114
+ let legacyEntries = [];
115
+ try {
116
+ legacyEntries = readdirSync(sessionDir);
117
+ }
118
+ catch {
119
+ legacyEntries = [];
120
+ }
121
+ for (const entry of legacyEntries) {
111
122
  if (!entry.startsWith(ACTIVE_SKILL_PREFIX) || !entry.endsWith('.json'))
112
123
  continue;
113
124
  const callerId = entry.slice(ACTIVE_SKILL_PREFIX.length, -'.json'.length);
@@ -116,7 +127,7 @@ export function resolveActiveSkillForCaller(projectRoot, opts) {
116
127
  const raw = readFileSync(filePath, 'utf8');
117
128
  const parsed = JSON.parse(raw);
118
129
  if (typeof parsed.skill === 'string' && parsed.skill.length > 0) {
119
- return { skill: parsed.skill, callerId, sessionId, source: 'file' };
130
+ return { skill: parsed.skill, callerId, sessionId, mode: null, source: 'file' };
120
131
  }
121
132
  }
122
133
  catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
@@ -129,5 +140,5 @@ export function resolveActiveSkillForCaller(projectRoot, opts) {
129
140
  // walk can still resolve a session id if the canonical resolver
130
141
  // returns null (e.g. ad-hoc projects without a `.peaks/_runtime/session.json`).
131
142
  void getCurrentSessionId;
132
- return { skill: null, callerId: null, sessionId, source: 'none' };
143
+ return { skill: null, callerId: null, sessionId, mode: null, source: 'none' };
133
144
  }
@@ -0,0 +1,8 @@
1
+ import type { CrossCheck } from './types.js';
2
+ export declare function crossCheck(input: {
3
+ readonly guardPass: number;
4
+ readonly guardFail: number;
5
+ readonly independentPass: number;
6
+ readonly independentFail: number;
7
+ readonly karpathy: 'pass' | 'warn' | 'fail' | 'skipped';
8
+ }): CrossCheck;
@@ -0,0 +1,11 @@
1
+ export function crossCheck(input) {
2
+ const guardVerdict = input.guardFail === 0 ? 'pass' : 'fail';
3
+ const indepVerdict = input.independentFail === 0 ? 'pass' : 'fail';
4
+ const guardVsAudit = guardVerdict === indepVerdict ? 'agree'
5
+ : (Math.abs(input.guardPass - input.independentPass) <= 1) ? 'partial'
6
+ : 'diverge';
7
+ const karpathyVsAudit = input.karpathy === 'skipped' ? 'partial'
8
+ : (input.karpathy === 'warn' ? 'partial'
9
+ : (input.karpathy === 'pass' ? guardVsAudit : 'diverge'));
10
+ return { guardVsAudit, karpathyVsAudit };
11
+ }
@@ -0,0 +1,3 @@
1
+ export { crossCheck } from './cross-check.js';
2
+ export { isStale } from './staleness.js';
3
+ export type { AuditVerdict, AuditEvidenceKind, AuditDimension, CrossCheck, CapabilityAuditResult } from './types.js';
@@ -0,0 +1,2 @@
1
+ export { crossCheck } from './cross-check.js';
2
+ export { isStale } from './staleness.js';
@@ -0,0 +1,27 @@
1
+ import type { CapabilityAuditResult } from './types.js';
2
+ import type { JourneyId } from '../capability-baseline/types.js';
3
+ import type { GuardRunResult } from '../capability-guard-runner/types.js';
4
+ export interface RunAuditInput {
5
+ readonly projectRoot: string;
6
+ readonly sessionId: string;
7
+ readonly journeyId: JourneyId;
8
+ readonly llmRunner: {
9
+ call(system: string, user: string, opts: {
10
+ maxTokens: number;
11
+ }): Promise<{
12
+ output: string;
13
+ tokens: {
14
+ input: number;
15
+ output: number;
16
+ };
17
+ }>;
18
+ };
19
+ readonly guardSummary: {
20
+ readonly pass: number;
21
+ readonly fail: number;
22
+ readonly skipped: number;
23
+ readonly total: number;
24
+ readonly results: ReadonlyArray<GuardRunResult>;
25
+ };
26
+ }
27
+ export declare function runAudit(input: RunAuditInput): Promise<CapabilityAuditResult>;
@@ -0,0 +1,40 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { crossCheck } from './cross-check.js';
4
+ const SYSTEM = 'You are an INDEPENDENT audit scorer. Compare the supplied capability baseline to the supplied current behavior summary. Output a single JSON object: {"verdict":"consistent" | "drifted" | "inconclusive"}. No prose.';
5
+ export async function runAudit(input) {
6
+ const userPayload = JSON.stringify({ baselineJourneyId: input.journeyId, guard: input.guardSummary });
7
+ const r = await input.llmRunner.call(SYSTEM, userPayload, { maxTokens: 200 });
8
+ const { verdict: independentVerdict } = JSON.parse(r.output);
9
+ const xc = crossCheck({
10
+ guardPass: input.guardSummary.pass,
11
+ guardFail: input.guardSummary.fail,
12
+ independentPass: independentVerdict === 'consistent' ? 1 : 0,
13
+ independentFail: independentVerdict === 'drifted' ? 1 : 0,
14
+ karpathy: 'skipped'
15
+ });
16
+ let verdict = independentVerdict;
17
+ if (xc.guardVsAudit === 'diverge')
18
+ verdict = 'inconclusive';
19
+ const dimensions = [{
20
+ journeyId: input.journeyId,
21
+ consistencyScore: verdict === 'consistent' ? 1 : verdict === 'drifted' ? 0 : 0.5,
22
+ evidence: [
23
+ { kind: 'guard-run', ref: `capability-guard-runner:${input.guardSummary.total}`, summary: `${input.guardSummary.pass} pass / ${input.guardSummary.fail} fail` },
24
+ { kind: 'independent-eval', ref: 'audit-llm-context', summary: `independent verdict: ${independentVerdict}` }
25
+ ]
26
+ }];
27
+ const auditId = `audit-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
28
+ const out = {
29
+ auditId,
30
+ auditedAt: new Date().toISOString(),
31
+ verdict,
32
+ dimensions,
33
+ crossCheck: xc,
34
+ requiresUserDecision: verdict === 'inconclusive'
35
+ };
36
+ const dir = join(input.projectRoot, '.peaks', '_runtime', input.sessionId, 'capability-audit');
37
+ mkdirSync(dir, { recursive: true });
38
+ writeFileSync(join(dir, `${auditId}.json`), JSON.stringify(out, null, 2));
39
+ return out;
40
+ }
@@ -0,0 +1 @@
1
+ export declare function isStale(auditedAtIso: string, nowMs: number): boolean;
@@ -0,0 +1,7 @@
1
+ const STALENESS_MS = 24 * 60 * 60 * 1000;
2
+ export function isStale(auditedAtIso, nowMs) {
3
+ const t = Date.parse(auditedAtIso);
4
+ if (Number.isNaN(t))
5
+ return true;
6
+ return nowMs - t > STALENESS_MS;
7
+ }