@principles/workbuddy-adapter 0.2.13 → 0.2.15

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.
@@ -13,18 +13,21 @@ export declare class WorkBuddyDecoderError extends Error {
13
13
  * SPEC §4.2 failure normalization: WorkBuddy marks tool failure through
14
14
  * explicit shapes — `{is_error:true,...}` (Read failures), Bash
15
15
  * `{exitCode>0, error}` — while SUCCESS may be a bare STRING (rendered Read
16
- * output). A string carries no failure marker and is passed through
17
- * faithfully (the frozen fixtures show string === Read success; asserting
18
- * success-or-failure from string-ness alone would be fabrication).
16
+ * output).
19
17
  *
20
- * The shared production pain path (production-pain-evidence normalizeOutcome)
21
- * detects failure from an `error` field or a non-zero `exitCode` on the
22
- * envelope/result. `{is_error:true}` WITHOUT either (the read-iserror fixture
23
- * shape) would silently classify as success there — so this decoder wraps
24
- * every DETECTED failure in an explicit envelope
25
- * `{result: <original>, error: <explicit>}`, preserving the raw response
26
- * inside `result` while guaranteeing the failure is never missed. Missing
27
- * exitCode on a detected failure NEVER defaults to success.
18
+ * SPEC-RULECODE-HOST-PROMOTION-001 R2: on the PostToolUse channel the FROZEN
19
+ * host contract (Phase 0 baseline; see the channel table at the top of this
20
+ * file) is itself completion evidence — PostToolUseFailure is a SEPARATE
21
+ * registration and "PostToolUse never fires for that failure class". A
22
+ * response with NO failure marker on THIS channel is therefore a completed
23
+ * success, not an unconfirmed shape: the durable tool_calls row must record
24
+ * `success` so the WorkBuddy rule-context provider can assemble a real
25
+ * history window (an `unknown` row is rejected by the shared history
26
+ * validator, which would leave the provider permanently unavailable on this
27
+ * host). String-ness ALONE still proves nothing on any other channel — the
28
+ * classification rides on the channel contract, and every DETECTED failure
29
+ * is still wrapped `{result, error}` so the shared classifier can never
30
+ * miss it. Missing exitCode on a detected failure NEVER defaults to success.
28
31
  */
29
32
  export declare function normalizeWorkbuddyToolResponse(toolResponse: unknown, eventForDiagnostics: string): unknown;
30
33
  export declare function decodeWorkbuddyInput(raw: unknown): HostEvent;
@@ -127,22 +127,33 @@ function field(value, key) {
127
127
  * SPEC §4.2 failure normalization: WorkBuddy marks tool failure through
128
128
  * explicit shapes — `{is_error:true,...}` (Read failures), Bash
129
129
  * `{exitCode>0, error}` — while SUCCESS may be a bare STRING (rendered Read
130
- * output). A string carries no failure marker and is passed through
131
- * faithfully (the frozen fixtures show string === Read success; asserting
132
- * success-or-failure from string-ness alone would be fabrication).
130
+ * output).
133
131
  *
134
- * The shared production pain path (production-pain-evidence normalizeOutcome)
135
- * detects failure from an `error` field or a non-zero `exitCode` on the
136
- * envelope/result. `{is_error:true}` WITHOUT either (the read-iserror fixture
137
- * shape) would silently classify as success there — so this decoder wraps
138
- * every DETECTED failure in an explicit envelope
139
- * `{result: <original>, error: <explicit>}`, preserving the raw response
140
- * inside `result` while guaranteeing the failure is never missed. Missing
141
- * exitCode on a detected failure NEVER defaults to success.
132
+ * SPEC-RULECODE-HOST-PROMOTION-001 R2: on the PostToolUse channel the FROZEN
133
+ * host contract (Phase 0 baseline; see the channel table at the top of this
134
+ * file) is itself completion evidence — PostToolUseFailure is a SEPARATE
135
+ * registration and "PostToolUse never fires for that failure class". A
136
+ * response with NO failure marker on THIS channel is therefore a completed
137
+ * success, not an unconfirmed shape: the durable tool_calls row must record
138
+ * `success` so the WorkBuddy rule-context provider can assemble a real
139
+ * history window (an `unknown` row is rejected by the shared history
140
+ * validator, which would leave the provider permanently unavailable on this
141
+ * host). String-ness ALONE still proves nothing on any other channel — the
142
+ * classification rides on the channel contract, and every DETECTED failure
143
+ * is still wrapped `{result, error}` so the shared classifier can never
144
+ * miss it. Missing exitCode on a detected failure NEVER defaults to success.
142
145
  */
