peaks-loop 4.0.6 → 4.0.8

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 (66) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/cli/commands/code-runtime-commands.js +30 -3
  3. package/dist/cli/commands/core/skill-command.js +75 -13
  4. package/dist/cli/commands/dispatch-commands.d.ts +20 -0
  5. package/dist/cli/commands/dispatch-commands.js +38 -1
  6. package/dist/cli/commands/dispatch-from-dag.js +17 -0
  7. package/dist/cli/commands/heartbeat-commands.d.ts +21 -0
  8. package/dist/cli/commands/heartbeat-commands.js +40 -0
  9. package/dist/cli/commands/hook-handle.js +5 -2
  10. package/dist/cli/commands/loop-eval-commands.js +5 -2
  11. package/dist/cli/commands/sub-agent-shared.d.ts +9 -0
  12. package/dist/cli/commands/workflow-commands.js +8 -0
  13. package/dist/cli/commands/workflow-lifecycle-commands.d.ts +63 -0
  14. package/dist/cli/commands/workflow-lifecycle-commands.js +302 -0
  15. package/dist/cli/commands/workspace/init-command.js +19 -0
  16. package/dist/services/audit/enforcers/active-skill-resolver.d.ts +25 -9
  17. package/dist/services/audit/enforcers/active-skill-resolver.js +83 -21
  18. package/dist/services/code/auto-compact-orchestrator.d.ts +35 -2
  19. package/dist/services/code/auto-compact-orchestrator.js +29 -9
  20. package/dist/services/dispatch/dispatch-record-writer.d.ts +25 -1
  21. package/dist/services/dispatch/dispatch-record-writer.js +85 -15
  22. package/dist/services/doctor/doctor-service/checks/skill-presence.d.ts +8 -0
  23. package/dist/services/doctor/doctor-service/checks/skill-presence.js +9 -1
  24. package/dist/services/hooks/presence-marker-detector.d.ts +6 -0
  25. package/dist/services/hooks/presence-marker-detector.js +48 -0
  26. package/dist/services/ide/adapters/claude-code-adapter.js +20 -0
  27. package/dist/services/ide/adapters/codex-adapter.js +20 -1
  28. package/dist/services/ide/adapters/cursor-adapter.js +21 -1
  29. package/dist/services/ide/adapters/hermes-adapter.js +20 -1
  30. package/dist/services/ide/adapters/openclaw-adapter.js +20 -1
  31. package/dist/services/ide/adapters/qoder-adapter.js +20 -1
  32. package/dist/services/ide/adapters/tongyi-lingma-adapter.js +20 -1
  33. package/dist/services/ide/adapters/trae-adapter.js +21 -1
  34. package/dist/services/ide/adapters/zcode-adapter.js +19 -0
  35. package/dist/services/ide/ide-types.d.ts +15 -0
  36. package/dist/services/observability/observability-service.d.ts +2 -2
  37. package/dist/services/session/caller-binding-service.d.ts +21 -0
  38. package/dist/services/session/caller-binding-service.js +35 -0
  39. package/dist/services/session/caller-id-types.d.ts +62 -13
  40. package/dist/services/session/caller-id-types.js +25 -13
  41. package/dist/services/session/index.d.ts +3 -1
  42. package/dist/services/session/index.js +7 -1
  43. package/dist/services/session/platform-fallbacks.d.ts +25 -17
  44. package/dist/services/session/platform-fallbacks.js +26 -28
  45. package/dist/services/session/resolve-caller-id.d.ts +64 -29
  46. package/dist/services/session/resolve-caller-id.js +122 -48
  47. package/dist/services/skills/presence-lease-service.d.ts +73 -0
  48. package/dist/services/skills/presence-lease-service.js +358 -0
  49. package/dist/services/skills/presence-lease-types.d.ts +75 -0
  50. package/dist/services/skills/presence-lease-types.js +16 -0
  51. package/dist/services/skills/skill-presence-service.js +86 -22
  52. package/dist/services/workflow/workflow-graph-store.d.ts +77 -0
  53. package/dist/services/workflow/workflow-graph-store.js +278 -0
  54. package/dist/services/workflow/workflow-graph-types.d.ts +56 -0
  55. package/dist/services/workflow/workflow-graph-types.js +67 -0
  56. package/dist/services/workflow/workflow-inflight-probe.d.ts +52 -0
  57. package/dist/services/workflow/workflow-inflight-probe.js +86 -0
  58. package/dist/services/workflow/workflow-node-lifecycle.d.ts +67 -0
  59. package/dist/services/workflow/workflow-node-lifecycle.js +348 -0
  60. package/dist/services/workflow/workflow-presence-lifecycle.d.ts +58 -0
  61. package/dist/services/workflow/workflow-presence-lifecycle.js +196 -0
  62. package/dist/services/workspace/reconcile-service.d.ts +31 -0
  63. package/dist/services/workspace/reconcile-service.js +132 -1
  64. package/package.json +4 -4
  65. package/skills/peaks-code/references/completion-handoff.md +1 -1
  66. package/skills/peaks-code/references/skill-presence-and-title.md +1 -1
