@remogram/core 0.1.0-beta.2 → 0.1.0-beta.4

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.
package/auth-classes.js CHANGED
@@ -11,10 +11,13 @@ const AUTH_CLASS_VALUES = new Set(Object.values(AUTH_CLASS));
11
11
  export const API_PROVIDER_COMMAND_AUTH = {
12
12
  repo_status: AUTH_CLASS.NONE,
13
13
  ref_compare: AUTH_CLASS.GIT_ONLY,
14
+ ref_inventory: AUTH_CLASS.GIT_ONLY,
15
+ cr_inventory: AUTH_CLASS.TOKEN_REQUIRED,
14
16
  pr_status: AUTH_CLASS.TOKEN_REQUIRED,
15
17
  pr_checks: AUTH_CLASS.TOKEN_REQUIRED,
16
18
  merge_plan: AUTH_CLASS.TOKEN_REQUIRED,
17
19
  sync_plan: AUTH_CLASS.GIT_ONLY,
20
+ cr_open: AUTH_CLASS.TOKEN_REQUIRED,
18
21
  };
19
22
 
20
23
  export function commandCapability(name, { implemented = true } = {}) {
@@ -25,10 +28,11 @@ export function commandCapability(name, { implemented = true } = {}) {
25
28
  return { name, implemented, auth_class };
26
29
  }
27
30
 
28
- export function apiProviderCommands() {
29
- return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) =>
30
- commandCapability(name, { implemented: true }),
31
- );
31
+ export function apiProviderCommands({ writeCommandsImplemented = false } = {}) {
32
+ return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) => {
33
+ const implemented = name === 'cr_open' ? writeCommandsImplemented : true;
34
+ return commandCapability(name, { implemented });
35
+ });
32
36
  }
33
37
 
