@principles/host-runtime 0.3.3 → 0.3.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/dist/codex-disclosure.d.ts +23 -0
- package/dist/codex-disclosure.js +60 -0
- package/dist/codex-ingestion-consent.d.ts +111 -0
- package/dist/codex-ingestion-consent.js +223 -0
- package/dist/codex-legacy-registration.d.ts +18 -0
- package/dist/codex-legacy-registration.js +34 -0
- package/dist/codex-transcript-locate.d.ts +16 -0
- package/dist/codex-transcript-locate.js +106 -0
- package/dist/codex-worker-status.d.ts +24 -0
- package/dist/codex-worker-status.js +52 -0
- package/dist/governance-observation-store.d.ts +77 -1
- package/dist/governance-observation-store.js +188 -3
- package/dist/governance-signal-admission.d.ts +26 -0
- package/dist/governance-signal-admission.js +47 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex conversation-ingestion consent disclosure — G2A frozen text (Slice D).
|
|
3
|
+
*
|
|
4
|
+
* The Chinese text below is copied VERBATIM from the Owner-approved decision
|
|
5
|
+
* package:
|
|
6
|
+
* docs/superpowers/specs/2026-08-28-codex-governance-closure-g0-g2a-decision.md
|
|
7
|
+
* § "Frozen consent disclosure text (setup will show this verbatim)"
|
|
8
|
+
*
|
|
9
|
+
* That document is the language SSoT. The frozen text "must not be weakened
|
|
10
|
+
* during implementation without a new Owner decision" — the guard test
|
|
11
|
+
* (codex-disclosure-g2a-guard.test.ts) re-extracts the frozen section from
|
|
12
|
+
* the document and asserts byte equality with this constant, so any drift
|
|
13
|
+
* here fails CI.
|
|
14
|
+
*
|
|
15
|
+
* The English rendering states the same facts; per the G2A binding note it is
|
|
16
|
+
* produced at setup implementation time from the frozen Chinese text. Setup
|
|
17
|
+
* surfaces always present the Chinese SSoT first.
|
|
18
|
+
*/
|
|
19
|
+
export declare const CODEX_INGESTION_DISCLOSURE_VERSION = "g2a-2026-08-28";
|
|
20
|
+
export type CodexIngestionDisclosureLanguage = 'zh' | 'en';
|
|
21
|
+
export declare const CODEX_INGESTION_DISCLOSURE_ZH = "### Principles Disciple \u2014 \u5BF9\u8BDD\u89C2\u5BDF\u4E0E\u6CBB\u7406\u95ED\u73AF\uFF08Codex\uFF09\n\n**\u5F00\u542F\u540E PD \u4F1A\u8BFB\u53D6\u4EC0\u4E48\uFF1F**\n\u8BFB\u53D6\u672C\u5DE5\u4F5C\u533A\u5185 Codex \u660E\u786E\u63D0\u4F9B\u7ED9 PD \u7684\u4F1A\u8BDD\u8BB0\u5F55\uFF08transcript\uFF09\u3002\u8FDB\u5165\u6CBB\u7406\u89C2\u5BDF\u7684\u53EA\u6709\u53EF\u89C1\u5185\u5BB9\uFF1A\u4F60\u53D1\u51FA\u7684\u6D88\u606F\u3001\u52A9\u624B\u7684\u53EF\u89C1\u56DE\u590D\u3001\u4EE5\u53CA\u5DE5\u5177\u8C03\u7528\u7684\u540D\u79F0/\u8F93\u5165/\u7ED3\u679C\uFF08\u7528\u4E8E\u8BC6\u522B\u53CD\u590D\u51FA\u73B0\u7684\u95EE\u9898\uFF09\u3002\u8BB0\u5F55\u4E2D\u540C\u65F6\u5B58\u5728\u7684\u9690\u85CF\u601D\u8003\u8FC7\u7A0B\uFF08Codex \u4EE5\u52A0\u5BC6\u5F62\u5F0F\u4FDD\u5B58\uFF09\u3001\u7CFB\u7EDF/\u5F00\u53D1\u8005\u63D0\u793A\u8BCD\u3001\u5BBF\u4E3B\u6CE8\u5165\u7684\u73AF\u5883\u4E0A\u4E0B\u6587\uFF0C\u4F1A\u5728\u89E3\u6790\u65F6\u88AB\u8BC6\u522B\u5E76\u4E22\u5F03\u2014\u2014\u4E0D\u4F1A\u88AB\u4FDD\u5B58\u3001\u4E0D\u4F1A\u8FDB\u5165\u65E5\u5FD7\u3001\u4E0D\u4F1A\u53D1\u9001\u7ED9\u8BCA\u65AD\u6A21\u578B\u3002\n\n**\u4E3A\u4EC0\u4E48\u8BFB\u53D6\uFF1F**\n\u4E3A\u4E86\u8BA9 PD \u5728 Codex \u4E0A\u5B8C\u6210\u5B83\u627F\u8BFA\u7684\u95ED\u73AF\uFF1A\u628A\u4F60\u53CD\u590D\u7EA0\u6B63\u7684\u5730\u65B9\u53D8\u6210\u4E00\u6761\u53EF\u5BA1\u67E5\u7684\u539F\u5219\u5019\u9009\u2014\u2014\u4F60\u9700\u8981\u5148\u770B\u5230\u8BC1\u636E\uFF0C\u518D\u51B3\u5B9A\u662F\u5426\u91C7\u7EB3\u3002\u4E0D\u89E3\u6790\u5BF9\u8BDD\uFF0CPD \u5C31\u53EA\u80FD\u7BA1\u5DE5\u5177\uFF0C\u5B66\u4E0D\u5230\u7EA0\u6B63\u3002\n\n**\u4FDD\u5B58\u591A\u5C11\u3001\u591A\u4E45\uFF1F**\n\u6BCF\u4E2A\u4F1A\u8BDD\u53EA\u4FDD\u7559\u6700\u8FD1 32 \u6761\u53EF\u89C1\u6D88\u606F\u3001\u6700\u591A 7 \u5929\uFF08\u5148\u5230\u671F\u5148\u5220\u9664\uFF09\u3002\u5F53\u67D0\u4E2A\u95EE\u9898\u88AB\u6B63\u5F0F\u7ACB\u4E3A pain \u65F6\uFF0C\u624D\u4F1A\u628A\u5B83\u7684\u524D 12 \u6761\u6D88\u606F + \u89E6\u53D1\u70B9 + \u52A9\u624B\u7684\u4E0B\u4E00\u6761\u5B8C\u6574\u56DE\u590D\u5347\u7EA7\u4E3A\u957F\u671F\u6CBB\u7406\u8BC1\u636E\uFF0C\u6309\u73B0\u6709\u6CBB\u7406\u6570\u636E\u7684\u751F\u547D\u5468\u671F\u7BA1\u7406\u3002\u4FDD\u5B58\u7684\u5DE5\u5177\u8BC1\u636E\u5728\u843D\u76D8\u524D\u4F1A\u7ECF\u8FC7\u65E2\u6709\u7684\u654F\u611F\u5B57\u6BB5\u4E0E\u5E38\u89C1\u5BC6\u94A5\u683C\u5F0F\u8131\u654F\uFF1B\u4F46\u8BF7\u6CE8\u610F\uFF1A\u5982\u679C\u4E00\u6BB5\u5BC6\u94A5\u770B\u8D77\u6765\u5C31\u50CF\u666E\u901A\u6587\u5B57\uFF0C\u4EFB\u4F55\u8FC7\u6EE4\u5668\u90FD\u65E0\u6CD5\u8BC6\u522B\u5B83\u2014\u2014\u8BF7\u4E0D\u8981\u8BA9 PD \u89C2\u5BDF\u5305\u542B\u6B64\u7C7B\u5185\u5BB9\u7684\u4F1A\u8BDD\u3002\n\n**\u6570\u636E\u4F1A\u79BB\u5F00\u672C\u673A\u5417\uFF1F**\n\u89C2\u5BDF\u6570\u636E\u672C\u8EAB\u53EA\u5B58\u5728\u672C\u673A\u5DE5\u4F5C\u533A\u3002\u88AB\u5347\u7EA7\u4E3A\u6CBB\u7406\u8BC1\u636E\u7684\u5BF9\u8BDD\u7247\u6BB5\uFF0C\u4F1A\u50CF\u73B0\u6709\u8BCA\u65AD\u6D41\u7A0B\u4E00\u6837\u53D1\u9001\u7ED9\u4F60\u914D\u7F6E\u7684 LLM API \u505A\u8BCA\u65AD\u2014\u2014\u8FD9\u662F\u552F\u4E00\u7684\u5916\u53D1\u8DEF\u5F84\uFF0C\u4E0E OpenClaw \u4E0A\u7684\u73B0\u6709\u884C\u4E3A\u4E00\u81F4\u3002\u4EA7\u54C1\u9065\u6D4B\u4E0D\u5305\u542B\u4EFB\u4F55\u6D88\u606F\u5185\u5BB9\u6216\u4F1A\u8BDD\u6807\u8BC6\u3002\n\n**\u5982\u4F55\u5173\u95ED\uFF1F\u968F\u65F6\u3002**\n\u5173\u95ED\u540E PD \u7ACB\u5373\u505C\u6B62\u8BFB\u53D6 transcript\uFF08\u4E0D\u662F\"\u8BFB\u4E86\u518D\u4E22\u5F03\"\uFF0C\u800C\u662F\u6839\u672C\u4E0D\u6253\u5F00\uFF09\u3002\u5DF2\u6709\u7684\u539F\u5219\u6CE8\u5165\u3001\u5DE5\u5177\u62E6\u622A\u3001\u65E2\u6709\u5DE5\u5177\u8BC1\u636E\u5B8C\u5168\u4E0D\u53D7\u5F71\u54CD\u3002\u5DF2\u4FDD\u5B58\u7684\u5185\u5BB9\u6309\u4E0A\u8FF0\u671F\u9650\u81EA\u7136\u8FC7\u671F\uFF1B\u5347\u7EA7\u8FC7\u7684\u8BC1\u636E\u53EA\u80FD\u7531\u4F60\u901A\u8FC7\u65E2\u6709\u6CBB\u7406\u6E05\u7406\u547D\u4EE4\u663E\u5F0F\u5220\u9664\u3002\u5378\u8F7D\u4E0D\u4F1A\u9759\u9ED8\u5220\u9664\u4EFB\u4F55\u8BC1\u636E\u3002\n\n**\u9ED8\u8BA4\u72B6\u6001\uFF1F**\n\u9ED8\u8BA4\u5173\u95ED\u3002\u53EA\u6709\u4F60\u5728\u770B\u5230\u672C\u8BF4\u660E\u540E\u660E\u786E\u9009\u62E9\u5F00\u542F\u624D\u4F1A\u751F\u6548\uFF1B\u5347\u7EA7 PD \u6C38\u8FDC\u4E0D\u4F1A\u66FF\u4F60\u5F00\u542F\u3002";
|
|
22
|
+
export declare const CODEX_INGESTION_DISCLOSURE_EN = "### Principles Disciple \u2014 Conversation Observation & Governance Closure (Codex)\n\n**What does PD read once enabled?**\nPD reads the Codex session transcripts that Codex explicitly provides to PD inside this workspace. Only visible content enters governance observation: the messages you send, the assistant's visible replies, and the names/inputs/results of tool calls (used to recognize recurring problems). The hidden reasoning process that Codex stores in encrypted form, system/developer prompts, and host-injected environment context present in the same records are recognized and discarded during parsing \u2014 never saved, never written to logs, never sent to the diagnosis model.\n\n**Why read at all?**\nSo that PD can deliver on its promised closed loop on Codex: turning the places you repeatedly correct into a reviewable principle candidate \u2014 you see the evidence first, then decide whether to adopt it. Without parsing conversations, PD can only govern tools and cannot learn your corrections.\n\n**How much is kept, and for how long?**\nPer session, only the most recent 32 visible messages are kept, for at most 7 days (expired first, deleted first). Only when a problem is formally established as a pain are its preceding 12 messages + the trigger point + the assistant's next complete reply promoted into long-term governance evidence, managed under the existing governance data lifecycle. Saved tool evidence passes through the existing sensitive-field and common-secret-format redaction before being written; but note: if a secret looks like ordinary text, no filter can recognize it \u2014 please do not let PD observe sessions containing such content.\n\n**Does data leave this machine?**\nObservation data itself lives only in the local workspace. Conversation fragments promoted into governance evidence are sent to your configured LLM API for diagnosis, exactly like the existing diagnosis flow \u2014 this is the only egress path, consistent with current behavior on OpenClaw. Product telemetry contains no message content and no session identifiers.\n\n**How do I turn it off? Anytime.**\nOnce off, PD immediately stops reading transcripts (not \"read then discard\" \u2014 the transcript is never opened at all). Existing principle injection, tool interception, and existing tool evidence are completely unaffected. Saved content expires naturally per the limits above; promoted evidence can only be deleted explicitly by you through the existing governance cleanup commands. Uninstall never silently deletes any evidence.\n\n**Default state?**\nOff by default. It takes effect only after you explicitly choose to enable it after seeing this disclosure; upgrading PD never enables it for you.";
|
|
23
|
+
export declare function getCodexIngestionDisclosureText(language: CodexIngestionDisclosureLanguage): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex conversation-ingestion consent disclosure — G2A frozen text (Slice D).
|
|
3
|
+
*
|
|
4
|
+
* The Chinese text below is copied VERBATIM from the Owner-approved decision
|
|
5
|
+
* package:
|
|
6
|
+
* docs/superpowers/specs/2026-08-28-codex-governance-closure-g0-g2a-decision.md
|
|
7
|
+
* § "Frozen consent disclosure text (setup will show this verbatim)"
|
|
8
|
+
*
|
|
9
|
+
* That document is the language SSoT. The frozen text "must not be weakened
|
|
10
|
+
* during implementation without a new Owner decision" — the guard test
|
|
11
|
+
* (codex-disclosure-g2a-guard.test.ts) re-extracts the frozen section from
|
|
12
|
+
* the document and asserts byte equality with this constant, so any drift
|
|
13
|
+
* here fails CI.
|
|
14
|
+
*
|
|
15
|
+
* The English rendering states the same facts; per the G2A binding note it is
|
|
16
|
+
* produced at setup implementation time from the frozen Chinese text. Setup
|
|
17
|
+
* surfaces always present the Chinese SSoT first.
|
|
18
|
+
*/
|
|
19
|
+
export const CODEX_INGESTION_DISCLOSURE_VERSION = 'g2a-2026-08-28';
|
|
20
|
+
export const CODEX_INGESTION_DISCLOSURE_ZH = `### Principles Disciple — 对话观察与治理闭环(Codex)
|
|
21
|
+
|
|
22
|
+
**开启后 PD 会读取什么?**
|
|
23
|
+
读取本工作区内 Codex 明确提供给 PD 的会话记录(transcript)。进入治理观察的只有可见内容:你发出的消息、助手的可见回复、以及工具调用的名称/输入/结果(用于识别反复出现的问题)。记录中同时存在的隐藏思考过程(Codex 以加密形式保存)、系统/开发者提示词、宿主注入的环境上下文,会在解析时被识别并丢弃——不会被保存、不会进入日志、不会发送给诊断模型。
|
|
24
|
+
|
|
25
|
+
**为什么读取?**
|
|
26
|
+
为了让 PD 在 Codex 上完成它承诺的闭环:把你反复纠正的地方变成一条可审查的原则候选——你需要先看到证据,再决定是否采纳。不解析对话,PD 就只能管工具,学不到纠正。
|
|
27
|
+
|
|
28
|
+
**保存多少、多久?**
|
|
29
|
+
每个会话只保留最近 32 条可见消息、最多 7 天(先到期先删除)。当某个问题被正式立为 pain 时,才会把它的前 12 条消息 + 触发点 + 助手的下一条完整回复升级为长期治理证据,按现有治理数据的生命周期管理。保存的工具证据在落盘前会经过既有的敏感字段与常见密钥格式脱敏;但请注意:如果一段密钥看起来就像普通文字,任何过滤器都无法识别它——请不要让 PD 观察包含此类内容的会话。
|
|
30
|
+
|
|
31
|
+
**数据会离开本机吗?**
|
|
32
|
+
观察数据本身只存在本机工作区。被升级为治理证据的对话片段,会像现有诊断流程一样发送给你配置的 LLM API 做诊断——这是唯一的外发路径,与 OpenClaw 上的现有行为一致。产品遥测不包含任何消息内容或会话标识。
|
|
33
|
+
|
|
34
|
+
**如何关闭?随时。**
|
|
35
|
+
关闭后 PD 立即停止读取 transcript(不是"读了再丢弃",而是根本不打开)。已有的原则注入、工具拦截、既有工具证据完全不受影响。已保存的内容按上述期限自然过期;升级过的证据只能由你通过既有治理清理命令显式删除。卸载不会静默删除任何证据。
|
|
36
|
+
|
|
37
|
+
**默认状态?**
|
|
38
|
+
默认关闭。只有你在看到本说明后明确选择开启才会生效;升级 PD 永远不会替你开启。`;
|
|
39
|
+
export const CODEX_INGESTION_DISCLOSURE_EN = `### Principles Disciple — Conversation Observation & Governance Closure (Codex)
|
|
40
|
+
|
|
41
|
+
**What does PD read once enabled?**
|
|
42
|
+
PD reads the Codex session transcripts that Codex explicitly provides to PD inside this workspace. Only visible content enters governance observation: the messages you send, the assistant's visible replies, and the names/inputs/results of tool calls (used to recognize recurring problems). The hidden reasoning process that Codex stores in encrypted form, system/developer prompts, and host-injected environment context present in the same records are recognized and discarded during parsing — never saved, never written to logs, never sent to the diagnosis model.
|
|
43
|
+
|
|
44
|
+
**Why read at all?**
|
|
45
|
+
So that PD can deliver on its promised closed loop on Codex: turning the places you repeatedly correct into a reviewable principle candidate — you see the evidence first, then decide whether to adopt it. Without parsing conversations, PD can only govern tools and cannot learn your corrections.
|
|
46
|
+
|
|
47
|
+
**How much is kept, and for how long?**
|
|
48
|
+
Per session, only the most recent 32 visible messages are kept, for at most 7 days (expired first, deleted first). Only when a problem is formally established as a pain are its preceding 12 messages + the trigger point + the assistant's next complete reply promoted into long-term governance evidence, managed under the existing governance data lifecycle. Saved tool evidence passes through the existing sensitive-field and common-secret-format redaction before being written; but note: if a secret looks like ordinary text, no filter can recognize it — please do not let PD observe sessions containing such content.
|
|
49
|
+
|
|
50
|
+
**Does data leave this machine?**
|
|
51
|
+
Observation data itself lives only in the local workspace. Conversation fragments promoted into governance evidence are sent to your configured LLM API for diagnosis, exactly like the existing diagnosis flow — this is the only egress path, consistent with current behavior on OpenClaw. Product telemetry contains no message content and no session identifiers.
|
|
52
|
+
|
|
53
|
+
**How do I turn it off? Anytime.**
|
|
54
|
+
Once off, PD immediately stops reading transcripts (not "read then discard" — the transcript is never opened at all). Existing principle injection, tool interception, and existing tool evidence are completely unaffected. Saved content expires naturally per the limits above; promoted evidence can only be deleted explicitly by you through the existing governance cleanup commands. Uninstall never silently deletes any evidence.
|
|
55
|
+
|
|
56
|
+
**Default state?**
|
|
57
|
+
Off by default. It takes effect only after you explicitly choose to enable it after seeing this disclosure; upgrading PD never enables it for you.`;
|
|
58
|
+
export function getCodexIngestionDisclosureText(language) {
|
|
59
|
+
return language === 'en' ? CODEX_INGESTION_DISCLOSURE_EN : CODEX_INGESTION_DISCLOSURE_ZH;
|
|
60
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex conversation-ingestion consent store — Codex Governance Closure
|
|
3
|
+
* Slice D (SPEC rev 2 §17; G2A frozen disclosure).
|
|
4
|
+
*
|
|
5
|
+
* Persists the Owner's recorded consent decision for enabling
|
|
6
|
+
* `codex_conversation_ingestion` in ONE workspace to
|
|
7
|
+
* `<workspace>/.pd/codex-ingestion-consent.json`.
|
|
8
|
+
*
|
|
9
|
+
* Authority model (P4): the FEATURE FLAG stays the runtime authority for
|
|
10
|
+
* whether ingestion runs (the hook gate reads only the flag — consent never
|
|
11
|
+
* sits on the hot path). This store is the GOVERNANCE RECORD that the flag
|
|
12
|
+
* may only be enabled THROUGH the disclosed consent flow (G2A); health uses
|
|
13
|
+
* it to report consent state and to flag `flag_on_without_grant` as a
|
|
14
|
+
* governance warning. Declining must never flip the flag off by side effect
|
|
15
|
+
* — the setup flow owns that ordering, not this store.
|
|
16
|
+
*
|
|
17
|
+
* Scope: per-workspace (the flag and the transcripts it gates are
|
|
18
|
+
* workspace-scoped). This file is consent control state — it never contains
|
|
19
|
+
* any captured conversation text (SPEC §15 "consent state without displaying
|
|
20
|
+
* captured text").
|
|
21
|
+
*
|
|
22
|
+
* Patterns mirror the product-telemetry consent store: missing file is the
|
|
23
|
+
* normal never-asked case; a malformed file fails loud (rc-3/rc-9) because
|
|
24
|
+
* silently treating it as "never asked" could re-prompt against a recorded
|
|
25
|
+
* Owner decision; writes are atomic (temp + rename); unknown/malformed
|
|
26
|
+
* fields are rejected via guards, never `as` (rc-1/rc-2/rc-5).
|
|
27
|
+
*/
|
|
28
|
+
export declare const CODEX_INGESTION_CONSENT_FILENAME = "codex-ingestion-consent.json";
|
|
29
|
+
export declare const CODEX_INGESTION_CONSENT_SCHEMA_VERSION = "2";
|
|
30
|
+
/**
|
|
31
|
+
* Consent transition states (review round 2, governance consistency):
|
|
32
|
+
*
|
|
33
|
+
* absent ──(disclosure shown, explicit decision)──▶ pending
|
|
34
|
+
* pending ──(flag enabled successfully)───────────▶ granted
|
|
35
|
+
* pending ──(declined / flag regularized off)─────▶ revoked
|
|
36
|
+
* pending ──(flag enable failed)──────────────────▶ failed (with reason)
|
|
37
|
+
* granted ──(explicit decline)────────────────────▶ revoked
|
|
38
|
+
*
|
|
39
|
+
* `granted` is only ever recorded AFTER the runtime flag activation has
|
|
40
|
+
* succeeded — consent can never become granted before activation, and a
|
|
41
|
+
* failed activation leaves an EXPLAINED `failed` state (reason + nextAction),
|
|
42
|
+
* never a silent granted/disabled mismatch. The flag remains the runtime
|
|
43
|
+
* authority; this record is the governance state machine that makes every
|
|
44
|
+
* observed flag/consent combination interpretable.
|
|
45
|
+
*/
|
|
46
|
+
export type CodexIngestionConsentDecision = 'pending' | 'granted' | 'revoked' | 'failed';
|
|
47
|
+
export type CodexIngestionConsentDecidedVia = 'pd_codex_setup' | 'codex_plugin_setup';
|
|
48
|
+
export interface CodexIngestionConsentRecord {
|
|
49
|
+
decision: CodexIngestionConsentDecision;
|
|
50
|
+
disclosureVersion: string;
|
|
51
|
+
decidedAt: string;
|
|
52
|
+
decidedVia: CodexIngestionConsentDecidedVia;
|
|
53
|
+
/** Populated when decision='failed': why the flag activation did not land. */
|
|
54
|
+
failureReason?: string;
|
|
55
|
+
schemaVersion: string;
|
|
56
|
+
}
|
|
57
|
+
export type CodexIngestionConsentRead = {
|
|
58
|
+
ok: true;
|
|
59
|
+
existed: boolean;
|
|
60
|
+
record: CodexIngestionConsentRecord | null;
|
|
61
|
+
} | {
|
|
62
|
+
ok: false;
|
|
63
|
+
reason: string;
|
|
64
|
+
nextAction: string;
|
|
65
|
+
};
|
|
66
|
+
export type CodexIngestionConsentWrite = {
|
|
67
|
+
ok: true;
|
|
68
|
+
record: CodexIngestionConsentRecord;
|
|
69
|
+
} | {
|
|
70
|
+
ok: false;
|
|
71
|
+
reason: string;
|
|
72
|
+
nextAction: string;
|
|
73
|
+
};
|
|
74
|
+
/** Health-surface consent state (SPEC §15). No captured text, ever. */
|
|
75
|
+
export type CodexIngestionConsentState = 'granted' | 'revoked' | 'pending' | 'failed' | 'not_present' | 'flag_on_without_grant';
|
|
76
|
+
export declare function getCodexIngestionConsentPath(workspaceDir: string): string;
|
|
77
|
+
/**
|
|
78
|
+
* Read the consent record. ENOENT is the normal never-asked case
|
|
79
|
+
* (existed=false, record=null). Anything unreadable/malformed fails loud —
|
|
80
|
+
* degrading to "never asked" could re-prompt or re-enable against the
|
|
81
|
+
* Owner's recorded decision.
|
|
82
|
+
*/
|
|
83
|
+
export declare function readCodexIngestionConsent(workspaceDir: string): CodexIngestionConsentRead;
|
|
84
|
+
/**
|
|
85
|
+
* Record an explicit consent decision made AFTER the disclosure was
|
|
86
|
+
* presented (the setup flow presents the frozen text before calling this).
|
|
87
|
+
* The flag itself is intentionally untouched — enabling it is the setup
|
|
88
|
+
* flow's explicit, ordered step.
|
|
89
|
+
*/
|
|
90
|
+
export declare function recordCodexIngestionConsent(workspaceDir: string, input: {
|
|
91
|
+
decision: CodexIngestionConsentDecision;
|
|
92
|
+
decidedVia: CodexIngestionConsentDecidedVia;
|
|
93
|
+
decidedAt?: string;
|
|
94
|
+
failureReason?: string;
|
|
95
|
+
}): CodexIngestionConsentWrite;
|
|
96
|
+
/**
|
|
97
|
+
* Combine the consent record with the ingestion flag into the health-surface
|
|
98
|
+
* state (SPEC §15). Every flag×record combination maps to exactly one
|
|
99
|
+
* interpretable state — none of them reads as silently healthy:
|
|
100
|
+
*
|
|
101
|
+
* record granted + flag any → 'granted' (activation succeeded)
|
|
102
|
+
* record revoked + flag any → 'revoked' (Owner said no; flag-off
|
|
103
|
+
* path also regularizes the flag)
|
|
104
|
+
* record failed + flag any → 'failed' (activation did not land;
|
|
105
|
+
* failureReason explains why)
|
|
106
|
+
* record pending + flag off → 'pending' (decision recorded, activation not yet applied)
|
|
107
|
+
* record pending + flag on → 'pending' (activation applied, terminal write pending)
|
|
108
|
+
* no record + flag on → 'flag_on_without_grant' (governance warning)
|
|
109
|
+
* no record + flag off → 'not_present'
|
|
110
|
+
*/
|
|
111
|
+
export declare function deriveCodexIngestionConsentState(record: CodexIngestionConsentRecord | null, ingestionFlagEnabled: boolean): CodexIngestionConsentState;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex conversation-ingestion consent store — Codex Governance Closure
|
|
3
|
+
* Slice D (SPEC rev 2 §17; G2A frozen disclosure).
|
|
4
|
+
*
|
|
5
|
+
* Persists the Owner's recorded consent decision for enabling
|
|
6
|
+
* `codex_conversation_ingestion` in ONE workspace to
|
|
7
|
+
* `<workspace>/.pd/codex-ingestion-consent.json`.
|
|
8
|
+
*
|
|
9
|
+
* Authority model (P4): the FEATURE FLAG stays the runtime authority for
|
|
10
|
+
* whether ingestion runs (the hook gate reads only the flag — consent never
|
|
11
|
+
* sits on the hot path). This store is the GOVERNANCE RECORD that the flag
|
|
12
|
+
* may only be enabled THROUGH the disclosed consent flow (G2A); health uses
|
|
13
|
+
* it to report consent state and to flag `flag_on_without_grant` as a
|
|
14
|
+
* governance warning. Declining must never flip the flag off by side effect
|
|
15
|
+
* — the setup flow owns that ordering, not this store.
|
|
16
|
+
*
|
|
17
|
+
* Scope: per-workspace (the flag and the transcripts it gates are
|
|
18
|
+
* workspace-scoped). This file is consent control state — it never contains
|
|
19
|
+
* any captured conversation text (SPEC §15 "consent state without displaying
|
|
20
|
+
* captured text").
|
|
21
|
+
*
|
|
22
|
+
* Patterns mirror the product-telemetry consent store: missing file is the
|
|
23
|
+
* normal never-asked case; a malformed file fails loud (rc-3/rc-9) because
|
|
24
|
+
* silently treating it as "never asked" could re-prompt against a recorded
|
|
25
|
+
* Owner decision; writes are atomic (temp + rename); unknown/malformed
|
|
26
|
+
* fields are rejected via guards, never `as` (rc-1/rc-2/rc-5).
|
|
27
|
+
*/
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { CODEX_INGESTION_DISCLOSURE_VERSION } from './codex-disclosure.js';
|
|
31
|
+
export const CODEX_INGESTION_CONSENT_FILENAME = 'codex-ingestion-consent.json';
|
|
32
|
+
export const CODEX_INGESTION_CONSENT_SCHEMA_VERSION = '2';
|
|
33
|
+
export function getCodexIngestionConsentPath(workspaceDir) {
|
|
34
|
+
return path.join(path.resolve(workspaceDir), '.pd', CODEX_INGESTION_CONSENT_FILENAME);
|
|
35
|
+
}
|
|
36
|
+
function isDecision(value) {
|
|
37
|
+
return value === 'granted' || value === 'pending' || value === 'revoked' || value === 'failed';
|
|
38
|
+
}
|
|
39
|
+
function isDecidedVia(value) {
|
|
40
|
+
return value === 'pd_codex_setup' || value === 'codex_plugin_setup';
|
|
41
|
+
}
|
|
42
|
+
function isNonShortString(value, max) {
|
|
43
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max;
|
|
44
|
+
}
|
|
45
|
+
function isIsoTimestamp(value) {
|
|
46
|
+
return isNonShortString(value, 40) && !Number.isNaN(Date.parse(value));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Read the consent record. ENOENT is the normal never-asked case
|
|
50
|
+
* (existed=false, record=null). Anything unreadable/malformed fails loud —
|
|
51
|
+
* degrading to "never asked" could re-prompt or re-enable against the
|
|
52
|
+
* Owner's recorded decision.
|
|
53
|
+
*/
|
|
54
|
+
export function readCodexIngestionConsent(workspaceDir) {
|
|
55
|
+
const filePath = getCodexIngestionConsentPath(workspaceDir);
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
const codeValue = typeof error === 'object' && error !== null && Object.hasOwn(error, 'code')
|
|
62
|
+
? error.code
|
|
63
|
+
: undefined;
|
|
64
|
+
if (codeValue === 'ENOENT') {
|
|
65
|
+
return { ok: true, existed: false, record: null };
|
|
66
|
+
}
|
|
67
|
+
const code = typeof codeValue === 'string' ? codeValue : String(error);
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
reason: `codex_ingestion_consent_unreadable: ${code.slice(0, 120)}`,
|
|
71
|
+
nextAction: `Check permissions on ${filePath}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(raw);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
reason: 'codex_ingestion_consent_malformed_json',
|
|
82
|
+
nextAction: `Fix or delete ${filePath} (delete = consent returns to not_present; the ingestion flag itself is unchanged)`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: 'codex_ingestion_consent_malformed_shape',
|
|
89
|
+
nextAction: `Fix or delete ${filePath} (delete = consent returns to not_present; the ingestion flag itself is unchanged)`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const obj = parsed;
|
|
93
|
+
const allowedKeys = ['decision', 'disclosureVersion', 'decidedAt', 'decidedVia', 'failureReason', 'schemaVersion'];
|
|
94
|
+
const errors = [];
|
|
95
|
+
for (const key of Object.keys(obj)) {
|
|
96
|
+
if (!allowedKeys.includes(key))
|
|
97
|
+
errors.push(`unknown field '${key}'`);
|
|
98
|
+
}
|
|
99
|
+
if (!isDecision(obj.decision))
|
|
100
|
+
errors.push('decision must be pending|granted|revoked|failed');
|
|
101
|
+
if (!isNonShortString(obj.disclosureVersion, 40))
|
|
102
|
+
errors.push('disclosureVersion must be a short non-empty string');
|
|
103
|
+
if (!isIsoTimestamp(obj.decidedAt))
|
|
104
|
+
errors.push('decidedAt must be a parseable ISO-8601 string');
|
|
105
|
+
if (!isDecidedVia(obj.decidedVia))
|
|
106
|
+
errors.push('decidedVia must be pd_codex_setup|codex_plugin_setup');
|
|
107
|
+
if (obj.failureReason !== undefined && !isNonShortString(obj.failureReason, 200)) {
|
|
108
|
+
errors.push('failureReason must be a non-empty string (≤200 chars) when present');
|
|
109
|
+
}
|
|
110
|
+
if (obj.schemaVersion !== CODEX_INGESTION_CONSENT_SCHEMA_VERSION) {
|
|
111
|
+
errors.push(`schemaVersion must be '${CODEX_INGESTION_CONSENT_SCHEMA_VERSION}'`);
|
|
112
|
+
}
|
|
113
|
+
if (errors.length > 0) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
reason: `codex_ingestion_consent_malformed: ${errors.join('; ')}`,
|
|
117
|
+
nextAction: `Fix or delete ${filePath} (delete = consent returns to not_present; the ingestion flag itself is unchanged)`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// Post-validation reconstruction from guard-narrowed fields — no `as` on
|
|
121
|
+
// the untrusted parsed object (rc-2). The errors check above proves every
|
|
122
|
+
// guard passes; the re-invoked guards here satisfy the type system the
|
|
123
|
+
// same way the telemetry consent store does.
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
existed: true,
|
|
127
|
+
record: {
|
|
128
|
+
decision: isDecision(obj.decision) ? obj.decision : 'failed',
|
|
129
|
+
disclosureVersion: isNonShortString(obj.disclosureVersion, 40) ? obj.disclosureVersion : '',
|
|
130
|
+
decidedAt: isIsoTimestamp(obj.decidedAt) ? obj.decidedAt : '',
|
|
131
|
+
decidedVia: isDecidedVia(obj.decidedVia) ? obj.decidedVia : 'pd_codex_setup',
|
|
132
|
+
...(isNonShortString(obj.failureReason, 200) && obj.failureReason !== undefined ? { failureReason: obj.failureReason } : {}),
|
|
133
|
+
schemaVersion: CODEX_INGESTION_CONSENT_SCHEMA_VERSION,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Record an explicit consent decision made AFTER the disclosure was
|
|
139
|
+
* presented (the setup flow presents the frozen text before calling this).
|
|
140
|
+
* The flag itself is intentionally untouched — enabling it is the setup
|
|
141
|
+
* flow's explicit, ordered step.
|
|
142
|
+
*/
|
|
143
|
+
export function recordCodexIngestionConsent(workspaceDir, input) {
|
|
144
|
+
// Write-side validation mirrors the read-side guards (review round 3): a
|
|
145
|
+
// record this writer produces must never be rejected by its own reader.
|
|
146
|
+
const errors = [];
|
|
147
|
+
if (input.decidedAt !== undefined && !isIsoTimestamp(input.decidedAt))
|
|
148
|
+
errors.push('decidedAt must be a parseable ISO-8601 string when provided');
|
|
149
|
+
if (input.decision === 'failed' && (input.failureReason === undefined || input.failureReason.trim().length === 0)) {
|
|
150
|
+
errors.push('decision=failed requires a non-empty failureReason');
|
|
151
|
+
}
|
|
152
|
+
if (input.failureReason !== undefined && input.decision !== 'failed' && input.failureReason.trim().length === 0) {
|
|
153
|
+
errors.push('failureReason must be non-empty when provided');
|
|
154
|
+
}
|
|
155
|
+
if (errors.length > 0) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
reason: 'codex_ingestion_consent_input_invalid: ' + errors.join('; '),
|
|
159
|
+
nextAction: 'Fix the recordCodexIngestionConsent arguments at the call site.',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const filePath = getCodexIngestionConsentPath(workspaceDir);
|
|
163
|
+
const record = {
|
|
164
|
+
decision: input.decision,
|
|
165
|
+
disclosureVersion: CODEX_INGESTION_DISCLOSURE_VERSION,
|
|
166
|
+
decidedAt: input.decidedAt ?? new Date().toISOString(),
|
|
167
|
+
decidedVia: input.decidedVia,
|
|
168
|
+
...(input.failureReason !== undefined ? { failureReason: input.failureReason.slice(0, 200) } : {}),
|
|
169
|
+
schemaVersion: CODEX_INGESTION_CONSENT_SCHEMA_VERSION,
|
|
170
|
+
};
|
|
171
|
+
const dir = path.dirname(filePath);
|
|
172
|
+
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
173
|
+
try {
|
|
174
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
175
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(record, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
176
|
+
fs.renameSync(tmpPath, filePath);
|
|
177
|
+
return { ok: true, record };
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
181
|
+
try {
|
|
182
|
+
fs.rmSync(tmpPath, { force: true });
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// best-effort cleanup; the write failure below is the loud signal
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
reason: `codex_ingestion_consent_write_failed: ${message.slice(0, 200)}`,
|
|
190
|
+
nextAction: `Check permissions on ${dir}`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Combine the consent record with the ingestion flag into the health-surface
|
|
196
|
+
* state (SPEC §15). Every flag×record combination maps to exactly one
|
|
197
|
+
* interpretable state — none of them reads as silently healthy:
|
|
198
|
+
*
|
|
199
|
+
* record granted + flag any → 'granted' (activation succeeded)
|
|
200
|
+
* record revoked + flag any → 'revoked' (Owner said no; flag-off
|
|
201
|
+
* path also regularizes the flag)
|
|
202
|
+
* record failed + flag any → 'failed' (activation did not land;
|
|
203
|
+
* failureReason explains why)
|
|
204
|
+
* record pending + flag off → 'pending' (decision recorded, activation not yet applied)
|
|
205
|
+
* record pending + flag on → 'pending' (activation applied, terminal write pending)
|
|
206
|
+
* no record + flag on → 'flag_on_without_grant' (governance warning)
|
|
207
|
+
* no record + flag off → 'not_present'
|
|
208
|
+
*/
|
|
209
|
+
export function deriveCodexIngestionConsentState(record, ingestionFlagEnabled) {
|
|
210
|
+
if (record === null) {
|
|
211
|
+
return ingestionFlagEnabled ? 'flag_on_without_grant' : 'not_present';
|
|
212
|
+
}
|
|
213
|
+
switch (record.decision) {
|
|
214
|
+
case 'granted':
|
|
215
|
+
return 'granted';
|
|
216
|
+
case 'revoked':
|
|
217
|
+
return 'revoked';
|
|
218
|
+
case 'failed':
|
|
219
|
+
return 'failed';
|
|
220
|
+
case 'pending':
|
|
221
|
+
return 'pending';
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legacy Codex global-hook registration detection — Codex Governance Closure
|
|
3
|
+
* Slice D (PRI-625; SPEC rev 2 §17 legacy-installer retirement).
|
|
4
|
+
*
|
|
5
|
+
* ONE authority for the "is a PD-owned legacy global registration present"
|
|
6
|
+
* fact: the pure parser lives in @principles/core/host
|
|
7
|
+
* (parseLegacyCodexHooksRegistration); this wrapper is the FS edge for
|
|
8
|
+
* host-runtime consumers (health/setup surfaces). The retired installer uses
|
|
9
|
+
* the same core parser at its own edge.
|
|
10
|
+
*/
|
|
11
|
+
import { type LegacyCodexRegistration } from '@principles/core/host';
|
|
12
|
+
/**
|
|
13
|
+
* Detect a PD-owned legacy global hook registration in ~/.codex/hooks.json.
|
|
14
|
+
* Unreadable/malformed/absent ⇒ detected:false (nothing provably ours to
|
|
15
|
+
* migrate); the health surface reports the unreadable state separately via
|
|
16
|
+
* its own hooks.json presence check.
|
|
17
|
+
*/
|
|
18
|
+
export declare function detectLegacyCodexHookRegistration(hooksJsonPathOverride?: string): LegacyCodexRegistration;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legacy Codex global-hook registration detection — Codex Governance Closure
|
|
3
|
+
* Slice D (PRI-625; SPEC rev 2 §17 legacy-installer retirement).
|
|
4
|
+
*
|
|
5
|
+
* ONE authority for the "is a PD-owned legacy global registration present"
|
|
6
|
+
* fact: the pure parser lives in @principles/core/host
|
|
7
|
+
* (parseLegacyCodexHooksRegistration); this wrapper is the FS edge for
|
|
8
|
+
* host-runtime consumers (health/setup surfaces). The retired installer uses
|
|
9
|
+
* the same core parser at its own edge.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { parseLegacyCodexHooksRegistration } from '@principles/core/host';
|
|
15
|
+
function getCodexHooksJsonPath() {
|
|
16
|
+
return path.join(os.homedir(), '.codex', 'hooks.json');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Detect a PD-owned legacy global hook registration in ~/.codex/hooks.json.
|
|
20
|
+
* Unreadable/malformed/absent ⇒ detected:false (nothing provably ours to
|
|
21
|
+
* migrate); the health surface reports the unreadable state separately via
|
|
22
|
+
* its own hooks.json presence check.
|
|
23
|
+
*/
|
|
24
|
+
export function detectLegacyCodexHookRegistration(hooksJsonPathOverride) {
|
|
25
|
+
const hooksJsonPath = hooksJsonPathOverride ?? getCodexHooksJsonPath();
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { detected: false, legacyAsyncPostToolUse: false };
|
|
32
|
+
}
|
|
33
|
+
return parseLegacyCodexHooksRegistration(parsed);
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type CodexTranscriptLookup = {
|
|
2
|
+
ok: true;
|
|
3
|
+
transcriptPath: string; /** file size in bytes at lookup time (null when stat fails); lets health compute lag without its own stat call */
|
|
4
|
+
sizeBytes: number | null;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
reason: 'catch_up_rollout_identity_invalid' | 'catch_up_sessions_root_missing' | 'catch_up_transcript_missing' | 'catch_up_transcript_ambiguous' | 'catch_up_lookup_exhausted';
|
|
8
|
+
nextAction: string;
|
|
9
|
+
};
|
|
10
|
+
/** rollout-<timestamp>-<uuid>.jsonl — returns the rollout uuid, or null when the name is off-contract. */
|
|
11
|
+
export declare function parseRolloutFileName(fileName: string): string | null;
|
|
12
|
+
/**
|
|
13
|
+
* Resolve one previously-authenticated rollout identity to its transcript
|
|
14
|
+
* path by exact-uuid filename match under `<codexHome>/sessions`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function locateCodexTranscriptByRolloutIdentity(codexHome: string, rolloutIdentity: string): CodexTranscriptLookup;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex transcript locator — moved to host-runtime in Slice D (PRI-625):
|
|
3
|
+
* the §15 health surface must compute per-rollout lag with the SAME locator
|
|
4
|
+
* the catch-up path uses (one truth for "where is this rollout's transcript"),
|
|
5
|
+
* and host-runtime cannot import codex-adapter. The adapter re-exports it.
|
|
6
|
+
*
|
|
7
|
+
* The durable checkpoint stores only the rollout uuid (SPEC §18 scenario 9
|
|
8
|
+
* forbids raw paths in the DB), so catch-up must resolve a checkpointed
|
|
9
|
+
* rollout back to its transcript file. This is NOT session discovery: the
|
|
10
|
+
* lookup searches for the EXACT rollout uuid of a rollout the authenticated
|
|
11
|
+
* Workspace hook previously delivered (only hooks write checkpoints). It
|
|
12
|
+
* never guesses a "latest session", never returns a partial match, and
|
|
13
|
+
* refuses ambiguities (ADR-0020 §11.2 / SPEC §9).
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
/** Bounded walk: hard cap on visited directory entries so a pathological sessions tree cannot stall the worker. */
|
|
18
|
+
const MAX_LOOKUP_ENTRIES = 5000;
|
|
19
|
+
const ROLLOUT_IDENTITY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
20
|
+
const UUID_HEX = /^[0-9a-fA-F]+$/;
|
|
21
|
+
function isHex(value) {
|
|
22
|
+
return UUID_HEX.test(value);
|
|
23
|
+
}
|
|
24
|
+
/** rollout-<timestamp>-<uuid>.jsonl — returns the rollout uuid, or null when the name is off-contract. */
|
|
25
|
+
export function parseRolloutFileName(fileName) {
|
|
26
|
+
if (!fileName.startsWith('rollout-') || !fileName.endsWith('.jsonl'))
|
|
27
|
+
return null;
|
|
28
|
+
const stem = fileName.slice('rollout-'.length, -'.jsonl'.length);
|
|
29
|
+
const parts = stem.split('-');
|
|
30
|
+
if (parts.length < 6)
|
|
31
|
+
return null; // at least one timestamp segment + the five uuid groups
|
|
32
|
+
const [a, b, c, d, e] = parts.slice(-5);
|
|
33
|
+
if (a === undefined || b === undefined || c === undefined || d === undefined || e === undefined)
|
|
34
|
+
return null;
|
|
35
|
+
if (a.length !== 8 || b.length !== 4 || c.length !== 4 || d.length !== 4 || e.length !== 12)
|
|
36
|
+
return null;
|
|
37
|
+
if (!isHex(a) || !isHex(b) || !isHex(c) || !isHex(d) || !isHex(e))
|
|
38
|
+
return null;
|
|
39
|
+
return parts.slice(-5).join('-').toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve one previously-authenticated rollout identity to its transcript
|
|
43
|
+
* path by exact-uuid filename match under `<codexHome>/sessions`.
|
|
44
|
+
*/
|
|
45
|
+
export function locateCodexTranscriptByRolloutIdentity(codexHome, rolloutIdentity) {
|
|
46
|
+
if (!ROLLOUT_IDENTITY_PATTERN.test(rolloutIdentity)) {
|
|
47
|
+
return { ok: false, reason: 'catch_up_rollout_identity_invalid', nextAction: 'the checkpointed rollout identity is not a rollout uuid; inspect the workspace trajectory database.' };
|
|
48
|
+
}
|
|
49
|
+
const sessionsRoot = path.join(codexHome, 'sessions');
|
|
50
|
+
let rootStats;
|
|
51
|
+
try {
|
|
52
|
+
rootStats = fs.statSync(sessionsRoot);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { ok: false, reason: 'catch_up_sessions_root_missing', nextAction: 'the configured CODEX_HOME has no sessions root; verify the Codex home used by the hook and by catch-up matches.' };
|
|
56
|
+
}
|
|
57
|
+
if (!rootStats.isDirectory()) {
|
|
58
|
+
return { ok: false, reason: 'catch_up_sessions_root_missing', nextAction: 'the configured CODEX_HOME sessions path is not a directory; verify the Codex home configuration.' };
|
|
59
|
+
}
|
|
60
|
+
const matches = [];
|
|
61
|
+
let visited = 0;
|
|
62
|
+
const stack = [sessionsRoot];
|
|
63
|
+
while (stack.length > 0 && matches.length < 2) {
|
|
64
|
+
const dir = stack.pop();
|
|
65
|
+
if (dir === undefined)
|
|
66
|
+
break;
|
|
67
|
+
let entries;
|
|
68
|
+
try {
|
|
69
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
continue; // unreadable subtree — other subtrees may still hold the rollout
|
|
73
|
+
}
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
visited += 1;
|
|
76
|
+
if (visited > MAX_LOOKUP_ENTRIES) {
|
|
77
|
+
return { ok: false, reason: 'catch_up_lookup_exhausted', nextAction: 'the sessions tree exceeded the bounded catch-up lookup; keep CODEX_HOME/sessions pruned or catch up rollouts manually.' };
|
|
78
|
+
}
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
stack.push(path.join(dir, entry.name));
|
|
81
|
+
}
|
|
82
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
83
|
+
if (parseRolloutFileName(entry.name) === rolloutIdentity) {
|
|
84
|
+
matches.push(path.join(dir, entry.name));
|
|
85
|
+
if (matches.length >= 2)
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (matches.length === 0) {
|
|
92
|
+
return { ok: false, reason: 'catch_up_transcript_missing', nextAction: 'the checkpointed rollout has no transcript under the Codex sessions root (rotated or cleaned by Codex); its committed observations remain, the pending lag cannot be recovered.' };
|
|
93
|
+
}
|
|
94
|
+
if (matches.length > 1) {
|
|
95
|
+
return { ok: false, reason: 'catch_up_transcript_ambiguous', nextAction: 'multiple transcripts match the rollout identity; refuse to guess — inspect the Codex sessions tree.' };
|
|
96
|
+
}
|
|
97
|
+
const transcriptPath = matches[0];
|
|
98
|
+
let sizeBytes;
|
|
99
|
+
try {
|
|
100
|
+
sizeBytes = fs.statSync(transcriptPath).size;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
sizeBytes = null;
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, transcriptPath, sizeBytes };
|
|
106
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex worker status-mode semantics — moved to host-runtime in Slice D
|
|
3
|
+
* (PRI-625): the §15 health surface needs the SAME mode authority in the CLI,
|
|
4
|
+
* the Console, and the worker itself, and this evaluation only depends on
|
|
5
|
+
* host-neutral inputs (workspace directory, .pd/config.yaml flags, install
|
|
6
|
+
* manifest registration). codex-adapter re-exports it for compatibility.
|
|
7
|
+
*
|
|
8
|
+
* SPEC §15 worker mode, evaluated WITHOUT executing anything (no lease, no
|
|
9
|
+
* LLM, no transcript I/O). `manual_action_required` means no
|
|
10
|
+
* Companion-registered worker serves this workspace — the manual CLI path
|
|
11
|
+
* (catch-up / diagnose / run-once) is the recovery route. 'ready' here means
|
|
12
|
+
* "an automatic worker would run and hold the workspace task leases";
|
|
13
|
+
* live-worker liveness surfacing belongs to the Slice D health surface.
|
|
14
|
+
*/
|
|
15
|
+
export type CodexWorkerMode = 'ready' | 'manual_action_required' | 'paused' | 'degraded';
|
|
16
|
+
export interface CodexWorkerStatusEvaluation {
|
|
17
|
+
readonly mode: CodexWorkerMode;
|
|
18
|
+
readonly reason?: string;
|
|
19
|
+
readonly nextAction?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function computeCodexWorkerStatusMode(input: {
|
|
22
|
+
workspaceDir: string;
|
|
23
|
+
registeredInInstallManifest: boolean;
|
|
24
|
+
}): CodexWorkerStatusEvaluation;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex worker status-mode semantics — moved to host-runtime in Slice D
|
|
3
|
+
* (PRI-625): the §15 health surface needs the SAME mode authority in the CLI,
|
|
4
|
+
* the Console, and the worker itself, and this evaluation only depends on
|
|
5
|
+
* host-neutral inputs (workspace directory, .pd/config.yaml flags, install
|
|
6
|
+
* manifest registration). codex-adapter re-exports it for compatibility.
|
|
7
|
+
*
|
|
8
|
+
* SPEC §15 worker mode, evaluated WITHOUT executing anything (no lease, no
|
|
9
|
+
* LLM, no transcript I/O). `manual_action_required` means no
|
|
10
|
+
* Companion-registered worker serves this workspace — the manual CLI path
|
|
11
|
+
* (catch-up / diagnose / run-once) is the recovery route. 'ready' here means
|
|
12
|
+
* "an automatic worker would run and hold the workspace task leases";
|
|
13
|
+
* live-worker liveness surfacing belongs to the Slice D health surface.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import { loadPdConfigForPlugin } from './pd-config.js';
|
|
18
|
+
import { computeFeatureFlagsFromConfig } from '@principles/core/runtime-v2';
|
|
19
|
+
function directoryExists(dir) {
|
|
20
|
+
try {
|
|
21
|
+
return fs.statSync(dir).isDirectory();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function computeCodexWorkerStatusMode(input) {
|
|
28
|
+
const workspaceDir = path.resolve(input.workspaceDir);
|
|
29
|
+
if (!directoryExists(workspaceDir)) {
|
|
30
|
+
return { mode: 'degraded', reason: 'workspace_missing', nextAction: 'The workspace directory does not exist; restore it or remove it from the install manifest.' };
|
|
31
|
+
}
|
|
32
|
+
const config = loadPdConfigForPlugin(workspaceDir);
|
|
33
|
+
if (!config.ok) {
|
|
34
|
+
const [first] = config.errors;
|
|
35
|
+
return { mode: 'degraded', reason: `pd_config_invalid:${first?.reason ?? 'unknown'}`, nextAction: first?.nextAction ?? 'Repair .pd/config.yaml.' };
|
|
36
|
+
}
|
|
37
|
+
const { flags } = computeFeatureFlagsFromConfig(config.effective);
|
|
38
|
+
if (flags['host.codex']?.enabled !== true) {
|
|
39
|
+
return { mode: 'paused', reason: 'host.codex_disabled', nextAction: 'Set features.host.codex.enabled=true in the Workspace .pd/config.yaml to enable Codex PD behavior.' };
|
|
40
|
+
}
|
|
41
|
+
if (flags.internalization_auto_consumer?.enabled !== true) {
|
|
42
|
+
return { mode: 'paused', reason: 'internalization_auto_consumer_disabled', nextAction: 'Automatic execution is paused; manual commands remain available: pd diagnose, pd runtime internalization run-once.' };
|
|
43
|
+
}
|
|
44
|
+
if (!input.registeredInInstallManifest) {
|
|
45
|
+
return {
|
|
46
|
+
mode: 'manual_action_required',
|
|
47
|
+
reason: 'workspace_not_in_install_manifest',
|
|
48
|
+
nextAction: `No Companion worker is registered for this workspace. Manual path: pd codex ingest catch-up --workspace "${workspaceDir}", then pd diagnose / pd runtime internalization run-once.`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return { mode: 'ready' };
|
|
52
|
+
}
|
|
@@ -6,7 +6,8 @@ export declare const GOVERNANCE_PENDING_TAIL_STALE_MS: number;
|
|
|
6
6
|
export type GovernanceObservationKind = 'user_turn' | 'assistant_turn' | 'tool_call';
|
|
7
7
|
export type GovernanceObservationSource = 'live_hook' | 'transcript';
|
|
8
8
|
export type GovernanceObservationCompleteness = 'complete' | 'partial';
|
|
9
|
-
|
|
9
|
+
/** `quarantined` (Slice D §15): audited, permanently-invalid row; bodies dropped, metadata kept. */
|
|
10
|
+
export type GovernanceRetentionClass = 'operational' | 'promoted' | 'expired' | 'rolled_back' | 'quarantined';
|
|
10
11
|
export interface GovernanceObservationInput {
|
|
11
12
|
readonly hostKind: 'codex';
|
|
12
13
|
readonly rolloutIdentity: string;
|
|
@@ -193,4 +194,79 @@ export type ListGovernanceObservationsResult = {
|
|
|
193
194
|
} | Degradation;
|
|
194
195
|
export declare function listGovernanceObservations(input: ListGovernanceObservationsInput): ListGovernanceObservationsResult;
|
|
195
196
|
export declare function promoteGovernanceEvidence(input: PromoteGovernanceEvidenceInput): PromoteGovernanceEvidenceResult;
|
|
197
|
+
export interface QuarantineGovernanceObservationArgs {
|
|
198
|
+
readonly workspaceDir: string;
|
|
199
|
+
readonly hostKind: 'codex';
|
|
200
|
+
readonly rolloutIdentity: string;
|
|
201
|
+
/** governance_observations.id — `pd codex ingest quarantine --record <id>`. */
|
|
202
|
+
readonly recordId: number;
|
|
203
|
+
/** Why this record is permanently invalid (bounded, stored verbatim). */
|
|
204
|
+
readonly reason: string;
|
|
205
|
+
/** Who ran the quarantine (operator identity string, bounded). */
|
|
206
|
+
readonly operator: string;
|
|
207
|
+
/** false = dry run (default contract): report, never mutate. */
|
|
208
|
+
readonly confirm?: boolean;
|
|
209
|
+
readonly databaseFactory?: ObservationDatabaseFactory;
|
|
210
|
+
}
|
|
211
|
+
export interface QuarantinedRecordSummary {
|
|
212
|
+
readonly id: number;
|
|
213
|
+
readonly kind: string;
|
|
214
|
+
readonly logicalKey: string;
|
|
215
|
+
readonly observedAt: string;
|
|
216
|
+
readonly retentionClass: GovernanceRetentionClass;
|
|
217
|
+
/** SHA-256 over the row's stored content (hex) — computed the same way for dry runs. */
|
|
218
|
+
readonly digest: string;
|
|
219
|
+
/** Bounded neighbor description: `prev=<order|none>;next=<order|none>;record=<order|null>`. */
|
|
220
|
+
readonly gap: string;
|
|
221
|
+
}
|
|
222
|
+
export type QuarantineGovernanceObservationResult = {
|
|
223
|
+
ok: true;
|
|
224
|
+
dryRun: boolean;
|
|
225
|
+
alreadyQuarantined: boolean;
|
|
226
|
+
record: QuarantinedRecordSummary;
|
|
227
|
+
} | {
|
|
228
|
+
ok: false;
|
|
229
|
+
reason: string;
|
|
230
|
+
nextAction: string;
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* Audited quarantine for a permanently invalid governance observation
|
|
234
|
+
* (SPEC §15). Dry run is the contract default: without `confirm` the store
|
|
235
|
+
* reports what WOULD happen and mutates nothing. With `confirm`:
|
|
236
|
+
* - bodies (visible_text / sanitized_tool_facts_json) are dropped;
|
|
237
|
+
* - retention_class becomes `quarantined` (terminal class — never pruned,
|
|
238
|
+
* never promoted);
|
|
239
|
+
* - digest, reason, operator, timestamp, and the neighbor gap are recorded;
|
|
240
|
+
* - the Codex transcript is never read or touched (this function opens only
|
|
241
|
+
* the workspace trajectory.db).
|
|
242
|
+
* Promoted evidence is refused: Owner-decided evidence leaves only through
|
|
243
|
+
* the Owner governance cleanup commands.
|
|
244
|
+
*/
|
|
245
|
+
export declare function quarantineGovernanceObservation(args: QuarantineGovernanceObservationArgs): QuarantineGovernanceObservationResult;
|
|
246
|
+
export interface GovernanceObservationStats {
|
|
247
|
+
readonly operational: number;
|
|
248
|
+
readonly promoted: number;
|
|
249
|
+
readonly quarantined: number;
|
|
250
|
+
readonly terminalOther: number;
|
|
251
|
+
/** Oldest operational observed_at + retention window — when the next row ages out. */
|
|
252
|
+
readonly nextExpiryAt: string | null;
|
|
253
|
+
readonly lastObservationAt: string | null;
|
|
254
|
+
}
|
|
255
|
+
export type ReadGovernanceObservationStatsResult = {
|
|
256
|
+
ok: true;
|
|
257
|
+
stats: GovernanceObservationStats;
|
|
258
|
+
} | {
|
|
259
|
+
ok: false;
|
|
260
|
+
reason: string;
|
|
261
|
+
nextAction: string;
|
|
262
|
+
};
|
|
263
|
+
/**
|
|
264
|
+
* Bounded read-only counts for the §15 health surface. Unknown is reported
|
|
265
|
+
* as a structured degradation — never silently as zero (§15: unknown is not
|
|
266
|
+
* reported as healthy).
|
|
267
|
+
*/
|
|
268
|
+
export declare function readGovernanceObservationStats(args: {
|
|
269
|
+
workspaceDir: string;
|
|
270
|
+
databaseFactory?: ObservationDatabaseFactory;
|
|
271
|
+
}): ReadGovernanceObservationStatsResult;
|
|
196
272
|
export {};
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import fs from 'node:fs';
|
|
28
28
|
import path from 'node:path';
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
29
30
|
import Database from 'better-sqlite3';
|
|
30
31
|
import { sanitizeString, sanitizeValue } from '@principles/core/runtime-v2';
|
|
31
32
|
// ─── Retention policy constants (Owner-approved, SPEC rev 2 §11) ────────────
|
|
@@ -33,7 +34,7 @@ export const GOVERNANCE_RETENTION_MAX_TURNS = 32;
|
|
|
33
34
|
export const GOVERNANCE_RETENTION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
34
35
|
export const GOVERNANCE_PROMOTION_PRECEDING_TURNS = 12;
|
|
35
36
|
export const GOVERNANCE_PENDING_TAIL_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
36
|
-
const GOVERNANCE_OBSERVATION_SCHEMA_VERSION =
|
|
37
|
+
const GOVERNANCE_OBSERVATION_SCHEMA_VERSION = 3;
|
|
37
38
|
const MAX_TEXT_BOUND = 200; // matches MAX_EVIDENCE_VALUE_CHARS; guards stored identity fields
|
|
38
39
|
const MAX_JSON_COLUMN = 8_000;
|
|
39
40
|
function isRecord(value) {
|
|
@@ -154,11 +155,19 @@ const V2_ADDED_COLUMNS = [
|
|
|
154
155
|
{ table: 'governance_observations', name: 'source_order', type: 'INTEGER' },
|
|
155
156
|
{ table: 'governance_observations', name: 'source_ordinal', type: 'INTEGER' },
|
|
156
157
|
];
|
|
158
|
+
/** Column additions in v3 (Slice D §15 quarantine: audited recovery metadata). */
|
|
159
|
+
const V3_ADDED_COLUMNS = [
|
|
160
|
+
{ table: 'governance_observations', name: 'quarantined_at', type: 'TEXT' },
|
|
161
|
+
{ table: 'governance_observations', name: 'quarantine_reason', type: 'TEXT' },
|
|
162
|
+
{ table: 'governance_observations', name: 'quarantine_digest', type: 'TEXT' },
|
|
163
|
+
{ table: 'governance_observations', name: 'quarantine_operator', type: 'TEXT' },
|
|
164
|
+
{ table: 'governance_observations', name: 'quarantine_gap', type: 'TEXT' },
|
|
165
|
+
];
|
|
157
166
|
function ensureGovernanceObservationSchema(db) {
|
|
158
167
|
db.transaction(() => {
|
|
159
168
|
for (const statement of CREATE_STATEMENTS)
|
|
160
169
|
db.exec(statement);
|
|
161
|
-
for (const column of V2_ADDED_COLUMNS) {
|
|
170
|
+
for (const column of [...V2_ADDED_COLUMNS, ...V3_ADDED_COLUMNS]) {
|
|
162
171
|
try {
|
|
163
172
|
db.exec(`ALTER TABLE ${column.table} ADD COLUMN ${column.name} ${column.type}`);
|
|
164
173
|
}
|
|
@@ -460,7 +469,7 @@ export function ingestGovernanceObservations(input) {
|
|
|
460
469
|
})();
|
|
461
470
|
if (result === 'conflict') {
|
|
462
471
|
return {
|
|
463
|
-
ok: false, reason: 'logical_key_content_conflict', nextAction: 'the first committed content was preserved and the observation is marked partial; run
|
|
472
|
+
ok: false, reason: 'logical_key_content_conflict', nextAction: 'the first committed content was preserved and the observation is marked partial; run `pd codex ingest quarantine --workspace <path> --rollout <id> --record <id>` (audited, dry-run default) or re-ingest from a fresh rollout',
|
|
464
473
|
inserted, enriched, duplicates, checkpointCommitted: false, warnings: [`conflict:${conflictKey}`],
|
|
465
474
|
};
|
|
466
475
|
}
|
|
@@ -694,3 +703,179 @@ export function promoteGovernanceEvidence(input) {
|
|
|
694
703
|
close();
|
|
695
704
|
}
|
|
696
705
|
}
|
|
706
|
+
const QUARANTINE_REASON_MAX = 200;
|
|
707
|
+
const QUARANTINE_OPERATOR_MAX = 80;
|
|
708
|
+
function observationDigest(row) {
|
|
709
|
+
// Digest the row's stored content exactly as persisted (rc-8: hashing
|
|
710
|
+
// string columns only — never JSON.stringify of untrusted unknowns). The
|
|
711
|
+
// digest lets a future audit recognize the same record if it reappears.
|
|
712
|
+
const hash = createHash('sha256');
|
|
713
|
+
for (const column of ['rollout_identity', 'logical_key', 'transcript_record_key', 'kind', 'source', 'completeness', 'visible_text', 'sanitized_tool_facts_json', 'observed_at']) {
|
|
714
|
+
const value = row[column];
|
|
715
|
+
hash.update(`${column}=`);
|
|
716
|
+
hash.update(value === null || value === undefined ? '<null>' : String(value));
|
|
717
|
+
hash.update('\n');
|
|
718
|
+
}
|
|
719
|
+
return hash.digest('hex');
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Audited quarantine for a permanently invalid governance observation
|
|
723
|
+
* (SPEC §15). Dry run is the contract default: without `confirm` the store
|
|
724
|
+
* reports what WOULD happen and mutates nothing. With `confirm`:
|
|
725
|
+
* - bodies (visible_text / sanitized_tool_facts_json) are dropped;
|
|
726
|
+
* - retention_class becomes `quarantined` (terminal class — never pruned,
|
|
727
|
+
* never promoted);
|
|
728
|
+
* - digest, reason, operator, timestamp, and the neighbor gap are recorded;
|
|
729
|
+
* - the Codex transcript is never read or touched (this function opens only
|
|
730
|
+
* the workspace trajectory.db).
|
|
731
|
+
* Promoted evidence is refused: Owner-decided evidence leaves only through
|
|
732
|
+
* the Owner governance cleanup commands.
|
|
733
|
+
*/
|
|
734
|
+
export function quarantineGovernanceObservation(args) {
|
|
735
|
+
const { workspaceDir, hostKind, rolloutIdentity, recordId, reason, operator, confirm = false } = args;
|
|
736
|
+
if (!Number.isInteger(recordId) || recordId <= 0) {
|
|
737
|
+
return { ok: false, reason: 'record_id_invalid', nextAction: 'Pass the numeric governance_observations.id (--record <id>); run `pd codex ingest catch-up --json` output or inspect trajectory.db to find it.' };
|
|
738
|
+
}
|
|
739
|
+
if (typeof reason !== 'string' || reason.trim().length === 0 || reason.length > QUARANTINE_REASON_MAX) {
|
|
740
|
+
return { ok: false, reason: 'reason_required', nextAction: `Provide a non-empty reason (≤ ${QUARANTINE_REASON_MAX} chars) describing why the record is permanently invalid.` };
|
|
741
|
+
}
|
|
742
|
+
if (typeof operator !== 'string' || operator.trim().length === 0 || operator.length > QUARANTINE_OPERATOR_MAX) {
|
|
743
|
+
return { ok: false, reason: 'operator_required', nextAction: `Provide the operator identity (≤ ${QUARANTINE_OPERATOR_MAX} chars).` };
|
|
744
|
+
}
|
|
745
|
+
const opened = openStore(workspaceDir, args.databaseFactory);
|
|
746
|
+
if (!('db' in opened))
|
|
747
|
+
return opened;
|
|
748
|
+
const { db, close } = opened;
|
|
749
|
+
try {
|
|
750
|
+
const outcome = db.transaction(() => {
|
|
751
|
+
const rolloutRow = db.prepare('SELECT id FROM governance_rollouts WHERE host_kind = ? AND rollout_identity = ?').get(hostKind, rolloutIdentity);
|
|
752
|
+
if (!isRecord(rolloutRow)) {
|
|
753
|
+
return { ok: false, reason: 'rollout_not_found', nextAction: `No governed rollout '${rolloutIdentity}' for host '${hostKind}' in this workspace; check the identity (see \`pd codex ingest catch-up\` output).` };
|
|
754
|
+
}
|
|
755
|
+
const rolloutRowId = rowField(rolloutRow, 'id');
|
|
756
|
+
const row = db.prepare('SELECT * FROM governance_observations WHERE id = ? AND rollout_row_id = ?').get(recordId, rolloutRowId);
|
|
757
|
+
if (!isRecord(row)) {
|
|
758
|
+
return { ok: false, reason: 'record_not_found', nextAction: `Record ${recordId} does not exist in rollout '${rolloutIdentity}' of this workspace; verify the id.` };
|
|
759
|
+
}
|
|
760
|
+
const retentionClassRaw = String(rowField(row, 'retention_class'));
|
|
761
|
+
// rc-2: the DB column is untrusted — only known classes enter the typed
|
|
762
|
+
// summary; anything else reads as 'expired' semantics for reporting but
|
|
763
|
+
// still fails the quarantinable check below.
|
|
764
|
+
const retentionClass = retentionClassRaw === 'operational' || retentionClassRaw === 'promoted'
|
|
765
|
+
|| retentionClassRaw === 'quarantined' || retentionClassRaw === 'rolled_back'
|
|
766
|
+
? retentionClassRaw
|
|
767
|
+
: 'expired';
|
|
768
|
+
const quarantinedAt = rowField(row, 'quarantined_at');
|
|
769
|
+
const sourceOrder = typeof rowField(row, 'source_order') === 'number' ? rowField(row, 'source_order') : null;
|
|
770
|
+
const neighborQuery = (direction) => {
|
|
771
|
+
if (sourceOrder === null)
|
|
772
|
+
return 'none';
|
|
773
|
+
const comparison = direction === 'prev' ? '<' : '>';
|
|
774
|
+
const ordering = direction === 'prev' ? 'DESC' : 'ASC';
|
|
775
|
+
const neighbor = db.prepare(`SELECT source_order FROM governance_observations
|
|
776
|
+
WHERE rollout_row_id = ? AND source_order IS NOT NULL AND source_order ${comparison} ?
|
|
777
|
+
ORDER BY source_order ${ordering} LIMIT 1`).get(rolloutRowId, sourceOrder);
|
|
778
|
+
const value = isRecord(neighbor) ? rowField(neighbor, 'source_order') : undefined;
|
|
779
|
+
return typeof value === 'number' ? value : 'none';
|
|
780
|
+
};
|
|
781
|
+
const summary = {
|
|
782
|
+
id: recordId,
|
|
783
|
+
kind: String(rowField(row, 'kind')),
|
|
784
|
+
logicalKey: String(rowField(row, 'logical_key')),
|
|
785
|
+
observedAt: String(rowField(row, 'observed_at')),
|
|
786
|
+
retentionClass,
|
|
787
|
+
digest: observationDigest(row),
|
|
788
|
+
gap: `prev=${neighborQuery('prev')};next=${neighborQuery('next')};record=${sourceOrder === null ? 'null' : sourceOrder}`,
|
|
789
|
+
};
|
|
790
|
+
if (typeof quarantinedAt === 'string' && quarantinedAt.length > 0) {
|
|
791
|
+
// Already terminal: report the RECORDED audit digest/gap, not a
|
|
792
|
+
// re-hash of the row (bodies were dropped at quarantine time, so a
|
|
793
|
+
// fresh hash would silently differ from the audited one). dryRun
|
|
794
|
+
// reflects THIS invocation's intent — a dry-run call reports
|
|
795
|
+
// dryRun:true even on an already-quarantined row (review round 3).
|
|
796
|
+
const recordedDigest = rowField(row, 'quarantine_digest');
|
|
797
|
+
const recordedGap = rowField(row, 'quarantine_gap');
|
|
798
|
+
return {
|
|
799
|
+
ok: true, dryRun: !confirm, alreadyQuarantined: true,
|
|
800
|
+
record: {
|
|
801
|
+
...summary,
|
|
802
|
+
digest: typeof recordedDigest === 'string' && recordedDigest.length > 0 ? recordedDigest : summary.digest,
|
|
803
|
+
gap: typeof recordedGap === 'string' && recordedGap.length > 0 ? recordedGap : summary.gap,
|
|
804
|
+
},
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
if (retentionClass === 'promoted') {
|
|
808
|
+
return { ok: false, reason: 'record_is_promoted_evidence', nextAction: 'Promoted evidence is Owner-decided governance evidence; quarantine never touches it. Use the existing Owner governance cleanup commands if the evidence must be removed.' };
|
|
809
|
+
}
|
|
810
|
+
if (retentionClass !== 'operational') {
|
|
811
|
+
return { ok: false, reason: `record_not_quarantinable:${retentionClass}`, nextAction: 'Only operational records can be quarantined; expired and rolled-back rows are already terminal.' };
|
|
812
|
+
}
|
|
813
|
+
if (!confirm) {
|
|
814
|
+
return { ok: true, dryRun: true, alreadyQuarantined: false, record: summary };
|
|
815
|
+
}
|
|
816
|
+
const now = new Date().toISOString();
|
|
817
|
+
db.prepare(`UPDATE governance_observations
|
|
818
|
+
SET visible_text = NULL, sanitized_tool_facts_json = NULL, retention_class = 'quarantined',
|
|
819
|
+
quarantined_at = ?, quarantine_reason = ?, quarantine_digest = ?, quarantine_operator = ?, quarantine_gap = ?
|
|
820
|
+
WHERE id = ? AND retention_class = 'operational'`)
|
|
821
|
+
.run(now, reason, summary.digest, operator, summary.gap, recordId);
|
|
822
|
+
return { ok: true, dryRun: false, alreadyQuarantined: false, record: summary };
|
|
823
|
+
})();
|
|
824
|
+
return outcome;
|
|
825
|
+
}
|
|
826
|
+
catch (error) {
|
|
827
|
+
const detail = error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200);
|
|
828
|
+
return { ok: false, reason: `governance_quarantine_failed:${detail}`, nextAction: 'inspect the workspace trajectory database and retry; the transaction rolled back, nothing was partially mutated' };
|
|
829
|
+
}
|
|
830
|
+
finally {
|
|
831
|
+
close();
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Bounded read-only counts for the §15 health surface. Unknown is reported
|
|
836
|
+
* as a structured degradation — never silently as zero (§15: unknown is not
|
|
837
|
+
* reported as healthy).
|
|
838
|
+
*/
|
|
839
|
+
export function readGovernanceObservationStats(args) {
|
|
840
|
+
const opened = openStore(args.workspaceDir, args.databaseFactory);
|
|
841
|
+
if (!('db' in opened))
|
|
842
|
+
return opened;
|
|
843
|
+
const { db, close } = opened;
|
|
844
|
+
try {
|
|
845
|
+
const counts = db.prepare(`SELECT
|
|
846
|
+
SUM(CASE WHEN retention_class = 'operational' THEN 1 ELSE 0 END) AS operational,
|
|
847
|
+
SUM(CASE WHEN retention_class = 'promoted' THEN 1 ELSE 0 END) AS promoted,
|
|
848
|
+
SUM(CASE WHEN retention_class = 'quarantined' THEN 1 ELSE 0 END) AS quarantined,
|
|
849
|
+
SUM(CASE WHEN retention_class IN ('expired', 'rolled_back') THEN 1 ELSE 0 END) AS terminal_other,
|
|
850
|
+
MIN(CASE WHEN retention_class = 'operational' THEN observed_at END) AS oldest_operational,
|
|
851
|
+
MAX(observed_at) AS last_observation
|
|
852
|
+
FROM governance_observations`).get();
|
|
853
|
+
if (!isRecord(counts)) {
|
|
854
|
+
return { ok: false, reason: 'governance_stats_unavailable', nextAction: 'Inspect the workspace trajectory.db governance_observations table.' };
|
|
855
|
+
}
|
|
856
|
+
const numberOr = (value) => (typeof value === 'number' ? value : 0);
|
|
857
|
+
const stringOr = (value) => (typeof value === 'string' && value.length > 0 ? value : null);
|
|
858
|
+
const oldest = stringOr(own(counts, 'oldest_operational'));
|
|
859
|
+
const nextExpiry = oldest !== null && !Number.isNaN(Date.parse(oldest))
|
|
860
|
+
? new Date(Date.parse(oldest) + GOVERNANCE_RETENTION_MAX_AGE_MS).toISOString()
|
|
861
|
+
: null;
|
|
862
|
+
return {
|
|
863
|
+
ok: true,
|
|
864
|
+
stats: {
|
|
865
|
+
operational: numberOr(own(counts, 'operational')),
|
|
866
|
+
promoted: numberOr(own(counts, 'promoted')),
|
|
867
|
+
quarantined: numberOr(own(counts, 'quarantined')),
|
|
868
|
+
terminalOther: numberOr(own(counts, 'terminal_other')),
|
|
869
|
+
nextExpiryAt: nextExpiry,
|
|
870
|
+
lastObservationAt: stringOr(own(counts, 'last_observation')),
|
|
871
|
+
},
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
catch (error) {
|
|
875
|
+
const detail = error instanceof Error ? error.message.slice(0, 160) : String(error);
|
|
876
|
+
return { ok: false, reason: `governance_stats_failed:${detail}`, nextAction: 'Inspect the workspace trajectory.db; the health surface degraded without mutating anything.' };
|
|
877
|
+
}
|
|
878
|
+
finally {
|
|
879
|
+
close();
|
|
880
|
+
}
|
|
881
|
+
}
|
|
@@ -225,4 +225,30 @@ export interface ReconcileGovernanceContinuationResult {
|
|
|
225
225
|
* Not a background worker — Slice C's Companion worker and the CLI call this.
|
|
226
226
|
*/
|
|
227
227
|
export declare function reconcileGovernanceContinuation(input: ReconcileGovernanceContinuationInput): Promise<ReconcileGovernanceContinuationResult>;
|
|
228
|
+
export interface GovernanceAdmissionCounts {
|
|
229
|
+
readonly admitted: number;
|
|
230
|
+
/** Admitted pains with no Diagnostician task link — the §15 orphan count. */
|
|
231
|
+
readonly admittedWithoutTask: number;
|
|
232
|
+
readonly pendingTails: number;
|
|
233
|
+
readonly staleTails: number;
|
|
234
|
+
readonly completedTails: number;
|
|
235
|
+
readonly lastAdmissionAt: string | null;
|
|
236
|
+
}
|
|
237
|
+
export type ReadGovernanceAdmissionCountsResult = {
|
|
238
|
+
ok: true;
|
|
239
|
+
counts: GovernanceAdmissionCounts;
|
|
240
|
+
} | {
|
|
241
|
+
ok: false;
|
|
242
|
+
reason: string;
|
|
243
|
+
nextAction: string;
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* Bounded read-only counts for the §15 health surface: admitted-pain-without-
|
|
247
|
+
* task count and promotion-tail states. Read-only — reconciliation itself
|
|
248
|
+
* stays in `reconcileGovernanceContinuation`.
|
|
249
|
+
*/
|
|
250
|
+
export declare function readGovernanceAdmissionCounts(args: {
|
|
251
|
+
workspaceDir: string;
|
|
252
|
+
databaseFactory?: ObservationDatabaseFactory;
|
|
253
|
+
}): ReadGovernanceAdmissionCountsResult;
|
|
228
254
|
export {};
|
|
@@ -857,3 +857,50 @@ export async function reconcileGovernanceContinuation(input) {
|
|
|
857
857
|
close();
|
|
858
858
|
}
|
|
859
859
|
}
|
|
860
|
+
/**
|
|
861
|
+
* Bounded read-only counts for the §15 health surface: admitted-pain-without-
|
|
862
|
+
* task count and promotion-tail states. Read-only — reconciliation itself
|
|
863
|
+
* stays in `reconcileGovernanceContinuation`.
|
|
864
|
+
*/
|
|
865
|
+
export function readGovernanceAdmissionCounts(args) {
|
|
866
|
+
const opened = openAdmissionStore(args.workspaceDir, args.databaseFactory);
|
|
867
|
+
if (!('db' in opened))
|
|
868
|
+
return opened;
|
|
869
|
+
const { db, close } = opened;
|
|
870
|
+
try {
|
|
871
|
+
const admissions = db.prepare(`SELECT
|
|
872
|
+
SUM(CASE WHEN decision = 'admitted' THEN 1 ELSE 0 END) AS admitted,
|
|
873
|
+
SUM(CASE WHEN decision = 'admitted' AND diagnostician_task_id IS NULL THEN 1 ELSE 0 END) AS admitted_without_task,
|
|
874
|
+
MAX(created_at) AS last_admission
|
|
875
|
+
FROM governance_signal_admissions`).get();
|
|
876
|
+
if (!isRecord(admissions)) {
|
|
877
|
+
return { ok: false, reason: 'governance_admission_counts_unavailable', nextAction: 'Inspect the governance_signal_admissions table in the workspace trajectory.db.' };
|
|
878
|
+
}
|
|
879
|
+
const numberOr = (value) => (typeof value === 'number' ? value : 0);
|
|
880
|
+
const stringOr = (value) => (typeof value === 'string' && value.length > 0 ? value : null);
|
|
881
|
+
const tails = db.prepare(`SELECT
|
|
882
|
+
SUM(CASE WHEN state = 'pending' THEN 1 ELSE 0 END) AS pending,
|
|
883
|
+
SUM(CASE WHEN state = 'stale' THEN 1 ELSE 0 END) AS stale,
|
|
884
|
+
SUM(CASE WHEN state = 'completed' THEN 1 ELSE 0 END) AS completed
|
|
885
|
+
FROM governance_pending_promotion_tails`).get();
|
|
886
|
+
const tailRow = isRecord(tails) ? tails : {};
|
|
887
|
+
return {
|
|
888
|
+
ok: true,
|
|
889
|
+
counts: {
|
|
890
|
+
admitted: numberOr(own(admissions, 'admitted')),
|
|
891
|
+
admittedWithoutTask: numberOr(own(admissions, 'admitted_without_task')),
|
|
892
|
+
pendingTails: numberOr(own(tailRow, 'pending')),
|
|
893
|
+
staleTails: numberOr(own(tailRow, 'stale')),
|
|
894
|
+
completedTails: numberOr(own(tailRow, 'completed')),
|
|
895
|
+
lastAdmissionAt: stringOr(own(admissions, 'last_admission')),
|
|
896
|
+
},
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
catch (error) {
|
|
900
|
+
const detail = error instanceof Error ? error.message.slice(0, 160) : String(error);
|
|
901
|
+
return { ok: false, reason: `governance_admission_counts_failed:${detail}`, nextAction: 'Inspect the workspace trajectory.db; the health surface degraded without mutating anything.' };
|
|
902
|
+
}
|
|
903
|
+
finally {
|
|
904
|
+
close();
|
|
905
|
+
}
|
|
906
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -24,6 +24,11 @@ export * from './product-telemetry/eligibility.js';
|
|
|
24
24
|
export * from './product-telemetry/exporter.js';
|
|
25
25
|
export * from './product-telemetry/milestone-readers.js';
|
|
26
26
|
export * from './product-telemetry/service.js';
|
|
27
|
+
export * from './codex-disclosure.js';
|
|
28
|
+
export * from './codex-ingestion-consent.js';
|
|
29
|
+
export * from './codex-worker-status.js';
|
|
30
|
+
export * from './codex-legacy-registration.js';
|
|
31
|
+
export * from './codex-transcript-locate.js';
|
|
27
32
|
export declare const HOST_RUNTIME_ROUTES: readonly ['before_prompt_build', 'before_tool_call', 'after_tool_call'];
|
|
28
33
|
export type HostRuntimeRoute = (typeof HOST_RUNTIME_ROUTES)[number];
|
|
29
34
|
export type HostRuntimePort = (event: HostEvent) => HostEventResult | Promise<HostEventResult>;
|
package/dist/index.js
CHANGED
|
@@ -33,6 +33,20 @@ export * from './product-telemetry/eligibility.js';
|
|
|
33
33
|
export * from './product-telemetry/exporter.js';
|
|
34
34
|
export * from './product-telemetry/milestone-readers.js';
|
|
35
35
|
export * from './product-telemetry/service.js';
|
|
36
|
+
// PRI-625 Slice D: Codex conversation-ingestion consent — G2A frozen
|
|
37
|
+
// disclosure SSoT constant + workspace consent record (governance layer; the
|
|
38
|
+
// runtime gate remains the feature flag alone).
|
|
39
|
+
export * from './codex-disclosure.js';
|
|
40
|
+
export * from './codex-ingestion-consent.js';
|
|
41
|
+
// PRI-625 Slice D: ONE §15 worker-mode authority, moved from codex-adapter so
|
|
42
|
+
// the CLI, the Console, and the worker share the same semantics.
|
|
43
|
+
export * from './codex-worker-status.js';
|
|
44
|
+
// PRI-625 Slice D: ONE legacy-registration predicate (installer refusal,
|
|
45
|
+
// health dualRegistration, and future setup flows all read the same fact).
|
|
46
|
+
export * from './codex-legacy-registration.js';
|
|
47
|
+
// PRI-625 Slice D: transcript locator moved here so the §15 health service
|
|
48
|
+
// computes per-rollout lag with the SAME locator the catch-up path uses.
|
|
49
|
+
export * from './codex-transcript-locate.js';
|
|
36
50
|
export const HOST_RUNTIME_ROUTES = [
|
|
37
51
|
'before_prompt_build',
|
|
38
52
|
'before_tool_call',
|