143
146
  export function normalizeWorkbuddyToolResponse(toolResponse, eventForDiagnostics) {
144
- if (typeof toolResponse === 'string')
145
- return toolResponse;
147
+ if (typeof toolResponse === 'string') {
148
+ // Wrap under a NON-`result` key: the shared classifier treats a record
149
+ // envelope as a structured completion, but a string inside `result` would
150
+ // still hit its text-only → unknown branch. `toolResponse` preserves the
151
+ // rendered text verbatim while the envelope carries the channel-contract
152
+ // completion.
153
+ return eventForDiagnostics === 'PostToolUse'
154
+ ? { toolResponse }
155
+ : toolResponse;
156
+ }
146
157
  if (!isRecord(toolResponse)) {
147
158
  throw new WorkBuddyDecoderError(`malformed required field "tool_response"`, `${eventForDiagnostics} tool_response must be the rendered string or the engine result object from ${HOST_CONTRACT_BASELINE}.`);
148
159
  }
@@ -152,8 +163,11 @@ export function normalizeWorkbuddyToolResponse(toolResponse, eventForDiagnostics
152
163
  const errorValue = field(toolResponse, 'error');
153
164
  const errorText = typeof errorValue === 'string' && errorValue.trim().length > 0 ? errorValue : undefined;
154
165
  const failed = isError || (exitCode !== undefined && exitCode > 0) || errorText !== undefined;
155
- if (!failed)
166
+ if (!failed) {
167
+ // An object envelope is already a structured completion for the shared
168
+ // classifier (normalizeOutcome: record ⇒ success) — pass through.
156
169
  return toolResponse;
170
+ }
157
171
  const toolErrorCode = field(toolResponse, 'tool_error_code');
158
172
  let explicitError;
159
173
  if (errorText !== undefined) {
package/dist/pd-hook.js CHANGED
@@ -37,7 +37,7 @@ import path from 'node:path';
37
37
  import process from 'node:process';
38
38
  import { pathToFileURL, fileURLToPath } from 'node:url';
39
39
  import { appendEventLogLine, redactTelemetryString, isRuleHostEvaluatedEventData } from '@principles/core/runtime-v2';
40
- import { createProductionHostRuntime, loadPdConfigForPlugin, parseOwnerStatementPrefix, assessWorkbuddyLiveKeyword, resolveSharedCorrectionKeywordStoreForDetection, admitTrustedWorkbuddyLiveCorrection, ensureGovernanceDiagnosticianTask } from '@principles/host-runtime';
40
+ import { createProductionHostRuntime, createWorkbuddyRuleContextProvider, loadPdConfigForPlugin, parseOwnerStatementPrefix, assessWorkbuddyLiveKeyword, resolveSharedCorrectionKeywordStoreForDetection, admitTrustedWorkbuddyLiveCorrection, ensureGovernanceDiagnosticianTask } from '@principles/host-runtime';
41
41
  import { computeFeatureFlagsFromConfig } from '@principles/core/runtime-v2';
42
42
  import { WorkBuddyHooksHostAdapter } from './host-adapter.js';
43
43
  import { WORKBUDDY_TOOL_SEMANTICS } from './tool-semantics.js';
@@ -157,16 +157,16 @@ function recordRuleHostEvaluations(stateDir, sessionId, evaluations) {
157
157
  return diagnostics;
158
158
  }
159
159
  /**
160
- * PRI-780 pattern (WorkBuddy boundary, same as Codex): this host supplies no
161
- * runtime context provider, and the "unavailable → allow" contract is prompt
162
- * discipline, NOT a runtime-enforced invariant. Handing v2 rules a truthy
163
- * context would let a persisted rule evaluate context-blind and deny calls
164
- * that were previously suspended — a silent governance change. The gate skips
165
- * v2 rules with its structured `rule_context_v2_unavailable` warning and this
166
- * hook annotates the warning with the explicit host-unsupported reason before
167
- * it reaches stderr (明确 unsupported, never a silent skip).
160
+ * SPEC-RULECODE-HOST-PROMOTION-001 C: WorkBuddy now supplies a REAL V2 rule
161
+ * context provider over its own trajectory (see the dispatch wiring below),
162
+ * so the old blanket "host provides no runtime context provider" note no
163
+ * longer applies. The remaining honest boundary: rules whose required facts
164
+ * include design_approval stay suspended on this host (no WorkBuddy
165
+ * design-gate attestation entry exists), and unavailable history still
166
+ * suspends context-dependent rules through the shared-gate contract. The
167
+ * note below annotates exactly that residual state.
168
168
  */
169
- const WORKBUDDY_CONTEXT_UNSUPPORTED_NOTE = 'workbuddy_runtime_context_unsupported: the WorkBuddy host provides no runtime context provider; v2 rules stay suspended on this host';
169
+ const WORKBUDDY_CONTEXT_UNSUPPORTED_NOTE = 'workbuddy_runtime_context_limited: WorkBuddy resolves rule context from its own trajectory only; rules requiring design_approval facts or unavailable history stay suspended on this host';
170
170
  export function annotateContextWarnings(warnings) {
171
171
  return warnings.map((warning) => warning.startsWith('rule_context_v2_unavailable')
172
172
  ? `${warning}; ${WORKBUDDY_CONTEXT_UNSUPPORTED_NOTE}`
@@ -226,6 +226,10 @@ export async function processWorkbuddyHookInvocation(rawStdin, channelEventName,
226
226
  }
227
227
  return failure;
228
228
  }
229
+ // Capture the decode boundary before governance or runtime work can consume
230
+ // time; the rule-context history window must never include rows written
231
+ // afterwards (same cutoff discipline as the Codex hook epoch).
232
+ const hookEpoch = new Date();
229
233
  // OD-07/P06 governance workspace: explicit, user-level, shared across
230
234
  // projects; never auto-created here and never falling back to another host.
231
235
  const resolution = resolveWorkbuddyWorkspace(os.homedir());
@@ -352,6 +356,23 @@ export async function processWorkbuddyHookInvocation(rawStdin, channelEventName,
352
356
  hostKind: 'workbuddy',
353
357
  toolSemantics: WORKBUDDY_TOOL_SEMANTICS,
354
358
  events: workbuddyEventEmitter(stateDir, evidenceContext),
359
+ // SPEC-RULECODE-HOST-PROMOTION-001 C: the V2 rule context provider over
360
+ // WorkBuddy's own observable trajectory (same-session tool_calls rows
361
+ // the shared afterToolCall path records). No designGate fact is
362
+ // supplied — WorkBuddy has no design-gate attestation entry point, so
363
+ // design_approval-dependent rules suspend with the precise
364
+ // `rule_required_fact_unavailable` warning rather than borrowing
365
+ // another host's declaration. Unavailable history (disabled flag, no
366
+ // project scope, first session turn) degrades through the shared
367
+ // suspension contract, observably.
368
+ ruleContextProvider: createWorkbuddyRuleContextProvider({
369
+ workspaceDir: resolution.workspaceDir,
370
+ ...(projectDir !== undefined ? { projectDir } : {}),
371
+ enabled: flags.rulecode_context_v2?.enabled === true,
372
+ cutoffAt: hookEpoch,
373
+ currentToolCallId: event.context.toolCallId ?? '',
374
+ toolSemantics: WORKBUDDY_TOOL_SEMANTICS,
375
+ }),
355
376
  // Remediation R2 (candidate-level admission): the diagnostician's
356
377
  // candidates are admitted ONLY with input evidence (needs_evidence
357
378
  // otherwise), and Codex-style transcript promotion does not exist on
@@ -378,8 +399,6 @@ export async function processWorkbuddyHookInvocation(rawStdin, channelEventName,
378
399
  }],
379
400
  };
380
401
  },
381
- // PRI-780 boundary: NO context provider — v2 rules stay SUSPENDED on
382
- // WorkBuddy (never loaded context-blind). See annotateContextWarnings.
383
402
  }).dispatch(event);