@@ -108,19 +108,29 @@ export function evaluateCompactTrigger(ratio, mode = 'standard') {
108
108
  */
109
109
  export function evaluateAutoCompactDecision(input) {
110
110
  const trigger = evaluateCompactTrigger(input.ratio, input.mode ?? 'standard');
111
- if (trigger.kind === 'none' || trigger.kind === 'soft-warn') {
112
- return { shouldCompact: false, reason: 'below-threshold', trigger };
111
+ // Normalize both `inFlightBatch` and `inflightBatch` (boolean | probe)
112
+ // into a single `InFlightBatchProbe` shape. The probe reads
113
+ // `hasInFlightBatch`; the boolean reads truthiness.
114
+ const rawProbe = input.inFlightBatch ?? input.inflightBatch;
115
+ const probe = typeof rawProbe === 'boolean'
116
+ ? { hasInFlightBatch: rawProbe }
117
+ : rawProbe;
118
+ if (trigger.kind === 'none') {
119
+ return { shouldCompact: false, reason: 'below-threshold', trigger, action: 'ok' };
120
+ }
121
+ if (trigger.kind === 'soft-warn') {
122
+ return { shouldCompact: false, reason: 'below-threshold', trigger, action: 'soft-warn' };
113
123
  }
114
124
  if (trigger.kind === 'red-line') {
115
125
  // Red line: ignore in-flight batch — synchronous dispatch wins.
116
- return { shouldCompact: true, reason: 'red-line', trigger };
126
+ return { shouldCompact: true, reason: 'red-line', trigger, action: 'red-line' };
117
127
  }
118
128
  // pre-compact zone (0.85 ≤ ratio < 0.95): honor D6.e in-flight deferral.
119
- if (input.inFlightBatch?.hasInFlightBatch === true) {
120
- return { shouldCompact: false, reason: 'in-flight-batch', trigger };
129
+ if (probe?.hasInFlightBatch === true) {
130
+ return { shouldCompact: false, reason: 'in-flight-batch', trigger, action: 'defer' };
121
131
  }
122
132
  if (input.force) {
123
- return { shouldCompact: true, reason: 'pre-compact', trigger };
133
+ return { shouldCompact: true, reason: 'pre-compact', trigger, action: 'auto-compact-now' };
124
134
  }
125
135
  // Slice 2026-07-31-rid-mac-transcript-estimate-trigger: transcript-estimate
126
136
  // is the ONLY signal available on Mac Claude Code (no env-var, no statusline
@@ -131,9 +141,9 @@ export function evaluateAutoCompactDecision(input) {
131
141
  // source is present (`claude-code-env` would have been P1, `statusline-poll`
132
142
  // P2, `user-overridden` P4) — Mac's only signal is `transcript-estimate`.
133
143
  if (input.source === 'transcript-estimate' && input.ratio >= AUTO_COMPACT_PRE_COMPACT_RATIO)
134
- return { shouldCompact: true, reason: 'pre-compact', trigger };
144
+ return { shouldCompact: true, reason: 'pre-compact', trigger, action: 'auto-compact-now' };
135
145
  // Default: peaks-loop drives pre-compact autonomously.
136
- return { shouldCompact: true, reason: 'pre-compact', trigger };
146
+ return { shouldCompact: true, reason: 'pre-compact', trigger, action: 'auto-compact-now' };
137
147
  }
138
148
  /**
139
149
  * Build the convergence plan that D7's post-compact-detect will read
@@ -514,7 +524,17 @@ export async function runAutoCompact(input) {
514
524
  });
515
525
  const decision = evaluateAutoCompactDecision({
516
526
  ratio: probe.ratio,
517
- inFlightBatch: input.inFlightBatch,
527
+ // Slice 4.0.8 (D4d): production `inFlightBatch` MUST come from
528
+ // the graph probe (see `workflow-inflight-probe.ts`). The
529
+ // legacy boolean is preserved as a TEST-ONLY seam: when
530
+ // `probeInflightBatch` is supplied, we call it and pass the
531
+ // result as a `InFlightBatchProbe` so the pure decision function
532
+ // sees a graph-backed value. When only the boolean is
533
+ // supplied, we treat it as the test seam (the CLI gates it
534
+ // behind `PEAKS_TEST_SEAM === '1'`).
535
+ inFlightBatch: input.probeInflightBatch !== undefined
536
+ ? { hasInFlightBatch: input.probeInflightBatch() }
537
+ : input.inFlightBatch,
518
538
  force: input.force,
519
539
  bypassRedLine: input.bypassRedLine,
520
540
  mode,
@@ -43,7 +43,7 @@ export interface DispatchRecord {
43
43
  * truth for "this record is v3.1-form"; readers check
44
44
  * `version === '3.1'` for forward-compatible dispatching.
45
45
  */
46
- readonly version: '3.2';
46
+ readonly version: '4.0.0';
47
47
  readonly createdAt: string;
48
48
  readonly completedAt: string | null;
49
49
  readonly outcome: DispatchOutcome;
@@ -120,6 +120,22 @@ export interface DispatchRecord {
120
120
  * merge transcript.
121
121
  */
122
122
  readonly mergeBackAttempts: number;
123
+ /**
124
+ * Slice 4.0.8 RD §4 D4c (presence-lease-graph): the workflow id +
125
+ * graph node id + graphRef this dispatch is bound to. Persisted
126
+ * directly in the dispatch record (NOT in a sidecar) so the
127
+ * envelope-writer `markCompleted` can auto-transition the bound
128
+ * graph node to `envelope-received` with `ackStatus=pending` in
129
+ * one protected update.
130
+ *
131
+ * The schema bump from `3.2 → 4.0.0` is BREAKING in the sense
132
+ * that the literal type is narrowed. The optional `?` keeps back-
133
+ * compat for old records that pre-date the binding (the
134
+ * `upgradeRecord` reader defaults them to `null`).
135
+ */
136
+ readonly workflowId: string | null;
137
+ readonly graphNodeId: string | null;
138
+ readonly graphRef: string | null;
123
139
  }
124
140
  /** Input for the initial write. */
125
141
  export type WriteInitialDispatchInput = {
@@ -148,6 +164,14 @@ export type WriteInitialDispatchInput = {
148
164
  * time when `--isolation` is requested.
149
165
  */
150
166
  isolationStartedAt?: string | null;
167
+ /**
168
+ * Slice 4.0.8: workflow graph binding for the dispatch. Defaults
169
+ * to `null` so a non-graph dispatch (legacy CLI flow, ad-hoc
170
+ * dispatch) still writes a v4.0.0 record.
171
+ */
172
+ workflowId?: string | null;
173
+ graphNodeId?: string | null;
174
+ graphRef?: string | null;
151
175
  };
152
176
  /** Heartbeat write input. */
153
177
  export type AppendHeartbeatInput = {
@@ -44,7 +44,7 @@ export function writeInitialDispatchRecord(input) {
44
44
  const path = dispatchRecordPath(projectRoot, sessionId, requestId, now());
45
45
  const safePath = assertSafeDispatchRecordPath(path, projectRoot);
46
46
  const record = {
47
- version: '3.2',
47
+ version: '4.0.0',
48
48
  createdAt: now().toISOString(),
49
49
  completedAt: null,
50
50
  outcome: 'no-execution',
@@ -91,7 +91,14 @@ export function writeInitialDispatchRecord(input) {
91
91
  // New records start with empty serviceKill and zero attempts;
92
92
  // the merge-back-runner (Task 9) populates them in place.
93
93
  serviceKill: [],
94
- mergeBackAttempts: 0
94
+ mergeBackAttempts: 0,
95
+ // Slice 4.0.8: workflow graph binding (RD §4 D4c). Defaults to
96
+ // `null` for legacy / ad-hoc dispatches that do not bind a graph
97
+ // node; v4.0.0 schema is structural (required field), so a `null`
98
+ // is the explicit "no binding" state.
99
+ workflowId: typeof input.workflowId === 'string' && /^[a-zA-Z0-9._-]{1,200}$/.test(input.workflowId) ? input.workflowId : null,
100
+ graphNodeId: typeof input.graphNodeId === 'string' && /^[a-zA-Z0-9._-]{1,200}$/.test(input.graphNodeId) ? input.graphNodeId : null,
101
+ graphRef: typeof input.graphRef === 'string' && input.graphRef.length > 0 ? input.graphRef : null,
95
102
  };
96
103
  writeAtomic(safePath, record);
97
104
  // Slice 2026-06-23-audit-4th #A4: register the path in the
@@ -425,6 +432,68 @@ export function markCompleted(input) {
425
432
  writeAtomic(input.recordPath, next);
426
433
  return { record: next };
427
434
  });
435
+ // Slice 4.0.8 RD §4 D4c: after validating the artifact/envelope
436
+ // association with `dispatchRef`, automatically transition the
437
+ // bound graph node to `envelope-received` with
438
+ // `ackStatus=pending`. This is the auto-transition the parent
439
+ // ack protocol relies on — the canonical dispatch chain is
440
+ // `prepare -> dispatched -> running -> envelope-received -> consumed-by-parent`.
441
+ // The transition runs through `workflow-node-lifecycle.writeEnvelope`
442
+ // so the same typed error contract (PEAKS_ENVELOPE_GRAPH_MISMATCH,
443
+ // PEAKS_GRAPH_REF_BROKEN, etc.) is reused. Failures are swallowed
444
+ // (best-effort; the dispatch record itself is the source of truth
445
+ // and the transition is observable through the graph store).
446
+ if (result.record.workflowId !== null && result.record.graphNodeId !== null && result.record.graphRef !== null) {
447
+ try {
448
+ // Lazy ESM dynamic import: the graph lifecycle service is not
449
+ // on the dispatch hot path; it would be wasteful to import it
450
+ // for non-graph-bound dispatches. ESM dynamic import returns
451
+ // a promise; we do not await (best-effort) — the dispatch
452
+ // record write is the load-bearing artifact, and the graph
453
+ // node transition is observable to the next CLI call.
454
+ void (async () => {
455
+ try {
456
+ const lifecycleMod = await import('../workflow/workflow-node-lifecycle.js');
457
+ const storeMod = await import('../workflow/workflow-graph-store.js');
458
+ const sessionRoot = (() => {
459
+ try {
460
+ return storeMod.graphPathFor({
461
+ projectRoot: input.projectRoot ?? '',
462
+ sessionId: result.record.sessionId,
463
+ graphRef: result.record.graphRef ?? '',
464
+ workflowId: result.record.workflowId ?? '',
465
+ });
466
+ }
467
+ catch {
468
+ return null;
469
+ }
470
+ })();
471
+ if (sessionRoot === null)
472
+ return;
473
+ const graph = storeMod.readGraph({
474
+ projectRoot: input.projectRoot ?? '',
475
+ sessionId: result.record.sessionId,
476
+ graphRef: result.record.graphRef ?? '',
477
+ workflowId: result.record.workflowId ?? '',
478
+ });
479
+ const node = graph.nodes.find((n) => n.id === result.record.graphNodeId);
480
+ if (node === undefined)
481
+ return;
482
+ // Use the dispatch record's path as the dispatchRef. The
483
+ // record itself is the load-bearing artifact; the
484
+ // graph-node transition is a derived side-effect.
485
+ const dispatchRef = input.recordPath;
486
+ lifecycleMod.writeEnvelope({
487
+ graphNode: node,
488
+ dispatchRef,
489
+ envelopeDispatchRef: dispatchRef,
490
+ });
491
+ }
492
+ catch { /* best-effort graph transition */ }
493
+ })();
494
+ }
495
+ catch { /* best-effort */ }
496
+ }
428
497
  // Slice 2026-06-23-audit-4th #A4: update the active-dispatches
429
498
  // index. Best-effort (the on-disk record is the source of truth);
430
499
  // we only attempt the update when the trusted projectRoot is
@@ -549,18 +618,13 @@ function upgradeRecord(parsed) {
549
618
  throw new Error('Dispatch record root must be an object');
550
619
  }
551
620
  const obj = parsed;
552
- // Slice 2026-07-29-rid-prose-only-sweep Part 34: the version
553
- // field is now the literal '3.1'. Legacy v2 and v3 records
554
- // (version: 2 or version: 3) are accepted transparently and
555
- // upgraded to v3.1 on read. The forward-compat check rejects
556
- // records with an unknown version string (which would
557
- // indicate either a much-older build or a much-newer one
558
- // with a schema we have not implemented). v1 records
559
- // (pre-audit-trail) must be regenerated.
621
+ // Slice 4.0.8: 3.2 → 4.0.0 schema bump. The literal type narrows
622
+ // to '4.0.0' but legacy v3.2 / v3.1 / 3 / 2 / 1 records are
623
+ // accepted transparently and upgraded on read.
560
624
  const rawVersion = obj.version;
561
- if (rawVersion !== '3.2' && rawVersion !== '3.1' && rawVersion !== 3 && rawVersion !== 2 && rawVersion !== 1) {
562
- throw new Error(`Dispatch record version mismatch: expected '3.2', '3.1', 3, 2, or 1, got ${JSON.stringify(rawVersion)}. ` +
563
- 'The v1 → v3.2 migration is in-file; records from much older or newer builds must be regenerated.');
625
+ if (rawVersion !== '4.0.0' && rawVersion !== '3.2' && rawVersion !== '3.1' && rawVersion !== 3 && rawVersion !== 2 && rawVersion !== 1) {
626
+ throw new Error(`Dispatch record version mismatch: expected '4.0.0', '3.2', '3.1', 3, 2, or 1, got ${JSON.stringify(rawVersion)}. ` +
627
+ 'The v1 → v4.0.0 migration is in-file; records from much older or newer builds must be regenerated.');
564
628
  }
565
629
  const role = stringField(obj, 'role');
566
630
  const requestId = stringField(obj, 'requestId');
@@ -602,7 +666,7 @@ function upgradeRecord(parsed) {
602
666
  ? obj.batchId
603
667
  : 'legacy-batch';
604
668
  return {
605
- version: '3.2',
669
+ version: '4.0.0',
606
670
  createdAt,
607
671
  completedAt,
608
672
  outcome,
@@ -652,7 +716,13 @@ function upgradeRecord(parsed) {
652
716
  : [],
653
717
  mergeBackAttempts: typeof obj.mergeBackAttempts === 'number' && Number.isFinite(obj.mergeBackAttempts) && obj.mergeBackAttempts >= 0
654
718
  ? Math.floor(obj.mergeBackAttempts)
655
- : 0
719
+ : 0,
720
+ // Slice 4.0.8: 3.2 → 4.0.0 migration. v3.2 records on disk
721
+ // pre-date the workflow-graph binding; default all three
722
+ // fields to `null` so a legacy record upgrades transparently.
723
+ workflowId: typeof obj.workflowId === 'string' && /^[a-zA-Z0-9._-]{1,200}$/.test(obj.workflowId) ? obj.workflowId : null,
724
+ graphNodeId: typeof obj.graphNodeId === 'string' && /^[a-zA-Z0-9._-]{1,200}$/.test(obj.graphNodeId) ? obj.graphNodeId : null,
725
+ graphRef: typeof obj.graphRef === 'string' ? obj.graphRef : null
656
726
  };
657
727
  }
658
728
  function isObject(v) {
@@ -2,6 +2,14 @@
2
2
  * Check: skill presence current + freshness
3
3
  * (`skill-presence:current` and `skill-presence:freshness`).
4
4
  *
5
+ * Slice 4.0.8 (RD §3 + §4 D3): the diagnostic also surfaces
6
+ * stale / lost / broken-graph leases by running a `dry-run` GC
7
+ * summary through `gcStalePresenceLeases`. The summary carries
8
+ * `removed` / `retained` counts plus per-lease warnings (typed
9
+ * `PEAKS_GRAPH_REF_BROKEN`, `PEAKS_GRAPH_CORRUPTED`); the doctor
10
+ * surfaces them so the operator can run `peaks workspace reconcile`
11
+ * to repair.
12
+ *
5
13
  * Two checks sharing one read of the `presence` context field.
6
14
  * - `skill-presence:current` is informational: passes when the
7
15
  * presence is wired, with a message describing the active
@@ -2,6 +2,14 @@
2
2
  * Check: skill presence current + freshness
3
3
  * (`skill-presence:current` and `skill-presence:freshness`).
4
4
  *
5
+ * Slice 4.0.8 (RD §3 + §4 D3): the diagnostic also surfaces
6
+ * stale / lost / broken-graph leases by running a `dry-run` GC
7
+ * summary through `gcStalePresenceLeases`. The summary carries
8
+ * `removed` / `retained` counts plus per-lease warnings (typed
9
+ * `PEAKS_GRAPH_REF_BROKEN`, `PEAKS_GRAPH_CORRUPTED`); the doctor
10
+ * surfaces them so the operator can run `peaks workspace reconcile`
11
+ * to repair.
12
+ *
5
13
  * Two checks sharing one read of the `presence` context field.
6
14
  * - `skill-presence:current` is informational: passes when the
7
15
  * presence is wired, with a message describing the active
@@ -56,7 +64,7 @@ function run({ options, presence }) {
56
64
  checks.push({
57
65
  id: 'skill-presence:freshness',
58
66
  ok: false,
59
- message: `Skill presence ${presence.skill} is stale (set ${presence.setAt}, ~${ageHours}h ago); run peaks skill presence:clear if the role has ended`
67
+ message: `Skill presence ${presence.skill} is stale (set ${presence.setAt}, ~${ageHours}h ago); run peaks skill presence:clear if the role has ended, or \`peaks skill lease gc\` to drain the canonical lease`
60
68
  });
61
69
  }
62
70
  else {
@@ -12,5 +12,11 @@ export type PresenceMarkerWarning = (typeof PRESENCE_MARKER_WARNING)[number];
12
12
  export declare const PRESENCE_MARKER_WARNING: readonly ["Peaks skill context may have been lost from this conversation; please re-invoke /peaks-<skill>."];
13
13
  /**
14
14
  * Pure read-only presence-marker detection. No I/O side effects.
15
+ *
16
+ * Slice 4.0.8: the canonical 4.0.7 behavior is preserved (read legacy
17
+ * `active-skill.json` or `.peaks/.active-skill.json`, project the
18
+ * `Peaks-Loop Skill:` marker). The new diagnostic for a broken
19
+ * canonical graph is surfaced via the `warning` field, NOT swallowed,
20
+ * so the statusline / hook / doctor consumers can render it.
15
21
  */
16
22
  export declare function detectPresenceMarker(input: DetectPresenceMarkerInput): DetectPresenceMarkerResult;
@@ -73,6 +73,41 @@ function readPresenceFile(absolutePath) {
73
73
  return null;
74
74
  return { skill: skillMatch[1] };
75
75
  }
76
+ /**
77
+ * Slice 4.0.8: surface a typed `PEAKS_GRAPH_REF_BROKEN` warning
78
+ * when the canonical presence lease / index points at a missing or
79
+ * corrupt graph. The legacy `active-skill.json` walk never
80
+ * inspected graphRef; in 4.0.8 we surface the broken graph in the
81
+ * `warnings` array so downstream consumers (statusline, doctor,
82
+ * hooks) can render the diagnostic instead of silently rendering
83
+ * "active" for a half-wired presence.
84
+ */
85
+ function tryDetectBrokenGraph(projectRoot) {
86
+ try {
87
+ const presenceIndexDir = resolve(projectRoot, '.peaks', '_runtime');
88
+ if (!existsSync(presenceIndexDir))
89
+ return null;
90
+ // Best-effort: walk a single directory level for the legacy
91
+ // `active-skill.json` indicator. A broken graphRef would be
92
+ // surfaced by the canonical lease service (PEAKS_GRAPH_REF_BROKEN
93
+ // is the typed error), but the marker detector is a *read* —
94
+ // we don't import the lease service here. The diagnostic is
95
+ // best-effort: we report the index file's `graphRef` field
96
+ // when it names a missing file.
97
+ const legacyPath = resolve(projectRoot, '.peaks', '_runtime', 'active-skill.json');
98
+ if (!existsSync(legacyPath))
99
+ return null;
100
+ const raw = readFileSync(legacyPath, 'utf8');
101
+ const parsed = JSON.parse(raw);
102
+ if (typeof parsed.graphRef !== 'string')
103
+ return null;
104
+ const graphPath = resolve(projectRoot, '.peaks', '_runtime', parsed.graphRef);
105
+ if (!existsSync(graphPath))
106
+ return 'PEAKS_GRAPH_REF_BROKEN';
107
+ }
108
+ catch { /* swallow — diagnostic only */ }
109
+ return null;
110
+ }
76
111
  function readPresenceBackCompat(project) {
77
112
  const projectRoot = resolve(project);
78
113
  const canonicalPath = resolve(projectRoot, PRESENCE_CANONICAL_PATH);
@@ -92,6 +127,12 @@ function messageHasMarker(message) {
92
127
  }
93
128
  /**
94
129
  * Pure read-only presence-marker detection. No I/O side effects.
130
+ *
131
+ * Slice 4.0.8: the canonical 4.0.7 behavior is preserved (read legacy
132
+ * `active-skill.json` or `.peaks/.active-skill.json`, project the
133
+ * `Peaks-Loop Skill:` marker). The new diagnostic for a broken
134
+ * canonical graph is surfaced via the `warning` field, NOT swallowed,
135
+ * so the statusline / hook / doctor consumers can render it.
95
136
  */
96
137
  export function detectPresenceMarker(input) {
97
138
  const project = input.project;
@@ -100,10 +141,17 @@ export function detectPresenceMarker(input) {
100
141
  if (presence === null) {
101
142
  return { active: false, markerFound: false };
102
143
  }
144
+ const brokenGraph = tryDetectBrokenGraph(project);
103
145
  const markerFound = messageHasMarker(message);
104
146
  if (markerFound) {
147
+ if (brokenGraph !== null) {
148
+ return { active: true, skill: presence.skill, markerFound: true, warning: `Peaks presence active but graph is broken (${brokenGraph}); run \`peaks workspace reconcile\` to repair.` };
149
+ }
105
150
  return { active: true, skill: presence.skill, markerFound: true };
106
151
  }
152
+ if (brokenGraph !== null) {
153
+ return { active: true, skill: presence.skill, markerFound: false, warning: `Peaks presence active but graph is broken (${brokenGraph}); run \`peaks workspace reconcile\` to repair.` };
154
+ }
107
155
  return {
108
156
  active: true,
109
157
  skill: presence.skill,
@@ -100,4 +100,24 @@ export const CLAUDE_CODE_ADAPTER = {
100
100
  installStrategy: 'symlink',
101
101
  envVarOverride: 'PEAKS_CLAUDE_SKILLS_DIR',
102
102
  },
103
+ // Slice 4.0.8 RD §5: Claude Code resolves PEAKS_CALLER_ID (override) →
104
+ // CLAUDE_CODE_SESSION_ID. Empty/invalid → typed PEAKS_CALLER_NOT_RESOLVED.
105
+ resolveCallerId: (env) => {
106
+ const e = env ?? process.env;
107
+ const override = e.PEAKS_CALLER_ID;
108
+ if (typeof override === 'string' && override.trim().length > 0) {
109
+ const trimmed = override.trim();
110
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
111
+ return trimmed;
112
+ }
113
+ const v = e.CLAUDE_CODE_SESSION_ID;
114
+ if (typeof v === 'string' && v.trim().length > 0) {
115
+ const trimmed = v.trim();
116
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
117
+ return trimmed;
118
+ }
119
+ const err = new Error('PEAKS_CALLER_NOT_RESOLVED: no Claude Code session id available');
120
+ err.code = 'PEAKS_CALLER_NOT_RESOLVED';
121
+ throw err;
122
+ },
103
123
  };
@@ -86,7 +86,26 @@ export const CODEX_ADAPTER = {
86
86
  // return a clear "not supported" stderr message (slice #008 P-5
87
87
  // capability-check contract preserved).
88
88
  statusline: false,
89
- }
89
+ },
90
+ // Slice 4.0.8 RD §5: Codex vendor signal reserved; fail closed.
91
+ resolveCallerId: (env) => {
92
+ const e = env ?? process.env;
93
+ const override = e.PEAKS_CALLER_ID;
94
+ if (typeof override === 'string' && override.trim().length > 0) {
95
+ const trimmed = override.trim();
96
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
97
+ return trimmed;
98
+ }
99
+ const candidate = e.CODEX_SESSION_ID;
100
+ if (typeof candidate === 'string' && candidate.trim().length > 0) {
101
+ const trimmed = candidate.trim();
102
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
103
+ return trimmed;
104
+ }
105
+ const err = new Error('PEAKS_CALLER_NOT_RESOLVED: Codex vendor signal reserved');
106
+ err.code = 'PEAKS_CALLER_NOT_RESOLVED';
107
+ throw err;
108
+ },
90
109
  // Standards: UNVERIFIED — see slice #013+ (Codex real-install dogfood for
91
110
  // the `standardsProfile` and `skillInstall` fields). Until then, `peaks
92
111
  // standards init` on a Codex-detected project falls back to the Claude
@@ -73,7 +73,7 @@ export const CURSOR_ADAPTER = {
73
73
  // Cursor has a statusline UI (Cmd+Shift+P → "Cursor: Open Status Bar")
74
74
  // that can host peaks statusline output, so opt in to the capability.
75
75
  statusline: true,
76
- }
76
+ },
77
77
  // Standards: UNVERIFIED — see slice #012+ (Cursor real-install dogfood for
78
78
  // the `standardsProfile` and `skillInstall` fields). Until then, `peaks
79
79
  // standards init` on a Cursor-detected project falls back to the Claude
@@ -81,4 +81,24 @@ export const CURSOR_ADAPTER = {
81
81
  // postinstall script writes skills + output-styles to the legacy
82
82
  // `~/.claude/{skills,output-styles}` paths with a stderr warning. Users
83
83
  // who want Cursor-specific paths must move the files manually.
84
+ // Slice 4.0.8 RD §5: Cursor vendor signal is unverified; reserve-only resolver.
85
+ resolveCallerId: (env) => {
86
+ const e = env ?? process.env;
87
+ const override = e.PEAKS_CALLER_ID;
88
+ if (typeof override === 'string' && override.trim().length > 0) {
89
+ const trimmed = override.trim();
90
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
91
+ return trimmed;
92
+ }
93
+ // Reserved vendor variable (UNVERIFIED): do not synthesize project identity.
94
+ const candidate = e.CURSOR_SESSION_ID;
95
+ if (typeof candidate === 'string' && candidate.trim().length > 0) {
96
+ const trimmed = candidate.trim();
97
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
98
+ return trimmed;
99
+ }
100
+ const err = new Error('PEAKS_CALLER_NOT_RESOLVED: Cursor vendor signal unverified in 4.0.8');
101
+ err.code = 'PEAKS_CALLER_NOT_RESOLVED';
102
+ throw err;
103
+ },
84
104
  };
@@ -47,5 +47,24 @@ export const HERMES_ADAPTER = {
47
47
  capabilities: {
48
48
  gateEnforce: true,
49
49
  statusline: true
50
- }
50
+ },
51
+ // Slice 4.0.8 RD §5: Hermes vendor signal unverified — fail closed.
52
+ resolveCallerId: (env) => {
53
+ const e = env ?? process.env;
54
+ const override = e.PEAKS_CALLER_ID;
55
+ if (typeof override === 'string' && override.trim().length > 0) {
56
+ const trimmed = override.trim();
57
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
58
+ return trimmed;
59
+ }
60
+ const candidate = e.HERMES_SESSION_ID;
61
+ if (typeof candidate === 'string' && candidate.trim().length > 0) {
62
+ const trimmed = candidate.trim();
63
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
64
+ return trimmed;
65
+ }
66
+ const err = new Error('PEAKS_CALLER_NOT_RESOLVED: Hermes vendor signal unverified');
67
+ err.code = 'PEAKS_CALLER_NOT_RESOLVED';
68
+ throw err;
69
+ },
51
70
  };
@@ -38,5 +38,24 @@ export const OPENCLAW_ADAPTER = {
38
38
  capabilities: {
39
39
  gateEnforce: true,
40
40
  statusline: true
41
- }
41
+ },
42
+ // Slice 4.0.8 RD §5: OpenClaw vendor signal unverified — fail closed.
43
+ resolveCallerId: (env) => {
44
+ const e = env ?? process.env;
45
+ const override = e.PEAKS_CALLER_ID;
46
+ if (typeof override === 'string' && override.trim().length > 0) {
47
+ const trimmed = override.trim();
48
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
49
+ return trimmed;
50
+ }
51
+ const candidate = e.OPENCLAW_SESSION_ID;
52
+ if (typeof candidate === 'string' && candidate.trim().length > 0) {
53
+ const trimmed = candidate.trim();
54
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
55
+ return trimmed;
56
+ }
57
+ const err = new Error('PEAKS_CALLER_NOT_RESOLVED: OpenClaw vendor signal unverified');
58
+ err.code = 'PEAKS_CALLER_NOT_RESOLVED';
59
+ throw err;
60
+ },
42
61
  };
@@ -53,5 +53,24 @@ export const QODER_ADAPTER = {
53
53
  capabilities: {
54
54
  gateEnforce: true,
55
55
  statusline: true
56
- }
56
+ },
57
+ // Slice 4.0.8 RD §5: Qoder vendor signal unverified — fail closed.
58
+ resolveCallerId: (env) => {
59
+ const e = env ?? process.env;
60
+ const override = e.PEAKS_CALLER_ID;
61
+ if (typeof override === 'string' && override.trim().length > 0) {
62
+ const trimmed = override.trim();
63
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
64
+ return trimmed;
65
+ }
66
+ const candidate = e.QODER_SESSION_ID;
67
+ if (typeof candidate === 'string' && candidate.trim().length > 0) {
68
+ const trimmed = candidate.trim();
69
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
70
+ return trimmed;
71
+ }
72
+ const err = new Error('PEAKS_CALLER_NOT_RESOLVED: Qoder vendor signal unverified');
73
+ err.code = 'PEAKS_CALLER_NOT_RESOLVED';
74
+ throw err;
75
+ },
57
76
  };
@@ -41,5 +41,24 @@ export const TONGYI_LINGMA_ADAPTER = {
41
41
  capabilities: {
42
42
  gateEnforce: true,
43
43
  statusline: true
44
- }
44
+ },
45
+ // Slice 4.0.8 RD §5: Tongyi Lingma vendor signal unverified — fail closed.
46
+ resolveCallerId: (env) => {
47
+ const e = env ?? process.env;
48
+ const override = e.PEAKS_CALLER_ID;
49
+ if (typeof override === 'string' && override.trim().length > 0) {
50
+ const trimmed = override.trim();
51
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
52
+ return trimmed;
53
+ }
54
+ const candidate = e.TONGYI_LINGMA_SESSION_ID;
55
+ if (typeof candidate === 'string' && candidate.trim().length > 0) {
56
+ const trimmed = candidate.trim();
57
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
58
+ return trimmed;
59
+ }
60
+ const err = new Error('PEAKS_CALLER_NOT_RESOLVED: Tongyi Lingma vendor signal unverified');
61
+ err.code = 'PEAKS_CALLER_NOT_RESOLVED';
62
+ throw err;
63
+ },
45
64
  };
@@ -71,7 +71,7 @@ export const TRAE_ADAPTER = {
71
71
  capabilities: {
72
72
  gateEnforce: true,
73
73
  statusline: true,
74
- }
74
+ },
75
75
  // Standards: UNVERIFIED — see slice #012+ (Trae real-install dogfood for
76
76
  // the `standardsProfile` and `skillInstall` fields). The slice #011
77
77
  // framework lands; per-IDE values for Trae are a follow-up gated on
@@ -80,4 +80,24 @@ export const TRAE_ADAPTER = {
80
80
  // (CLAUDE.md + .claude/rules/**) with a stderr warning, and the
81
81
  // postinstall script writes skills + output-styles to the legacy
82
82
  // `~/.claude/{skills,output-styles}` paths with a stderr warning.
83
+ // Slice 4.0.8 RD §5: Trae resolves PEAKS_CALLER_ID (override) →
84
+ // TRAE_SESSION_ID. Empty/invalid → typed PEAKS_CALLER_NOT_RESOLVED.
85
+ resolveCallerId: (env) => {
86
+ const e = env ?? process.env;
87
+ const override = e.PEAKS_CALLER_ID;
88
+ if (typeof override === 'string' && override.trim().length > 0) {
89
+ const trimmed = override.trim();
90
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
91
+ return trimmed;
92
+ }
93
+ const v = e.TRAE_SESSION_ID;
94
+ if (typeof v === 'string' && v.trim().length > 0) {
95
+ const trimmed = v.trim();
96
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
97
+ return trimmed;
98
+ }
99
+ const err = new Error('PEAKS_CALLER_NOT_RESOLVED: no Trae session id available');
100
+ err.code = 'PEAKS_CALLER_NOT_RESOLVED';
101
+ throw err;
102
+ },
83
103
  };
@@ -217,4 +217,23 @@ export const ZCODE_ADAPTER = {
217
217
  // provider → first enabled → first provider). Returns undefined
218
218
  // on any failure (consistent with the cross-IDE contract).
219
219
  detectCurrentModel: detectZcodeCurrentModel,
220
+ // Slice 4.0.8 RD §5: z-code vendor signal unverified — fail closed.
221
+ resolveCallerId: (env) => {
222
+ const e = env ?? process.env;
223
+ const override = e.PEAKS_CALLER_ID;
224
+ if (typeof override === 'string' && override.trim().length > 0) {
225
+ const trimmed = override.trim();
226
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
227
+ return trimmed;
228
+ }
229
+ const candidate = e.ZCODE_SESSION_ID;
230
+ if (typeof candidate === 'string' && candidate.trim().length > 0) {
231
+ const trimmed = candidate.trim();
232
+ if (/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed))
233
+ return trimmed;
234
+ }
235
+ const err = new Error('PEAKS_CALLER_NOT_RESOLVED: z-code vendor signal unverified');
236
+ err.code = 'PEAKS_CALLER_NOT_RESOLVED';
237
+ throw err;
238
+ },
220
239
  };