openplanr 1.21.2 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/cli/commands/doctor.d.ts.map +1 -1
  2. package/dist/cli/commands/doctor.js +11 -1
  3. package/dist/cli/commands/doctor.js.map +1 -1
  4. package/dist/cli/commands/operate.d.ts.map +1 -1
  5. package/dist/cli/commands/operate.js +91 -2
  6. package/dist/cli/commands/operate.js.map +1 -1
  7. package/dist/services/operate/advisors.d.ts +63 -0
  8. package/dist/services/operate/advisors.d.ts.map +1 -1
  9. package/dist/services/operate/advisors.js +68 -4
  10. package/dist/services/operate/advisors.js.map +1 -1
  11. package/dist/services/operate/artifacts.d.ts +9 -3
  12. package/dist/services/operate/artifacts.d.ts.map +1 -1
  13. package/dist/services/operate/artifacts.js +126 -5
  14. package/dist/services/operate/artifacts.js.map +1 -1
  15. package/dist/services/operate/citation-resolution.d.ts +14 -0
  16. package/dist/services/operate/citation-resolution.d.ts.map +1 -1
  17. package/dist/services/operate/citation-resolution.js +9 -0
  18. package/dist/services/operate/citation-resolution.js.map +1 -1
  19. package/dist/services/operate/completion.d.ts +80 -0
  20. package/dist/services/operate/completion.d.ts.map +1 -0
  21. package/dist/services/operate/completion.js +279 -0
  22. package/dist/services/operate/completion.js.map +1 -0
  23. package/dist/services/operate/config.d.ts +7 -0
  24. package/dist/services/operate/config.d.ts.map +1 -1
  25. package/dist/services/operate/config.js +7 -0
  26. package/dist/services/operate/config.js.map +1 -1
  27. package/dist/services/operate/context-research.d.ts +43 -0
  28. package/dist/services/operate/context-research.d.ts.map +1 -1
  29. package/dist/services/operate/context-research.js +219 -1
  30. package/dist/services/operate/context-research.js.map +1 -1
  31. package/dist/services/operate/decision-brief.d.ts +90 -1
  32. package/dist/services/operate/decision-brief.d.ts.map +1 -1
  33. package/dist/services/operate/decision-brief.js +175 -1
  34. package/dist/services/operate/decision-brief.js.map +1 -1
  35. package/dist/services/operate/doctor.d.ts +58 -0
  36. package/dist/services/operate/doctor.d.ts.map +1 -1
  37. package/dist/services/operate/doctor.js +395 -21
  38. package/dist/services/operate/doctor.js.map +1 -1
  39. package/dist/services/operate/engine.d.ts +75 -2
  40. package/dist/services/operate/engine.d.ts.map +1 -1
  41. package/dist/services/operate/engine.js +504 -33
  42. package/dist/services/operate/engine.js.map +1 -1
  43. package/dist/services/operate/event-store.d.ts +22 -0
  44. package/dist/services/operate/event-store.d.ts.map +1 -1
  45. package/dist/services/operate/event-store.js +35 -0
  46. package/dist/services/operate/event-store.js.map +1 -1
  47. package/dist/services/operate/evidence-cache.d.ts.map +1 -1
  48. package/dist/services/operate/evidence-cache.js +41 -5
  49. package/dist/services/operate/evidence-cache.js.map +1 -1
  50. package/dist/services/operate/index.d.ts.map +1 -1
  51. package/dist/services/operate/index.js +98 -1
  52. package/dist/services/operate/index.js.map +1 -1
  53. package/dist/services/operate/interaction/question-engine.d.ts.map +1 -1
  54. package/dist/services/operate/interaction/question-engine.js +36 -8
  55. package/dist/services/operate/interaction/question-engine.js.map +1 -1
  56. package/dist/services/operate/interaction/question-registry.d.ts +8 -0
  57. package/dist/services/operate/interaction/question-registry.d.ts.map +1 -1
  58. package/dist/services/operate/interaction/question-registry.js +21 -2
  59. package/dist/services/operate/interaction/question-registry.js.map +1 -1
  60. package/dist/services/operate/lifecycle-driver.d.ts +309 -0
  61. package/dist/services/operate/lifecycle-driver.d.ts.map +1 -0
  62. package/dist/services/operate/lifecycle-driver.js +434 -0
  63. package/dist/services/operate/lifecycle-driver.js.map +1 -0
  64. package/dist/services/operate/maintenance.d.ts +65 -1
  65. package/dist/services/operate/maintenance.d.ts.map +1 -1
  66. package/dist/services/operate/maintenance.js +718 -24
  67. package/dist/services/operate/maintenance.js.map +1 -1
  68. package/dist/services/operate/mission-dispatch.d.ts +61 -1
  69. package/dist/services/operate/mission-dispatch.d.ts.map +1 -1
  70. package/dist/services/operate/mission-dispatch.js +78 -5
  71. package/dist/services/operate/mission-dispatch.js.map +1 -1
  72. package/dist/services/operate/profile-migration.d.ts +69 -0
  73. package/dist/services/operate/profile-migration.d.ts.map +1 -0
  74. package/dist/services/operate/profile-migration.js +462 -0
  75. package/dist/services/operate/profile-migration.js.map +1 -0
  76. package/dist/services/operate/projection-persistence.d.ts.map +1 -1
  77. package/dist/services/operate/projection-persistence.js +24 -5
  78. package/dist/services/operate/projection-persistence.js.map +1 -1
  79. package/dist/services/operate/projection.d.ts.map +1 -1
  80. package/dist/services/operate/projection.js +9 -0
  81. package/dist/services/operate/projection.js.map +1 -1
  82. package/dist/services/operate/protocol.d.ts +1 -0
  83. package/dist/services/operate/protocol.d.ts.map +1 -1
  84. package/dist/services/operate/protocol.js.map +1 -1
  85. package/dist/services/operate/scratch.d.ts +81 -0
  86. package/dist/services/operate/scratch.d.ts.map +1 -0
  87. package/dist/services/operate/scratch.js +190 -0
  88. package/dist/services/operate/scratch.js.map +1 -0
  89. package/dist/services/operate/types.d.ts +3 -2
  90. package/dist/services/operate/types.d.ts.map +1 -1
  91. package/dist/services/operate/types.js.map +1 -1
  92. package/dist/services/operate/workspace.d.ts +1 -0
  93. package/dist/services/operate/workspace.d.ts.map +1 -1
  94. package/dist/services/operate/workspace.js +2 -0
  95. package/dist/services/operate/workspace.js.map +1 -1
  96. package/dist/services/runtime-manager-service.d.ts +23 -0
  97. package/dist/services/runtime-manager-service.d.ts.map +1 -1
  98. package/dist/services/runtime-manager-service.js +30 -0
  99. package/dist/services/runtime-manager-service.js.map +1 -1
  100. package/package.json +2 -2