384
403
  const evaluationDiagnostics = recordRuleHostEvaluations(stateDir, event.context.sessionId, result.metadata?.evaluations);
385
404
  // Encode BEFORE recording an attributed deny: a runtime_verified
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principles/workbuddy-adapter",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "WorkBuddy host adapter for Principles Disciple — implements the HostAdapter interface for WorkBuddy's Git Bash command-hook model (ADR-0020 third host, SPEC workbuddy-host-adapter v0.3.0).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -42,8 +42,8 @@
42
42
  "lint": "eslint \"src/**/*.ts\""
43
43
  },
44
44
  "dependencies": {
45
- "@principles/core": "^1.297.3",
46
- "@principles/host-runtime": "^0.14.0",
45
+ "@principles/core": "^1.297.5",
46
+ "@principles/host-runtime": "^0.14.2",
47
47
  "@principles/install-layout": "^0.4.0"
48
48
  },
49
49
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Principles Disciple — Owner-governed behavior internalization for AI agents (WorkBuddy host adapter, ADR-0020 third host). Requires explicit setup and host.workbuddy feature enablement; inactive until then.",
5
5
  "defaultEnabled": false
6
6
  }
@@ -42,7 +42,7 @@
42
42
  {
43
43
  "type": "command",
44
44
  "command": "node \"${CODEBUDDY_PLUGIN_ROOT}/scripts/wrapper.cjs\" UserPromptSubmit",
45
- "timeout": 30
45
+ "timeout": 15
46
46
  }