34
38
  export function stubProviderCommands() {
package/caps.js CHANGED
@@ -2,6 +2,15 @@ export const DEFAULT_MAX_BYTES = 8192;
2
2
  export const DEFAULT_FIELD_MAX_BYTES = 512;
3
3
  export const FORGE_INGEST_MAX_BYTES_ENV = 'REMOGRAM_FORGE_INGEST_MAX_BYTES';
4
4
 
5
+ /** Conservative check/status page size vs DEFAULT_MAX_BYTES raw ingest cap (pre-parse). */
6
+ export const DEFAULT_CHECK_STATUS_PAGE_SIZE = 25;
7
+ export const MAX_CHECK_STATUS_PAGES = 50;
8
+
9
+ /** Gitea open-pull list page size for idempotency scan and inventory list bounds. */
10
+ export const DEFAULT_OPEN_PULL_LIST_PAGE_SIZE = 100;
11
+ /** Max pages scanned before cr open idempotency fails closed (decoupled from check-status pagination). */
12
+ export const MAX_OPEN_PULL_IDEMPOTENCY_PAGES = 50;
13
+
5
14
  export function getEffectiveIngestMaxBytes() {
6
15
  const raw = process.env[FORGE_INGEST_MAX_BYTES_ENV];
7
16
  if (raw == null || raw === '') {
@@ -20,6 +29,70 @@ export function forgeIngestCapabilityFacts() {
20
29
  return { forge_ingest_cap_bytes: bytes };
21
30
  }
22
31
 
32
+ /**
33
+ * Structured check-list pagination facts for provider capabilities.
34
+ * @param {{ strategy: 'offset_limit' | 'link_header', pageSizeParam: 'limit' | 'per_page' | null, sourceCount?: number }} opts
35
+ */
36
+ export function checkPaginationCapabilityFacts({ strategy, pageSizeParam, sourceCount = 1 }) {
37
+ const perSource = DEFAULT_CHECK_STATUS_PAGE_SIZE * MAX_CHECK_STATUS_PAGES;
38
+ return {
39
+ check_pagination: {
40
+ strategy,
41
+ page_size: DEFAULT_CHECK_STATUS_PAGE_SIZE,
42
+ max_pages: MAX_CHECK_STATUS_PAGES,
43
+ page_size_param: pageSizeParam,
44
+ ingest_backoff: 'halve_until_fit',
45
+ on_page_cap: 'set_checks_truncated',
46
+ compliant_max_items_per_source: perSource,
47
+ check_source_count: sourceCount,
48
+ truncation_combination:
49
+ sourceCount > 1 ? 'any_source_truncated' : 'single_source',
50
+ compliant_max_items_total: perSource * sourceCount,
51
+ truncation_packet_field: 'checks_truncated',
52
+ },
53
+ };
54
+ }
55
+
56
+ /** Structured idempotency scan facts for provider capabilities (cr open). */
57
+ export function idempotencyScanCapabilityFacts() {
58
+ return {
59
+ idempotency_scan: {
60
+ max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
61
+ page_size: DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
62
+ ingest_backoff: 'halve_until_fit',
63
+ },
64
+ };
65
+ }
66
+
67
+ /** Structured open-pull list pagination facts for provider capabilities (cr inventory). */
68
+ export function openPullListCapabilityFacts({
69
+ totalCountSource = null,
70
+ totalCountHeader = null,
71
+ sliceSortNotes = null,
72
+ } = {}) {
73
+ const compliantMaxItems = DEFAULT_OPEN_PULL_LIST_PAGE_SIZE * MAX_CHECK_STATUS_PAGES;
74
+ return {
75
+ open_pull_list: {
76
+ max_pages: MAX_CHECK_STATUS_PAGES,
77
+ page_size: DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
78
+ ingest_backoff: 'halve_until_fit',
79
+ compliant_max_items: compliantMaxItems,
80
+ truncation_packet_field: 'list_truncated',
81
+ incomplete_error_code: 'inventory_list_incomplete',
82
+ default_slice_sort: 'number_asc',
83
+ supported_slice_sorts: [
84
+ 'number_asc',
85
+ 'number_desc',
86
+ 'recent_update',
87
+ 'recent_created',
88
+ ],
89
+ ...(totalCountSource ? { total_count_source: totalCountSource } : {}),
90
+ ...(totalCountHeader ? { total_count_header: totalCountHeader } : {}),
91
+ ...(sliceSortNotes ? { slice_sort_notes: sliceSortNotes } : {}),
92
+ },
93
+ };
94
+ }
95
+
23
96
  export function capText(text, maxBytes = DEFAULT_MAX_BYTES) {
24
97
  if (!text) return { text: '', truncated: false, bytes: 0 };
25
98
  const buf = Buffer.from(text, 'utf8');
@@ -0,0 +1,215 @@
1
+ import { ERROR_CODES } from './contracts/errors.js';
2
+ import { DEFAULT_CHECK_STATUS_PAGE_SIZE, MAX_CHECK_STATUS_PAGES } from './caps.js';
3
+ import { resolvePaginatedEntryCount } from './open-pull-list.js';
4
+
5
+ function isOversizedIngestError(err) {
6
+ return err?.forgeError?.code === ERROR_CODES.OVERSIZED_RAW_OUTPUT;
7
+ }
8
+
9
+ export function withPerPageParam(url, limit) {
10
+ const parsed = new URL(url);
11
+ parsed.searchParams.set('per_page', String(limit));
12
+ return parsed.toString();
13
+ }
14
+
15
+ export function withLimitParam(url, limit) {
16
+ const parsed = new URL(url);
17
+ parsed.searchParams.set('limit', String(limit));
18
+ return parsed.toString();
19
+ }
20
+
21
+ /**
22
+ * Retry fetch with halved page size when raw ingest exceeds cap.
23
+ * @template T
24
+ * @param {(url: string) => Promise<T>} fetchFn
25
+ * @param {(limit: number) => string} buildUrl
26
+ * @param {number} [initialLimit]
27
+ */
28
+ export async function fetchWithIngestPageBackoff(
29
+ fetchFn,
30
+ buildUrl,
31
+ initialLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE,
32
+ ) {
33
+ let limit = initialLimit;
34
+ while (true) {
35
+ try {
36
+ return await fetchFn(buildUrl(limit));
37
+ } catch (err) {
38
+ if (isOversizedIngestError(err) && limit > 1) {
39
+ limit = Math.max(1, Math.floor(limit / 2));
40
+ continue;
41
+ }
42
+ throw err;
43
+ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Fetch one offset page with ingest-cap backoff.
49
+ * @template T
50
+ * @param {(opts: { page: number, limit: number }) => Promise<unknown[]>} fetchPage
51
+ * @param {number} page
52
+ * @param {number} initialLimit
53
+ * @returns {Promise<{ items: unknown[], usedLimit: number }>}
54
+ */
55
+ export async function fetchPageWithIngestBackoff(fetchPage, page, initialLimit) {
56
+ let usedLimit = initialLimit;
57
+ const items = await fetchWithIngestPageBackoff(
58
+ async (limit) => {
59
+ usedLimit = limit;
60
+ const pageItems = await fetchPage({ page, limit });
61
+ return Array.isArray(pageItems) ? pageItems : [];
62
+ },
63
+ (limit) => limit,
64
+ initialLimit,
65
+ );
66
+ return { items, usedLimit };
67
+ }
68
+
69
+ /**
70
+ * When a full page lands on maxPages, probe one item on the next page to distinguish
71
+ * end-of-list from truncation.
72
+ * @template T
73
+ * @param {(opts: { page: number, limit: number }) => Promise<unknown[]>} fetchPage
74
+ * @param {number} page
75
+ * @param {number} maxPages
76
+ * @returns {Promise<boolean>} true when list is truncated (more items exist)
77
+ */
78
+ async function probeNextPageHasItems(fetchPage, page, maxPages) {
79
+ if (page > maxPages) return true;
80
+ const { items: probeItems } = await fetchPageWithIngestBackoff(fetchPage, page + 1, 1);
81
+ return probeItems.length > 0;
82
+ }
83
+
84
+ /**
85
+ * Offset/limit check-status pagination with ingest-cap backoff.
86
+ * @param {{ fetchPage: (opts: { page: number, limit: number }) => Promise<unknown[]>, pageSize?: number, maxPages?: number }} opts
87
+ */
88
+ export async function paginateCheckStatusPages({
89
+ fetchPage,
90
+ pageSize = DEFAULT_CHECK_STATUS_PAGE_SIZE,
91
+ maxPages = MAX_CHECK_STATUS_PAGES,
92
+ }) {
93
+ const all = [];
94
+ let truncated = false;
95
+ let activeLimit = pageSize;
96
+ for (let page = 1; page <= maxPages; page += 1) {
97
+ const { items: pageItems, usedLimit } = await fetchPageWithIngestBackoff(
98
+ fetchPage,
99
+ page,
100
+ activeLimit,
101
+ );
102
+ activeLimit = usedLimit;
103
+ all.push(...pageItems);
104
+ if (pageItems.length < usedLimit) {
105
+ break;
106
+ }
107
+ if (page === maxPages) {
108
+ truncated = true;
109
+ break;
110
+ }
111
+ }
112
+ return { items: all, truncated };
113
+ }
114
+
115
+ /**
116
+ * Offset/limit open-list pagination with ingest-cap backoff and optional list cap.
117
+ * listLimit bounds request size per page; callers slice returned items when enforcing a hard cap.
118
+ * @param {{ fetchPage: (opts: { page: number, limit: number }) => Promise<unknown[]>, pageSize: number, listLimit?: number | null, maxPages?: number, maxPagesTruncatesWithLimit?: boolean, retainMax?: number | null, trustedEntryCount?: number | null, seededFirstPage?: { items: unknown[], usedLimit: number } | null, startPage?: number, suppressFinalPageProbe?: boolean }} opts
119
+ * @returns {Promise<{ items: unknown[], list_truncated: boolean, walked_count: number, entry_count?: number }>}
120
+ */
121
+ export async function paginateOffsetListPages({
122
+ fetchPage,
123
+ pageSize,
124
+ listLimit = null,
125
+ maxPages = MAX_CHECK_STATUS_PAGES,
126
+ maxPagesTruncatesWithLimit = false,
127
+ retainMax = null,
128
+ trustedEntryCount = null,
129
+ seededFirstPage = null,
130
+ startPage = 1,
131
+ suppressFinalPageProbe = false,
132
+ }) {
133
+ const all = [];
134
+ let entryCount = 0;
135
+ let listTruncated = false;
136
+ let activeLimit = pageSize;
137
+
138
+ async function afterPage(page, items, usedLimit) {
139
+ entryCount += items.length;
140
+ if (retainMax != null) {
141
+ const space = Math.max(retainMax - all.length, 0);
142
+ if (space > 0) all.push(...items.slice(0, space));
143
+ } else {
144
+ all.push(...items);
145
+ }
146
+ if (items.length < usedLimit) {
147
+ if (
148
+ trustedEntryCount != null &&
149
+ trustedEntryCount > entryCount &&
150
+ page === 1 &&
151
+ page < maxPages
152
+ ) {
153
+ return false;
154
+ }
155
+ return true;
156
+ }
157
+ if (listLimit != null && all.length >= listLimit) {
158
+ listTruncated = await probeNextPageHasItems(fetchPage, page, maxPages);
159
+ return true;
160
+ }
161
+ if (listLimit != null) {
162
+ if (maxPagesTruncatesWithLimit && page === maxPages) {
163
+ listTruncated = true;
164
+ return true;
165
+ }
166
+ } else if (page === maxPages) {
167
+ if (suppressFinalPageProbe) {
168
+ listTruncated = items.length >= usedLimit;
169
+ } else {
170
+ listTruncated = await probeNextPageHasItems(fetchPage, page, maxPages);
171
+ }
172
+ return true;
173
+ }
174
+ return false;
175
+ }
176
+
177
+ let page = startPage;
178
+ if (seededFirstPage && startPage === 1) {
179
+ const { items, usedLimit } = seededFirstPage;
180
+ activeLimit = usedLimit;
181
+ if (await afterPage(1, items, usedLimit)) {
182
+ return {
183
+ items: all,
184
+ list_truncated: listTruncated,
185
+ walked_count: entryCount,
186
+ ...(retainMax != null || trustedEntryCount != null
187
+ ? { entry_count: resolvePaginatedEntryCount(trustedEntryCount, entryCount) }
188
+ : {}),
189
+ };
190
+ }
191
+ page = 2;
192
+ }
193
+
194
+ for (; page <= maxPages; page += 1) {
195
+ const remaining = listLimit != null ? Math.max(listLimit - all.length, 0) : activeLimit;
196
+ if (listLimit != null && remaining === 0) break;
197
+ const requestLimit = listLimit != null ? Math.min(activeLimit, remaining) : activeLimit;
198
+ const { items, usedLimit } = await fetchPageWithIngestBackoff(fetchPage, page, requestLimit);
199
+ activeLimit = usedLimit;
200
+ if (await afterPage(page, items, usedLimit)) {
201
+ break;
202
+ }
203
+ }
204
+
205
+ return {
206
+ items: all,
207
+ list_truncated: listTruncated,
208
+ walked_count: entryCount,
209
+ ...(retainMax != null || trustedEntryCount != null
210
+ ? {
211
+ entry_count: resolvePaginatedEntryCount(trustedEntryCount, entryCount),
212
+ }
213
+ : {}),
214
+ };
215
+ }
package/config-schema.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { writeCommandSchema } from './write-config.js';
2
3
 
3
4
  const providerSchema = z.enum([
4
5
  'gitea-api',
@@ -23,6 +24,7 @@ export const configSchema = z
23
24
  owner: repoSegmentSchema,
24
25
  repo: repoSegmentSchema,
25
26
  baseUrl: z.string().url().optional(),
27
+ write_commands: z.array(writeCommandSchema).optional(),
26
28
  })
27
29
  .strict();
28
30
 
@@ -1,4 +1,5 @@
1
1
  import { sanitizeField } from '../caps.js';
2
+ import { normalizeForgeErrorFields } from './forge-error-fields.js';
2
3
 
3
4
  export const SCHEMA_VERSION = 1;
4
5
 
@@ -12,6 +13,7 @@ export const PACKET_TYPES = {
12
13
  PROVIDER_CAPABILITIES: 'provider_capabilities',
13
14
  PROVIDER_DOCTOR: 'provider_doctor',
14
15
  FORGE_ERROR: 'forge_error',
16
+ CHANGE_REQUEST_OPENED: 'change_request_opened',
15
17
  };
16
18
 
17
19
  export const FORBIDDEN_PACKET_KEYS = new Set([
@@ -29,7 +31,7 @@ function assertNoForbiddenKeys(value) {
29
31
  }
30
32
  for (const [key, nested] of Object.entries(value)) {
31
33
  if (FORBIDDEN_PACKET_KEYS.has(key)) {
32
- throw new Error(`Forbidden Topogram concept in remogram output: ${key}`);
34
+ throw new Error(`Forbidden workflow/planning-tool key in remogram output: ${key}`);
33
35
  }
34
36
  assertNoForbiddenKeys(nested);
35
37
  }
@@ -53,13 +55,21 @@ export function forgePacket(type, context, body = {}, error = null) {
53
55
  packet.error_code = error.code;
54
56
  packet.error_message = sanitizeField(error.message);
55
57
  if (error.status != null) packet.error_status = error.status;
58
+ if (error.fields != null && typeof error.fields === 'object') {
59
+ assertNoForbiddenKeys(error.fields);
60
+ const trustedFields = normalizeForgeErrorFields(error.code, error.fields);
61
+ if (trustedFields != null) {
62
+ Object.assign(packet, trustedFields);
63
+ }
64
+ }
56
65
  }
57
66
 
58
67
  return packet;
59
68
  }
60
69
 
61
70
  export function forgeErrorPacket(context, error, type = PACKET_TYPES.FORGE_ERROR) {
62
- return forgePacket(type, context, {}, error);
71
+ const body = error?.fields != null && typeof error.fields === 'object' ? error.fields : {};
72
+ return forgePacket(type, context, body, error);
63
73
  }
64
74
 
65
75
  export function unknownForgeContext() {
@@ -11,9 +11,18 @@ export const ERROR_CODES = {
11
11
  CONFIG_NOT_FOUND: 'config_not_found',
12
12
  INVALID_ARGS: 'invalid_args',
13
13
  API_ERROR: 'api_error',
14
+ PR_NOT_OPEN: 'pr_not_open',
14
15
  REMOTE_INFER_FAILED: 'remote_infer_failed',
16
+ WRITE_NOT_CONFIGURED: 'write_not_configured',
17
+ IDEMPOTENCY_SCAN_INCOMPLETE: 'idempotency_scan_incomplete',
18
+ INVENTORY_LIST_INCOMPLETE: 'inventory_list_incomplete',
15
19
  };
16
20
 
17
- export function forgeError(code, message, status = null) {
18
- return { code, message, ...(status != null ? { status } : {}) };
21
+ export function forgeError(code, message, status = null, fields = null) {
22
+ return {
23
+ code,
24
+ message,
25
+ ...(status != null ? { status } : {}),
26
+ ...(fields != null && typeof fields === 'object' ? { fields } : {}),
27
+ };
19
28
  }
@@ -0,0 +1,97 @@
1
+ import { ERROR_CODES } from './errors.js';
2
+
3
+ /** Top-level packet keys forge error fields must never override. */
4
+ const FORBIDDEN_ERROR_FIELD_KEYS = new Set([
5
+ 'type',
6
+ 'schema_version',
7
+ 'provider_id',
8
+ 'remote_name',
9
+ 'repo_id',
10
+ 'observed_at',
11
+ 'ok',
12
+ 'error_code',
13
+ 'error_message',
14
+ 'error_status',
15
+ ]);
16
+
17
+ /** Trusted body fields allowed per forge error code. */
18
+ export const FORGE_ERROR_FIELD_ALLOWLIST = Object.freeze({
19
+ [ERROR_CODES.IDEMPOTENCY_SCAN_INCOMPLETE]: ['idempotency_scan'],
20
+ [ERROR_CODES.INVENTORY_LIST_INCOMPLETE]: ['inventory_list'],
21
+ });
22
+
23
+ function assertPositiveInteger(name, value) {
24
+ if (!Number.isInteger(value) || value <= 0) {
25
+ throw new Error(`Invalid forge error field ${name}: must be a positive integer`);
26
+ }
27
+ }
28
+
29
+ function validateIdempotencyScan(scan) {
30
+ if (scan == null || typeof scan !== 'object' || Array.isArray(scan)) {
31
+ throw new Error('Invalid forge error field idempotency_scan: must be an object');
32
+ }
33
+ const keys = Object.keys(scan).sort();
34
+ const expected = ['max_pages', 'page_size', 'pages'];
35
+ if (keys.length !== expected.length || !expected.every((k) => keys.includes(k))) {
36
+ throw new Error('Invalid forge error field idempotency_scan: unexpected keys');
37
+ }
38
+ assertPositiveInteger('idempotency_scan.pages', scan.pages);
39
+ assertPositiveInteger('idempotency_scan.max_pages', scan.max_pages);
40
+ assertPositiveInteger('idempotency_scan.page_size', scan.page_size);
41
+ return { idempotency_scan: scan };
42
+ }
43
+
44
+ function validateInventoryList(list) {
45
+ if (list == null || typeof list !== 'object' || Array.isArray(list)) {
46
+ throw new Error('Invalid forge error field inventory_list: must be an object');
47
+ }
48
+ const keys = Object.keys(list).sort();
49
+ if (keys.length !== 1 || keys[0] !== 'entry_count') {
50
+ throw new Error('Invalid forge error field inventory_list: unexpected keys');
51
+ }
52
+ assertPositiveInteger('inventory_list.entry_count', list.entry_count);
53
+ return { inventory_list: list };
54
+ }
55
+
56
+ /**
57
+ * Validate and normalize trusted forge_error body fields before packet merge.
58
+ * @param {string} code
59
+ * @param {Record<string, unknown> | null | undefined} fields
60
+ * @returns {Record<string, unknown> | null}
61
+ */
62
+ export function normalizeForgeErrorFields(code, fields) {
63
+ if (fields == null) return null;
64
+ if (typeof fields !== 'object' || Array.isArray(fields)) {
65
+ throw new Error('Invalid forge error fields: must be an object');
66
+ }
67
+
68
+ const keys = Object.keys(fields);
69
+ if (keys.length === 0) return null;
70
+
71
+ for (const key of keys) {
72
+ if (FORBIDDEN_ERROR_FIELD_KEYS.has(key)) {
73
+ throw new Error(`Forge error fields cannot override packet field ${key}`);
74
+ }
75
+ }
76
+
77
+ const allowlist = FORGE_ERROR_FIELD_ALLOWLIST[code];
78
+ if (!allowlist) {
79
+ throw new Error(`Forge error code ${code} does not allow trusted fields`);
80
+ }
81
+
82
+ for (const key of keys) {
83
+ if (!allowlist.includes(key)) {
84
+ throw new Error(`Forge error field ${key} is not allowed for code ${code}`);
85
+ }
86
+ }
87
+
88
+ if (fields.idempotency_scan != null) {
89
+ return validateIdempotencyScan(fields.idempotency_scan);
90
+ }
91
+
92
+ if (fields.inventory_list != null) {
93
+ return validateInventoryList(fields.inventory_list);
94
+ }
95
+
96
+ return fields;
97
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Remogram fact requirements for observer and semantic-diff consumer snapshots.
3
+ * Observer proto today captures remogram repo status only; downstream consumers
4
+ * may compose additional read-only fact packets listed here.
5
+ */
6
+
7
+ import { FACT_INVENTORY_PACKET_TYPES, V1_READ_PLAN_COMMANDS } from './semantic-diff-facts.js';
8
+
9
+ /** CLI/MCP surface entries observer consumers may call (read-only). */
10
+ export const OBSERVER_REMOGRAM_COMMANDS = Object.freeze([
11
+ { command: 'repo status', mcp_tool: 'repo_status', read_only: true, observer_proto: true },
12
+ { command: 'refs inventory', mcp_tool: 'ref_inventory', read_only: true, observer_proto: false },
13
+ { command: 'cr inventory', mcp_tool: 'cr_inventory', read_only: true, observer_proto: false },
14
+ { command: 'refs compare', mcp_tool: 'ref_compare', read_only: true, observer_proto: false },
15
+ { command: 'pr view', mcp_tool: 'pr_status', read_only: true, observer_proto: false },
16
+ { command: 'pr checks', mcp_tool: 'pr_checks', read_only: true, observer_proto: false },
17
+ { command: 'merge plan', mcp_tool: 'merge_plan', read_only: true, observer_proto: false },
18
+ { command: 'sync plan', mcp_tool: 'sync_plan', read_only: true, observer_proto: false },
19
+ { command: 'provider capabilities', mcp_tool: 'provider_capabilities', read_only: true, observer_proto: false },
20
+ { command: 'doctor', mcp_tool: 'doctor', read_only: true, observer_proto: false },
21
+ ]);
22
+
23
+ /** Fact inventory packet types for semantic-diff / branch-workcycle composition. */
24
+ export const OBSERVER_FACT_INVENTORY_PACKETS = Object.freeze([
25
+ {
26
+ packet_type: FACT_INVENTORY_PACKET_TYPES.REF_INVENTORY,
27
+ command: 'refs inventory',
28
+ mcp_tool: 'ref_inventory',
29
+ read_only: true,
30
+ },
31
+ {
32
+ packet_type: FACT_INVENTORY_PACKET_TYPES.CR_INVENTORY_SLICE,
33
+ command: 'cr inventory',
34
+ mcp_tool: 'cr_inventory',
35
+ read_only: true,
36
+ },
37
+ ]);
38
+
39
+ /** Commands captured directly by observer-snapshot.sh today. */
40
+ export function observerProtoRemogramCommands() {
41
+ return OBSERVER_REMOGRAM_COMMANDS.filter((entry) => entry.observer_proto);
42
+ }
43
+
44
+ /** Extended fact commands for semantic-diff inventory beyond the proto script. */
45
+ export function semanticDiffFactCommands() {
46
+ return OBSERVER_REMOGRAM_COMMANDS.filter((entry) => !entry.observer_proto);
47
+ }
48
+
49
+ /** All v1 read/plan commands remain authoritative alongside fact inventory. */
50
+ export function allObserverEligibleCommands() {
51
+ return V1_READ_PLAN_COMMANDS.map((command) => {
52
+ const entry = OBSERVER_REMOGRAM_COMMANDS.find((c) => c.command === command);
53
+ return {
54
+ command,
55
+ mcp_tool: entry?.mcp_tool ?? null,
56
+ read_only: entry?.read_only ?? true,
57
+ };
58
+ });
59
+ }