@@ -5,6 +5,34 @@ export interface OperatingDoctorDiagnostic {
5
5
  message: string;
6
6
  fix?: string;
7
7
  }
8
+ /**
9
+ * FR9 (T-008): the AGENT-CONTRACT decision, isolated as a pure function so a
10
+ * genuine role/provider/boundary divergence in the installed contract can be
11
+ * exercised directly. This owns the ONLY fail path for the installed contract;
12
+ * a board persisted at an older protocol envelope is explicitly not its concern
13
+ * (that is `diagnoseBoardStateVersion`). The fail message names which facet —
14
+ * roles, providers, or read-only boundaries — actually diverged.
15
+ */
16
+ export declare function evaluateAgentContract(input: {
17
+ roleIds: string[];
18
+ providerIds: string[];
19
+ boundariesValid: boolean;
20
+ pipelineVersion?: string;
21
+ }): OperatingDoctorDiagnostic;
22
+ /**
23
+ * FR9 (T-008): report the persisted board-state protocol version as a SEPARATE
24
+ * fact from the installed agent contract. A board written at an older protocol
25
+ * envelope than the current agent contract stays fully readable — the frozen
26
+ * additive-envelope guarantee — so an older board is reported informationally
27
+ * (`pass`), NEVER `warn`/`fail` "incompatible". The genuine-mismatch failure
28
+ * path belongs exclusively to `evaluateAgentContract` and the boundary/id
29
+ * checks. A board written at a NEWER contract than is installed is the only
30
+ * version drift that warns (this install may not implement its newer surface),
31
+ * and even then the message never frames the older/newer relationship itself as
32
+ * an incompatibility. Pure over `(boardStateVersion, agentContractVersion)` so
33
+ * the mapping is testable without a board on disk.
34
+ */
35
+ export declare function diagnoseBoardStateVersion(boardStateVersion: string | null, agentContractVersion?: string): OperatingDoctorDiagnostic;
8
36
  /**
9
37
  * FR7: cycle integrity is a first-class readable-tree surface. This is the
10
38
  * regression guard on that surface — a citation rejection or boundary refusal
@@ -18,6 +46,36 @@ export interface OperatingDoctorDiagnostic {
18
46
  * without a full board on disk.
19
47
  */
20
48
  export declare function diagnoseOperatingCycleIntegrity(state: OperatingState, cycleId: string | null, integrityFileContent: string | null): OperatingDoctorDiagnostic;