47
47
  ]
48
48
  }
@@ -23,6 +23,25 @@
23
23
  * 1 with a bounded stderr diagnostic: probe P02 established exit 1 is
24
24
  * non-blocking on WorkBuddy, and the SPEC §4.4 startup-failure row forbids
25
25
  * claiming self-blocking the wrapper cannot guarantee.
26
+ *
27
+ * SELF-DEADLINE (SPEC §4.4 timeout row): the adapter entry's own fail-open
28
+ * exits can never run when the HOST kills this wrapper on the hooks.json
29
+ * timeout — the process dies mid-flight, and the host's timeout semantics for
30
+ * a killed hook are undefined. So the wrapper arms a deadline STRICTLY BELOW
31
+ * the host budget and kills the child itself, degrading non-blocking
32
+ * (exit 1 + bounded stderr) instead of leaving the worst failure mode — a
33
+ * lost UserPromptSubmit prompt — to the host. The budget pairs are locked
34
+ * below the hooks.json timeouts by tests/plugin-skeleton.test.ts:
35
+ * UserPromptSubmit 12000ms < 15s (prompt loss is the costliest channel);
36
+ * every other channel 25000ms < 30s.
37
+ * PD_WORKBUDDY_HOOK_SELF_TIMEOUT_MS overrides the table (integer ms > 0, all
38
+ * events) for tests and field diagnostics; invalid values fall back to the
39
+ * table, and a valid one is CLAMPED strictly below the host budget so the
40
+ * override can never re-create the bug this deadline exists to fix.
41
+ *
42
+ * A killed child gets no chance to clean up (SIGTERM is TerminateProcess on
43
+ * Windows), so the deadline path deliberately does NOT promise adapter-side
44
+ * flush of evidence / SQLite locks — see SPEC §4.4.
26
45
  */
27
46
  const { spawnSync } = require('node:child_process');
28
47
  const fs = require('node:fs');
@@ -30,13 +49,42 @@ const path = require('node:path');
30
49
 
31
50
  const eventName = process.argv[2];
32
51
 