49
+ /**
50
+ * FR6 (T-005): the internal transport contract is exactly that — internal. The
51
+ * user sees validated Markdown, concise progress, and a final synthesis, never a
52
+ * lease token, an idempotency key, an evidence digest, or a harness/adapter
53
+ * lifecycle command; JSON is available only under an explicit `--json`. These
54
+ * patterns are the forbidden field/command tokens as they appear when raw
55
+ * transport leaks into a human surface. They deliberately do NOT match the plain
56
+ * English words ("release"/"please" have no word boundary before "lease"; a
57
+ * finding that merely mentions "the runtime adapter" carries no lifecycle verb),
58
+ * so the check flags a genuine leak rather than ordinary advisory prose.
59
+ */
60
+ export declare const OPERATING_TRANSPORT_LEAK_PATTERNS: ReadonlyArray<{
61
+ field: string;
62
+ pattern: RegExp;
63
+ }>;
64
+ /**
65
+ * Return the internal-transport field/command tokens that leaked into one block
66
+ * of human-facing text. Empty when the text is clean. Shared with the CLI
67
+ * regression test so both guards agree on exactly what counts as a leak.
68
+ */
69
+ export declare function scanOperatingTransportLeakage(text: string): string[];
70
+ /**
71
+ * FR6: fail when any of the forbidden transport tokens appears in the non-`--json`
72
+ * output of `status`/`report`/`review`. Pure over the assembled surfaces so a
73
+ * deliberate leak can be exercised directly (and a clean board proven clean).
74
+ */
75
+ export declare function evaluateOperatingTransportLeakage(surfaces: ReadonlyArray<{
76
+ surface: string;
77
+ text: string;
78
+ }>): OperatingDoctorDiagnostic;
21
79
  export declare function diagnoseOperatingBoard(input: {
22
80
  projectRoot: string;
23
81
  localRoot?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../../src/services/operate/doctor.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGjD,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAqlBD;;;;;;;;;;;GAWG;AACH,wBAAgB,+BAA+B,CAC7C,KAAK,EAAE,cAAc,EACrB,OAAO,EAAE,MAAM,GAAG,IAAI,EACtB,oBAAoB,EAAE,MAAM,GAAG,IAAI,GAClC,yBAAyB,CAmC3B;AA2DD,wBAAsB,sBAAsB,CAAC,KAAK,EAAE;IAClD,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,yBAAyB,EAAE,CAAC,CAwDvC"}
1
+ {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../../src/services/operate/doctor.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAIjD,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AA6BD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE;IAC3C,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,eAAe,EAAE,OAAO,CAAC;IACzB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,GAAG,yBAAyB,CAoB5B;AAgED;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CACvC,iBAAiB,EAAE,MAAM,GAAG,IAAI,EAChC,oBAAoB,GAAE,MAAuC,GAC5D,yBAAyB,CA6B3B;AA0jBD;;;;;;;;;;;GAWG;AACH,wBAAgB,+BAA+B,CAC7C,KAAK,EAAE,cAAc,EACrB,OAAO,EAAE,MAAM,GAAG,IAAI,EACtB,oBAAoB,EAAE,MAAM,GAAG,IAAI,GAClC,yBAAyB,CAmC3B;AAqJD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iCAAiC,EAAE,aAAa,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAc7F,CAAC;AAEJ;;;;GAIG;AACH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAMpE;AAED;;;;GAIG;AACH,wBAAgB,iCAAiC,CAC/C,QAAQ,EAAE,aAAa,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,GACzD,yBAAyB,CAuB3B;AA2FD,wBAAsB,sBAAsB,CAAC,KAAK,EAAE;IAClD,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,yBAAyB,EAAE,CAAC,CAyEvC"}
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { readdir, readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { canonicalDigest } from './canonical.js';
5
+ import { verifyOperatingCompletionPhases } from './completion.js';
5
6
  import { operatingProjectKey } from './config.js';
6
7
  import { OperatingEventStore } from './event-store.js';
7
8
  import { buildOperatingIntegritySummary, detectGitignoredWorkspace } from './integrity.js';
@@ -10,8 +11,12 @@ import { readJournal } from './journal.js';
10
11
  import { readOperatingLock } from './lock-service.js';
11
12
  import { detectOperatingStorageLayout } from './migration.js';
12
13
  import { classifyOperatingRuntime, resolveActiveOperatingRuntime } from './mission-dispatch.js';
14
+ import { compareLegacyProfileToOperatingConfig } from './profile-migration.js';
15
+ import { renderOperatingBrief } from './projection.js';
13
16
  import { inspectOperatingProjectionDrift } from './projection-persistence.js';
14
17
  import { loadOperatingProtocol, operatingPipelineAvailable } from './protocol.js';
18
+ import { listAbandonedOperatingScratch } from './scratch.js';
19
+ import { OPERATE_AGENT_PROTOCOL_VERSION } from './types.js';
15
20
  import { resolveOperatingPaths } from './workspace.js';
16
21
  const EXPECTED_ROLES = [
17
22
  'strategy-finance',
@@ -34,7 +39,44 @@ function sameIds(actual, expected) {
34
39
  return (actual.length === expected.length &&
35
40
  [...actual].sort().join('\0') === [...expected].sort().join('\0'));
36
41
  }
37
- async function diagnoseProtocol(pipelineVersion) {
42
+ /**
43
+ * FR9 (T-008): the AGENT-CONTRACT decision, isolated as a pure function so a
44
+ * genuine role/provider/boundary divergence in the installed contract can be
45
+ * exercised directly. This owns the ONLY fail path for the installed contract;
46
+ * a board persisted at an older protocol envelope is explicitly not its concern
47
+ * (that is `diagnoseBoardStateVersion`). The fail message names which facet —
48
+ * roles, providers, or read-only boundaries — actually diverged.
49
+ */
50
+ export function evaluateAgentContract(input) {
51
+ const diverged = [];
52
+ if (!sameIds(input.roleIds, EXPECTED_ROLES))
53
+ diverged.push('roles');
54
+ if (!sameIds(input.providerIds, EXPECTED_PROVIDERS))
55
+ diverged.push('providers');
56
+ if (!input.boundariesValid)
57
+ diverged.push('read-only boundaries');
58
+ if (diverged.length > 0) {
59
+ return {
60
+ code: 'operate-protocol',
61
+ status: 'fail',
62
+ message: `Operating Board agent contract (Protocol v${OPERATE_AGENT_PROTOCOL_VERSION}) does not match the certified role/provider contract: ${diverged.join(', ')} diverge`,
63
+ fix: 'Install the exact OpenPlanr release dependencies, then rerun `planr doctor`.',
64
+ };
65
+ }
66
+ return {
67
+ code: 'operate-protocol',
68
+ status: 'pass',
69
+ message: `Operating Board agent contract (Protocol v${OPERATE_AGENT_PROTOCOL_VERSION}) registries are compatible${input.pipelineVersion ? ` with planr-pipeline ${input.pipelineVersion}` : ''}`,
70
+ };
71
+ }
72
+ /**
73
+ * FR9 (T-008): report the currently installed agent contract (registry/role/
74
+ * provider check) as ONE fact. Split out of the former conflated
75
+ * `diagnoseProtocol`; the persisted board-state protocol version is reported
76
+ * separately by `diagnoseBoardStateVersion` so an older persisted board never
77
+ * reads as an incompatibility here.
78
+ */
79
+ async function diagnoseAgentContractVersion(pipelineVersion) {
38
80
  if (!operatingPipelineAvailable()) {
39
81
  return {
40
82
  code: 'operate-protocol',
@@ -47,35 +89,95 @@ async function diagnoseProtocol(pipelineVersion) {
47
89
  const protocol = await loadOperatingProtocol();
48
90
  const roles = protocol.listOperatingRoles();
49
91
  const providers = protocol.listOperatingProviders();
50
- const roleIds = roles.map((entry) => entry.id);
51
- const providerIds = providers.map((entry) => entry.id);
52
92
  const boundariesValid = roles.every((entry) => entry.readOnly === true &&
53
93
  ['none', 'governed-output-only'].includes(String(entry.writeBoundary))) && providers.every((entry) => entry.readOnly === true);
54
- if (!sameIds(roleIds, EXPECTED_ROLES) ||
55
- !sameIds(providerIds, EXPECTED_PROVIDERS) ||
56
- !boundariesValid) {
57
- return {
58
- code: 'operate-protocol',
59
- status: 'fail',
60
- message: 'Operating Board Protocol v1.4 registries do not match the certified role/provider contract',
61
- fix: 'Install the exact OpenPlanr release dependencies, then rerun `planr doctor`.',
62
- };
63
- }
64
- return {
65
- code: 'operate-protocol',
66
- status: 'pass',
67
- message: `Operating Board Protocol v1.4 registries are compatible${pipelineVersion ? ` with planr-pipeline ${pipelineVersion}` : ''}`,
68
- };
94
+ return evaluateAgentContract({
95
+ roleIds: roles.map((entry) => entry.id),
96
+ providerIds: providers.map((entry) => entry.id),
97
+ boundariesValid,
98
+ pipelineVersion,
99
+ });
69
100
  }
70
101
  catch (error) {
71
102
  return {
72
103
  code: 'operate-protocol',
73
104
  status: 'fail',
74
- message: `Operating Board Protocol v1.2 compatibility check failed: ${error instanceof Error ? error.message : String(error)}`,
105
+ message: `Operating Board agent contract (Protocol v${OPERATE_AGENT_PROTOCOL_VERSION}) compatibility check failed: ${error instanceof Error ? error.message : String(error)}`,
75
106
  fix: 'Install the exact OpenPlanr release dependencies, then rerun `planr doctor`.',
76
107
  };
77
108
  }
78
109
  }
110
+ function compareProtocolVersions(left, right) {
111
+ const parse = (value) => value.split('.').map((part) => {
112
+ const parsed = Number.parseInt(part, 10);
113
+ return Number.isFinite(parsed) ? parsed : 0;
114
+ });
115
+ const a = parse(left);
116
+ const b = parse(right);
117
+ for (let index = 0; index < 3; index += 1) {
118
+ const da = a[index] ?? 0;
119
+ const db = b[index] ?? 0;
120
+ if (da !== db)
121
+ return da < db ? -1 : 1;
122
+ }
123
+ return 0;
124
+ }
125
+ /**
126
+ * FR9 (T-008): report the persisted board-state protocol version as a SEPARATE
127
+ * fact from the installed agent contract. A board written at an older protocol
128
+ * envelope than the current agent contract stays fully readable — the frozen
129
+ * additive-envelope guarantee — so an older board is reported informationally
130
+ * (`pass`), NEVER `warn`/`fail` "incompatible". The genuine-mismatch failure
131
+ * path belongs exclusively to `evaluateAgentContract` and the boundary/id
132
+ * checks. A board written at a NEWER contract than is installed is the only
133
+ * version drift that warns (this install may not implement its newer surface),
134
+ * and even then the message never frames the older/newer relationship itself as
135
+ * an incompatibility. Pure over `(boardStateVersion, agentContractVersion)` so
136
+ * the mapping is testable without a board on disk.
137
+ */
138
+ export function diagnoseBoardStateVersion(boardStateVersion, agentContractVersion = OPERATE_AGENT_PROTOCOL_VERSION) {
139
+ if (!boardStateVersion) {
140
+ return {
141
+ code: 'operate-board-state-version',
142
+ status: 'pass',
143
+ message: 'No persisted Operating Board state is present yet to report a protocol version for',
144
+ };
145
+ }
146
+ const ordering = compareProtocolVersions(boardStateVersion, agentContractVersion);
147
+ if (ordering > 0) {
148
+ return {
149
+ code: 'operate-board-state-version',
150
+ status: 'warn',
151
+ message: `Persisted Operating Board state is at Protocol v${boardStateVersion}, newer than the installed agent contract (Protocol v${agentContractVersion}); this installation may not implement its newer surface`,
152
+ fix: 'Run `npm install -g openplanr@latest`, then `planr setup --scope user`, to match the agent contract to the persisted board state.',
153
+ };
154
+ }
155
+ if (ordering < 0) {
156
+ return {
157
+ code: 'operate-board-state-version',
158
+ status: 'pass',
159
+ message: `Persisted Operating Board state is at Protocol v${boardStateVersion}, readable under the current agent contract (Protocol v${agentContractVersion}); an older persisted board is expected and is not an incompatibility`,
160
+ };
161
+ }
162
+ return {
163
+ code: 'operate-board-state-version',
164
+ status: 'pass',
165
+ message: `Persisted Operating Board state is at Protocol v${boardStateVersion}, matching the installed agent contract (Protocol v${agentContractVersion})`,
166
+ };
167
+ }
168
+ async function diagnoseBoardStateVersionSurface(projectRoot, localRoot) {
169
+ let boardStateVersion = null;
170
+ try {
171
+ const state = await new OperatingEventStore(projectRoot, { localRoot }).state();
172
+ boardStateVersion = typeof state.protocolVersion === 'string' ? state.protocolVersion : null;
173
+ }
174
+ catch {
175
+ // A broken/absent event chain is reported by the event-replay check; the
176
+ // board-state protocol version is only meaningful against a readable board.
177
+ boardStateVersion = null;
178
+ }
179
+ return diagnoseBoardStateVersion(boardStateVersion, OPERATE_AGENT_PROTOCOL_VERSION);
180
+ }
79
181
  async function diagnoseEventState(projectRoot, localRoot) {
80
182
  const diagnostics = [];
81
183
  const store = new OperatingEventStore(projectRoot, { localRoot });
@@ -523,6 +625,37 @@ async function diagnoseIncrementalBaselines(projectRoot, localRoot) {
523
625
  message: 'Incremental evidence baselines match the committed workspace digest',
524
626
  };
525
627
  }
628
+ /**
629
+ * FR7 (T-006): detect OpenPlanr-owned scratch left behind by a session that never
630
+ * finalized. The reproduction lost completed analyses because the runtime chose
631
+ * its own temp-file transport with nothing owning it; scratch now lives under the
632
+ * project-and-machine-keyed operate root, is recorded in an owned manifest, and is
633
+ * cleaned automatically after a successful record/finalize. Any scratch still
634
+ * carrying an owned manifest past its lease window is abandoned. This reads only
635
+ * machine-local scratch, names abandoned sets by their cycle key (never a raw
636
+ * project or home path), and points at the FR7-named `planr doctor --fix`, which
637
+ * delegates to the single owned-only cleanup that removes ONLY confirmed
638
+ * OpenPlanr-owned stale scratch — never an arbitrary file found under the
639
+ * directory.
640
+ */
641
+ async function diagnoseAbandonedScratch(projectRoot, localRoot) {
642
+ const paths = resolveOperatingPaths(projectRoot, { localRoot });
643
+ const abandoned = await listAbandonedOperatingScratch(paths);
644
+ if (abandoned.length > 0) {
645
+ const cycles = abandoned.map((entry) => entry.cycleId).join(', ');
646
+ return {
647
+ code: 'operate-scratch',
648
+ status: 'warn',
649
+ message: `${abandoned.length} OpenPlanr-owned scratch set(s) were left behind by a session that never finalized: ${cycles}`,
650
+ fix: 'Run `planr doctor --fix` to remove only the confirmed OpenPlanr-owned stale scratch.',
651
+ };
652
+ }
653
+ return {
654
+ code: 'operate-scratch',
655
+ status: 'pass',
656
+ message: 'No abandoned OpenPlanr-owned operate scratch is present',
657
+ };
658
+ }
526
659
  /**
527
660
  * Classify the active runtime for agent-native Operate. Enforced isolation and
528
661
  * runtime-governed native workflows are both first-class; only a missing or
@@ -642,8 +775,232 @@ async function diagnoseWorkspaceVersioning(projectRoot) {
642
775
  : {}),
643
776
  };
644
777
  }
778
+ /**
779
+ * FR10 / T-009: report drift between a legacy `.planr/operate-profile.json` and
780
+ * the live operating configuration. A stale profile that still names fields the
781
+ * current config no longer carries (its `id`, role selection, caps, providers, or
782
+ * budgets) is exactly what made guided init suggest a profile the CLI then
783
+ * rejected; naming the differing fields points the operator at
784
+ * `planr operate profiles migrate inspect`. Reads only; a missing profile or a
785
+ * missing/unreadable config is a clean pass because there is nothing to reconcile.
786
+ */
787
+ async function diagnoseProfileDrift(projectRoot, localRoot) {
788
+ const profileRaw = await readFile(path.join(projectRoot, '.planr', 'operate-profile.json'), 'utf8').catch((error) => {
789
+ if (error.code === 'ENOENT')
790
+ return null;
791
+ throw error;
792
+ });
793
+ if (profileRaw === null) {
794
+ return {
795
+ code: 'operate-profile-drift',
796
+ status: 'pass',
797
+ message: 'No legacy .planr/operate-profile.json is present to reconcile',
798
+ };
799
+ }
800
+ let profile;
801
+ try {
802
+ profile = JSON.parse(profileRaw);
803
+ }
804
+ catch {
805
+ return {
806
+ code: 'operate-profile-drift',
807
+ status: 'warn',
808
+ message: 'Legacy .planr/operate-profile.json is not valid JSON and cannot be reconciled with the operating configuration',
809
+ fix: 'Run `planr operate profiles migrate inspect` to review the legacy profile before migrating it.',
810
+ };
811
+ }
812
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
813
+ return {
814
+ code: 'operate-profile-drift',
815
+ status: 'warn',
816
+ message: 'Legacy .planr/operate-profile.json is not a JSON object and cannot be reconciled',
817
+ fix: 'Run `planr operate profiles migrate inspect` to review the legacy profile before migrating it.',
818
+ };
819
+ }
820
+ const configRaw = await readFile(resolveOperatingPaths(projectRoot, { localRoot }).config, 'utf8').catch((error) => {
821
+ if (error.code === 'ENOENT')
822
+ return null;
823
+ throw error;
824
+ });
825
+ if (configRaw === null) {
826
+ return {
827
+ code: 'operate-profile-drift',
828
+ status: 'pass',
829
+ message: 'No operating configuration is present to compare the legacy profile against',
830
+ };
831
+ }
832
+ let config;
833
+ try {
834
+ config = JSON.parse(configRaw);
835
+ }
836
+ catch {
837
+ return {
838
+ code: 'operate-profile-drift',
839
+ status: 'pass',
840
+ message: 'Operating configuration is unreadable; the legacy profile cannot be compared to it',
841
+ };
842
+ }
843
+ const drift = compareLegacyProfileToOperatingConfig(profile, (config ?? {}));
844
+ if (drift.length > 0) {
845
+ return {
846
+ code: 'operate-profile-drift',
847
+ status: 'warn',
848
+ message: `Legacy .planr/operate-profile.json drifts from the operating configuration in ${drift.length} field(s): ${drift.join(', ')}`,
849
+ fix: 'Run `planr operate profiles migrate inspect`, then `planr operate profiles migrate apply --yes` to reconcile the legacy profile.',
850
+ };
851
+ }
852
+ return {
853
+ code: 'operate-profile-drift',
854
+ status: 'pass',
855
+ message: 'Legacy .planr/operate-profile.json matches the operating configuration',
856
+ };
857
+ }
858
+ /**
859
+ * FR6 (T-005): the internal transport contract is exactly that — internal. The
860
+ * user sees validated Markdown, concise progress, and a final synthesis, never a
861
+ * lease token, an idempotency key, an evidence digest, or a harness/adapter
862
+ * lifecycle command; JSON is available only under an explicit `--json`. These
863
+ * patterns are the forbidden field/command tokens as they appear when raw
864
+ * transport leaks into a human surface. They deliberately do NOT match the plain
865
+ * English words ("release"/"please" have no word boundary before "lease"; a
866
+ * finding that merely mentions "the runtime adapter" carries no lifecycle verb),
867
+ * so the check flags a genuine leak rather than ordinary advisory prose.
868
+ */
869
+ export const OPERATING_TRANSPORT_LEAK_PATTERNS = [
870
+ {
871
+ field: 'lease',
872
+ pattern: /\blease\s*(?:token|id|status|expires?|expiry|remaining|duration|nonce)\b|\blease(?:Token|Id|Status|Expires\w*|Remaining\w*|Duration\w*|Nonce)\b|"lease"\s*:/i,
873
+ },
874
+ { field: 'idempotencyKey', pattern: /\bidempotency[\s._-]?key\b|\bidempotencyKey\b/i },
875
+ { field: 'evidenceDigest', pattern: /\bevidence[\s._-]?digest\b|\bevidenceDigest\b/i },
876
+ {
877
+ field: 'harness/adapter command',
878
+ pattern: /\b(?:harness|adapter)(?:\.[a-z]+|\s+(?:prepare|record|finalize|resume|cancel|heartbeat))\b/i,
879
+ },
880
+ ];
881
+ /**
882
+ * Return the internal-transport field/command tokens that leaked into one block
883
+ * of human-facing text. Empty when the text is clean. Shared with the CLI
884
+ * regression test so both guards agree on exactly what counts as a leak.
885
+ */
886
+ export function scanOperatingTransportLeakage(text) {
887
+ const found = new Set();
888
+ for (const { field, pattern } of OPERATING_TRANSPORT_LEAK_PATTERNS) {
889
+ if (pattern.test(text))
890
+ found.add(field);
891
+ }
892
+ return [...found];
893
+ }
894
+ /**
895
+ * FR6: fail when any of the forbidden transport tokens appears in the non-`--json`
896
+ * output of `status`/`report`/`review`. Pure over the assembled surfaces so a
897
+ * deliberate leak can be exercised directly (and a clean board proven clean).
898
+ */
899
+ export function evaluateOperatingTransportLeakage(surfaces) {
900
+ const leaks = [];
901
+ for (const { surface, text } of surfaces) {
902
+ for (const field of scanOperatingTransportLeakage(text)) {
903
+ leaks.push(`${surface}: ${field}`);
904
+ }
905
+ }
906
+ if (leaks.length > 0) {
907
+ return {
908
+ code: 'operate-transport-hiding',
909
+ status: 'fail',
910
+ message: `Internal transport leaked into non-JSON operate output — ${[...new Set(leaks)]
911
+ .sort()
912
+ .join(', ')}`,
913
+ fix: 'Keep lease tokens, idempotency keys, evidence digests, and harness/adapter lifecycle commands inside the `--json` surface only.',
914
+ };
915
+ }
916
+ return {
917
+ code: 'operate-transport-hiding',
918
+ status: 'pass',
919
+ message: 'No internal transport (lease, idempotency key, evidence digest, or harness/adapter command) appears in status/report/review human output',
920
+ };
921
+ }
922
+ async function diagnoseTransportHiding(projectRoot, localRoot) {
923
+ const surfaces = [];
924
+ try {
925
+ const state = await new OperatingEventStore(projectRoot, { localRoot }).state();
926
+ // `status` renders the concise brief as its human summary.
927
+ surfaces.push({ surface: 'status', text: renderOperatingBrief(state) });
928
+ }
929
+ catch {
930
+ // No readable state yet; the event-replay check owns a broken chain.
931
+ }
932
+ try {
933
+ const { readOperatingReport } = await import('./reports.js');
934
+ const report = await readOperatingReport({ projectRoot, localRoot });
935
+ // `report` renders this Markdown directly; `review`'s human gate reuses the
936
+ // same assembly, so scanning it once covers both surfaces.
937
+ surfaces.push({ surface: 'report/review', text: report.markdown });
938
+ }
939
+ catch {
940
+ // No governed cycle to report yet.
941
+ }
942
+ if (surfaces.length === 0) {
943
+ return {
944
+ code: 'operate-transport-hiding',
945
+ status: 'pass',
946
+ message: 'No operate human output is available to scan for transport leakage yet',
947
+ };
948
+ }
949
+ return evaluateOperatingTransportLeakage(surfaces);
950
+ }
951
+ /**
952
+ * FR14 (T-005): a cycle is `reviewable` in committed state ONLY if its phase-F
953
+ * review-gate artifacts genuinely exist on disk. A projection that claims
954
+ * `reviewable` without the recorded roles, Chair result, final report, actions
955
+ * file, and draft/provenance results is exactly the dishonest completion the spec
956
+ * forbids, and it fails here with the actual phase each cycle reached.
957
+ */
958
+ async function diagnoseCompletionDiscipline(projectRoot, localRoot) {
959
+ let state;
960
+ try {
961
+ state = await new OperatingEventStore(projectRoot, { localRoot }).state();
962
+ }
963
+ catch {
964
+ return {
965
+ code: 'operate-completion',
966
+ status: 'pass',
967
+ message: 'No readable board state is available to verify completion discipline yet',
968
+ };
969
+ }
970
+ const paths = resolveOperatingPaths(projectRoot, { localRoot });
971
+ const reviewable = state.cycles.filter((cycle) => cycle.state === 'reviewable');
972
+ if (reviewable.length === 0) {
973
+ return {
974
+ code: 'operate-completion',
975
+ status: 'pass',
976
+ message: 'No cycle is marked reviewable; nothing to verify against the phase-F review gate',
977
+ };
978
+ }
979
+ const incomplete = [];
980
+ for (const cycle of reviewable) {
981
+ const result = await verifyOperatingCompletionPhases(state, cycle.id, paths);
982
+ if (!result.complete)
983
+ incomplete.push({ cycleId: cycle.id, result });
984
+ }
985
+ if (incomplete.length > 0) {
986
+ const detail = incomplete
987
+ .map(({ cycleId, result }) => `${cycleId} (reached phase ${result.reachedPhase ?? 'none'}; missing ${result.missing.join('; ') || 'phase-F artifacts'})`)
988
+ .join(', ');
989
+ return {
990
+ code: 'operate-completion',
991
+ status: 'fail',
992
+ message: `${incomplete.length} cycle(s) are marked reviewable without their phase-F review-gate artifacts on disk: ${detail}`,
993
+ fix: 'Run `planr operate cycles recover <cycleId> --yes` to re-materialize the review-gate artifacts from the verified event chain.',
994
+ };
995
+ }
996
+ return {
997
+ code: 'operate-completion',
998
+ status: 'pass',
999
+ message: `${reviewable.length} reviewable cycle(s) carry every phase-F review-gate artifact on disk`,
1000
+ };
1001
+ }
645
1002
  export async function diagnoseOperatingBoard(input) {
646
- const diagnostics = [await diagnoseProtocol(input.pipelineVersion)];
1003
+ const diagnostics = [await diagnoseAgentContractVersion(input.pipelineVersion)];
647
1004
  const paths = resolveOperatingPaths(input.projectRoot, { localRoot: input.localRoot });
648
1005
  if (!existsSync(paths.root))
649
1006
  return diagnostics;
@@ -685,12 +1042,29 @@ export async function diagnoseOperatingBoard(input) {
685
1042
  // FR11: the two staleness detectors for the machine-local caches FR4 binds
686
1043
  // to board identity — stale adapter sessions and stale incremental baselines.
687
1044
  await diagnoseAdapterSessions(input.projectRoot, input.localRoot), await diagnoseIncrementalBaselines(input.projectRoot, input.localRoot),
1045
+ // FR7 (T-006): detect OpenPlanr-owned scratch abandoned by a session that
1046
+ // never finalized; the scoped purge removes only confirmed owned stale scratch.
1047
+ await diagnoseAbandonedScratch(input.projectRoot, input.localRoot),
688
1048
  // FR7: cycle integrity is a first-class readable surface — this guards it
689
1049
  // against regression and reports its three conditions explicitly.
690
1050
  await diagnoseCycleIntegritySurface(input.projectRoot, input.localRoot),
1051
+ // FR9 (T-008): report the persisted board-state protocol version as its own
1052
+ // fact, separate from the agent-contract check above, so an older persisted
1053
+ // board is never rendered as an incompatibility.
1054
+ await diagnoseBoardStateVersionSurface(input.projectRoot, input.localRoot),
691
1055
  // FR9: state plainly whether a gitignored `.planr/` leaves the board
692
1056
  // unversioned, rather than implying it is tracked.
693
- await diagnoseWorkspaceVersioning(input.projectRoot));
1057
+ await diagnoseWorkspaceVersioning(input.projectRoot),
1058
+ // FR10 (T-009): report drift between a legacy operate-profile.json and the
1059
+ // live operating configuration, naming the differing fields.
1060
+ await diagnoseProfileDrift(input.projectRoot, input.localRoot),
1061
+ // FR6 (T-005): fail if internal transport (lease, idempotency key, evidence
1062
+ // digest, or a harness/adapter command) leaked into status/report/review
1063
+ // human output — those belong to the `--json` surface only.
1064
+ await diagnoseTransportHiding(input.projectRoot, input.localRoot),
1065
+ // FR14 (T-005): fail if a cycle is marked reviewable in committed state
1066
+ // without its phase-F review-gate artifacts genuinely present on disk.
1067
+ await diagnoseCompletionDiscipline(input.projectRoot, input.localRoot));
694
1068
  return diagnostics;
695
1069
  }
696
1070
  //# sourceMappingURL=doctor.js.map