33
- function fail(message) {
52
+ // hooks.json host budgets in ms. Kept here ONLY so the override below can be
53
+ // clamped and so the guard test can prove the two tables agree with
54
+ // hooks.json; the host remains the authority for actually killing us.
55
+ const HOST_TIMEOUT_MS = { UserPromptSubmit: 15000, default: 30000 };
56
+ // Margin for this process's own startup plus the degradation write, so our
57
+ // timer always fires before the host's.
58
+ const SELF_DEADLINE_MARGIN_MS = 3000;
59
+ // Must stay strictly below the matching hooks.json timeout (seconds) — the
60
+ // guard test in tests/plugin-skeleton.test.ts locks every pair.
61
+ const SELF_TIMEOUT_MS = { UserPromptSubmit: 12000, default: 25000 };
62
+
63
+ function hostBudgetMs(event) {
64
+ return HOST_TIMEOUT_MS[event] ?? HOST_TIMEOUT_MS.default;
65
+ }
66
+
67
+ function resolveSelfDeadlineMs(event) {
68
+ const budget = SELF_TIMEOUT_MS[event] ?? SELF_TIMEOUT_MS.default;
69
+ const override = Number.parseInt(process.env.PD_WORKBUDDY_HOOK_SELF_TIMEOUT_MS ?? '', 10);
70
+ if (!Number.isInteger(override) || override <= 0) return budget;
71
+ // Clamp, never trust: a diagnostic setting of 60000 would otherwise leave
72
+ // the outcome to the host's undefined timeout semantics again.
73
+ return Math.max(1, Math.min(override, hostBudgetMs(event) - SELF_DEADLINE_MARGIN_MS));
74
+ }
75
+
76
+ function fail(message, nextAction = 'Reinstall PD (npx create-principles-disciple) or set PD_WORKBUDDY_ADAPTER_ENTRY to the built adapter entry.') {
34
77
  try {
35
- process.stderr.write(`[PD] status=degraded reason=workbuddy_wrapper:${message} nextAction=Reinstall PD (npx create-principles-disciple) or set PD_WORKBUDDY_ADAPTER_ENTRY to the built adapter entry.\n`);
78
+ process.stderr.write(`[PD] status=degraded reason=workbuddy_wrapper:${message} nextAction=${nextAction}\n`);
36
79
  } catch (_) { /* no channel left */ }
37
80
  process.exit(1);
38
81
  }
39
82
 
83
+ // The adapter child was killed at OUR deadline (before the host timeout):
84
+ // the hook result is a non-blocking degradation, so the user's prompt/tool
85
+ // call survives — the recovery action is diagnosis, not reinstall.
86
+ const DEADLINE_NEXT_ACTION = 'The hook degraded at its self-deadline before the host timeout (non-blocking: the prompt/tool call survived). If this repeats, inspect PD stderr and the governance workspace, and time one manual pd-hook run.';
87
+
40
88
  function resolveEntry() {
41
89
  const override = process.env.PD_WORKBUDDY_ADAPTER_ENTRY;
42
90
  if (override && override.trim().length > 0) {
@@ -62,18 +110,34 @@ try {
62
110
  } catch (_) {
63
111
  fail('entry_not_found:' + entry.slice(0, 120));
64
112
  }
113
+ const deadlineMs = resolveSelfDeadlineMs(eventName);
114
+ const startedAt = Date.now();
65
115
  const child = spawnSync(process.execPath, [entry, eventName], {
66
116
  stdio: ['inherit', 'inherit', 'inherit'],
67
117
  windowsHide: true,
118
+ timeout: deadlineMs,
119
+ killSignal: 'SIGTERM',
68
120
  });
69
121
 
70
122
  if (child.error) {
123
+ // spawnSync surfaces its OWN timeout kill as error.code === 'ETIMEDOUT'
124
+ // (status null, no child exit code) — that is the self-deadline firing,
125
+ // not a spawn failure. Degrade non-blocking with the observable budget.
126
+ if (child.error.code === 'ETIMEDOUT') {
127
+ fail(`entry_deadline_exceeded:${Date.now() - startedAt}ms_budget_${deadlineMs}ms`, DEADLINE_NEXT_ACTION);
128
+ }
71
129
  const code = child.error.code === 'ENOENT' ? 'entry_not_found' : 'spawn_failed';
72
130
  fail(`${code}:${child.error.message.slice(0, 120)}`);
73
131
  }
74
132
  if (child.status === null) {
75
- // Killed by signal (e.g. host timeout): mirror as a non-blocking exit 1
76
- // with the observable reason; P02 timeout semantics are host-managed.
133
+ // Killed by signal. When our own deadline armed first, this is the
134
+ // self-timeout degradation (the host kills THIS wrapper, not the child);
135
+ // report it distinctly so field logs separate PD-side degradation from an
136
+ // externally killed adapter.
137
+ const elapsedMs = Date.now() - startedAt;
138
+ if (child.signal === 'SIGTERM' && elapsedMs >= deadlineMs - 500) {
139
+ fail(`entry_deadline_exceeded:${elapsedMs}ms_budget_${deadlineMs}ms`, DEADLINE_NEXT_ACTION);
140
+ }
77
141
  fail(`entry_killed_signal:${String(child.signal).slice(0, 30)}`);
78
142
  }
79
143
  process.exit(child.status);