harness-everything 0.11.7 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +53 -0
  4. package/README.md +1 -0
  5. package/behavioral-evals/README.md +37 -0
  6. package/behavioral-evals/cases/self-evolve-retrieval-recurrence.yaml +48 -0
  7. package/behavioral-evals/paired-benchmark.js +211 -5
  8. package/ci/contract-integrity.test.js +68 -2
  9. package/ci/mechanism-2ah-workflow-lifecycle.test.js +258 -2
  10. package/ci/mechanism-32-paired-benchmark.test.js +139 -0
  11. package/ci/openai-plugin-package.test.js +17 -0
  12. package/ci/self-evolve-loop.test.js +37 -2
  13. package/ci/telemetry.test.js +31 -1
  14. package/docs/enforcement-locks.md +114 -0
  15. package/grill-with-docs/ADR-FORMAT.md +22 -0
  16. package/grill-with-docs/SKILL.md +1 -1
  17. package/hooks/scripts/lib/learning-opportunity.js +22 -0
  18. package/hooks/scripts/lib/telemetry.js +40 -0
  19. package/hooks/scripts/lib/workflow-isolation.js +10 -2
  20. package/hooks/scripts/lib/workflow-runtime.js +108 -5
  21. package/hooks/scripts/state-persist.js +9 -3
  22. package/hooks/scripts/workflow-disposition.js +19 -8
  23. package/hooks/scripts/workflow-gate.js +33 -4
  24. package/multi-agent-workspace/SKILL.md +1 -1
  25. package/multi-agent-workspace/scripts/index_memory.js +28 -0
  26. package/opencode-plugin/plugin.json +1 -1
  27. package/package.json +1 -1
  28. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  29. package/plugins/harness-everything/contract-integrity/scripts/audit.js +536 -0
  30. package/plugins/harness-everything/hooks/scripts/lib/learning-opportunity.js +22 -0
  31. package/plugins/harness-everything/hooks/scripts/lib/telemetry.js +40 -0
  32. package/plugins/harness-everything/hooks/scripts/lib/workflow-isolation.js +10 -2
  33. package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +108 -5
  34. package/plugins/harness-everything/hooks/scripts/state-persist.js +9 -3
  35. package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +19 -8
  36. package/plugins/harness-everything/hooks/scripts/workflow-gate.js +33 -4
  37. package/plugins/harness-everything/plugin.json +1 -1
  38. package/plugins/harness-everything/skills/grill-with-docs/ADR-FORMAT.md +22 -0
  39. package/plugins/harness-everything/skills/grill-with-docs/SKILL.md +1 -1
  40. package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +1 -1
  41. package/plugins/harness-everything/skills/multi-agent-workspace/scripts/index_memory.js +28 -0
  42. package/plugins/harness-everything/skills/self-evolve/SKILL.md +1 -1
  43. package/plugins/harness-everything/skills/self-evolve/scripts/lesson-candidate.js +52 -0
  44. package/plugins/harness-everything/skills/to-spec/SKILL.md +1 -1
  45. package/plugins/harness-everything/skills/to-spec/references/process.md +26 -0
  46. package/plugins/harness-everything/skills/to-spec/templates/cli-reference.md +14 -0
  47. package/plugins/harness-everything/skills/to-spec/templates/dev-doc.md +14 -0
  48. package/plugins/harness-everything/skills/to-spec/templates/feature-spec.md +14 -0
  49. package/plugins/harness-everything/skills/to-spec/templates/schema-doc.md +14 -0
  50. package/plugins/harness-everything/skills/to-tickets/SKILL.md +1 -1
  51. package/plugins/harness-everything/skills/to-tickets/references/publishing-and-templates.md +20 -1
  52. package/plugins/harness-everything/skills/verification-loop/SKILL.md +3 -2
  53. package/plugins/harness-everything/skills/verification-loop/references/verification-phases.md +25 -1
  54. package/plugins/harness-everything/skills/verification-loop/scripts/contract-integrity-audit.js +31 -0
  55. package/plugins/harness-everything/skills/verification-loop/templates/verification-report.template.md +3 -0
  56. package/scripts/sync-openai-plugin.js +12 -0
  57. package/self-evolve/SKILL.md +1 -1
  58. package/self-evolve/scripts/lesson-candidate.js +52 -0
  59. package/telemetry/schemas/event.schema.json +141 -22
  60. package/telemetry/scripts/report.js +42 -0
  61. package/to-spec/SKILL.md +1 -1
  62. package/to-spec/references/process.md +26 -0
  63. package/to-spec/templates/cli-reference.md +14 -0
  64. package/to-spec/templates/dev-doc.md +14 -0
  65. package/to-spec/templates/feature-spec.md +14 -0
  66. package/to-spec/templates/schema-doc.md +14 -0
  67. package/to-tickets/SKILL.md +1 -1
  68. package/to-tickets/references/publishing-and-templates.md +20 -1
  69. package/verification-loop/SKILL.md +3 -2
  70. package/verification-loop/references/verification-phases.md +25 -1
  71. package/verification-loop/scripts/contract-integrity-audit.js +31 -0
  72. package/verification-loop/templates/verification-report.template.md +3 -0
@@ -0,0 +1,114 @@
1
+ # Enforcement and Lock Diagnostic Flows
2
+
3
+ Harness has several mechanisms that can deny a tool call or delay completion. Only one of them is a repeated-failure circuit breaker. Use the emitted mechanism name and the flows below before describing a denial as a lock problem.
4
+
5
+ This guide describes the repository's current hook wiring. Mechanical enforcement depends on the host and installation surface; see [Platform Capability Matrix](platform-capabilities.md) before generalizing a behavior to every host.
6
+
7
+ ## Lifecycle map
8
+
9
+ On the Claude hook surface, a request enters the router before tool use. Pre-tool gates decide whether a tool call may start, post-tool hooks record outcomes, and stop gates decide whether the turn may finish.
10
+
11
+ ```mermaid
12
+ flowchart TD
13
+ A["User prompt"] --> B["Kernel router<br/>select workflow contract"]
14
+ B --> C["Pre-tool gates"]
15
+ C --> D{"Tool allowed?"}
16
+ D -- "No" --> E["Emit gate-specific reason<br/>and recovery"]
17
+ D -- "Yes" --> F["Execute tool"]
18
+ F --> G["Post-tool outcome tracking"]
19
+ G --> H{"Turn stopping?"}
20
+ H -- "No" --> C
21
+ H -- "Yes" --> I["Completion gates"]
22
+ I --> J{"Obligations resolved?"}
23
+ J -- "Yes" --> K["Evidence-backed completion"]
24
+ J -- "No" --> E
25
+ ```
26
+
27
+ The important separation is:
28
+
29
+ - **Pre-tool denial:** the requested operation did not run.
30
+ - **Post-tool tracking:** the operation ran and its outcome updated state.
31
+ - **Stop denial:** the turn cannot claim completion yet; it does not necessarily prevent the next tool call.
32
+ - **Approval request:** the exact destructive or external action waits for authorization; it is not a retry lock.
33
+
34
+ ## Which mechanism stopped progress?
35
+
36
+ Start with the prefix in the error or hook message. Do not infer the mechanism only from the fact that a command did not run.
37
+
38
+ ```mermaid
39
+ flowchart TD
40
+ A["Operation or completion blocked"] --> B{"Message prefix or condition"}
41
+ B -- "RULE OF 3" --> C["Repeated-failure circuit breaker"]
42
+ B -- "Workflow gate" --> D["Workflow entry, state,<br/>run, or worktree precondition"]
43
+ B -- "Action gate or permission" --> E["Exact side effect needs approval"]
44
+ B -- "Workflow Completion Gate" --> F["Selected workflow obligations unresolved"]
45
+ B -- "Stop Gate" --> G["Edits lack later successful verification"]
46
+ B -- "Boundary, depth, context,<br/>or scope guard" --> H["Guard-specific policy denial"]
47
+ C --> I["Follow Rule of 3 recovery flow"]
48
+ D --> J["Satisfy named workflow precondition"]
49
+ E --> K["Approve, reject, or change exact payload"]
50
+ F --> L["Resolve stages or report blocked state"]
51
+ G --> M["Run a relevant check or state why not applicable"]
52
+ H --> N["Follow the emitted guard reason"]
53
+ ```
54
+
55
+ If a message has no Harness prefix, check the host permission system and the tool's own error before changing Harness state.
56
+
57
+ ## Mechanism reference
58
+
59
+ | Mechanism | Trigger | What it blocks | State and persistence | Recovery |
60
+ |---|---|---|---|---|
61
+ | Rule of 3 | The same normalized shell failure signature reaches its category threshold. Defaults to 3; timeout and permission use 2; environment and dependency use 4. | Mutating tools. Read-only investigation remains available, and the required zoom-out report is the only permitted write. | Session-scoped `rule-of-3-state.json`. A second trip of the same signature after one accepted reflection becomes a human-decision hard lock. | First trip: produce a fresh valid `zoom-out-report.md`. Repeat trip: human runs `npm run harness:reset`, starts a new session, or uses `/clear`. |
62
+ | Workflow gate | Active workflow state or entry contract is incompatible with the requested mutation; examples include blocked/failed/satisfied state, missing correlated Fable run, or missing linked worktree for major work. | The requested mutation. Read-only commands and trusted workflow-controller commands have narrow exceptions. | Session workflow state. Major-workflow isolation is a hard precondition, not an automatic fallback. | Use the exact message: enter a linked worktree, start/replan through the controller, resolve blocked state, or submit a new task after satisfaction. |
63
+ | Action gate | A shell rule or structured destructive/external action matches the policy. | Only the exact action payload; its hash is used for audit correlation. | Approval/audit state records pending, executed, failed, or rejected disposition. | Approve or reject through the host flow, or change the action. Approval of one payload does not authorize a different payload. |
64
+ | Workflow completion gate | A selected workflow is stopping with unresolved stage, correlation, or verification obligations. | Completion. On a repeated stop-hook attempt it records a blocked state instead of looping forever. | `workflow-run.json` plus correlated stage/handoff state. | Resolve the named obligation. If genuinely blocked, report the blocker rather than claim completion. |
65
+ | Ordinary stop gate | A dirty tree contains edits with no later successful verification-like command. | Completion once per edit batch. It is intentionally a bounce, not a persistent cage. | `stop-gate-state.json` remembers the edit timestamp already bounced. | Run a targeted test/build/lint check, or explicitly state why verification is not applicable and stop again. |
66
+ | Boundary/depth/context/scope guards | A guard-specific read, write, context-pressure, or subagent-scope condition matches. | The matching tool or workflow step. | Mechanism-specific; these are not Rule of 3 counters. | Follow the emitted reason and the corresponding architecture documentation. |
67
+
68
+ ## Rule of 3 state transitions
69
+
70
+ The tracker hashes a normalized failure together with category, file path, and command context. A different signature starts a new counter. A confirmed successful action clears recovery state.
71
+
72
+ ```mermaid
73
+ stateDiagram-v2
74
+ [*] --> Counting
75
+ Counting --> Counting: same signature below threshold
76
+ Counting --> Reflecting: threshold reached
77
+ Reflecting --> Counting: valid fresh report with RESUME
78
+ Reflecting --> Escalating: valid fresh report with ESCALATE
79
+ Counting --> HardLocked: same signature trips after reflection
80
+ HardLocked --> [*]: human reset or new session
81
+ Counting --> [*]: confirmed success
82
+ Escalating --> HardLocked: mutation retried and signature trips again
83
+ ```
84
+
85
+ A valid reflection report must be newer than the failure that tripped the breaker, contain `Goal`, `Failed Attempts`, `Verified Facts`, `Diagnosis`, and `Decision`, and end in a `RESUME` or `ESCALATE` decision. A stale report cannot release a later trip.
86
+
87
+ ## Pre-tool ordering and overlapping denials
88
+
89
+ The configured Claude pre-tool order is workflow gate, action gate, Rule of 3, then the narrower boundary/depth/context/scope guards for their matched tools. This means the first emitted denial identifies the condition reached on that call; it does not prove that every later gate would pass.
90
+
91
+ For example:
92
+
93
+ 1. A force push may first fail the workflow gate if the selected workflow has not been entered.
94
+ 2. After workflow entry is fixed, the action gate may still require approval for the same force push.
95
+ 3. Rule of 3 is relevant only when repeated tracked failures reached the signature threshold; it should not be reset to bypass workflow or approval requirements.
96
+
97
+ Treat each denial independently and re-evaluate after satisfying its stated condition.
98
+
99
+ ## Host and evidence boundary
100
+
101
+ The hook sequence above is the canonical Claude package wiring. The local OpenAI/Codex plugin packages related lifecycle adapters, while general installer paths and the public Skills-only artifact may be advisory rather than mechanically enforced. OpenCode has a separate plugin implementation and a narrower retained live-host evidence boundary.
102
+
103
+ Repository mechanism tests show that packaged code follows its tested contract. They do not by themselves prove that a particular live host loaded the package, fired the hook, or improved agent behavior. Record live-host evidence before making that stronger claim.
104
+
105
+ ## Source map
106
+
107
+ Use these implementation files when this guide and runtime appear to disagree:
108
+
109
+ - `hooks/hooks.json` — lifecycle wiring and order.
110
+ - `hooks/scripts/rule-of-3.js` and `rule-of-3-tracker.js` — counter, thresholds, reflection, and hard-lock behavior.
111
+ - `hooks/scripts/workflow-gate.js` and `workflow-stop-gate.js` — mutation and completion workflow contracts.
112
+ - `hooks/scripts/action-gate.js` and `action-gate-rules.json` — approval policy and audit behavior.
113
+ - `hooks/scripts/stop-gate.js` — ordinary one-bounce verification behavior.
114
+ - [Harness Architecture](architecture.md) and [Workflow Runtime](workflow-runtime.md) — broader runtime contracts and limitations.
@@ -22,6 +22,28 @@ Only include these when they add genuine value. Most ADRs won't need them.
22
22
  - **Considered Options** — only when the rejected alternatives are worth remembering
23
23
  - **Consequences** — only when non-obvious downstream effects need to be called out
24
24
 
25
+ ## Decision lifecycle and contract lineage
26
+
27
+ Once an ADR is **accepted**, keep its decision and rationale historically stable. Do not rewrite an accepted ADR to make a later design look as though it was always the decision.
28
+
29
+ Use compact metadata when the ADR participates in a behavior contract:
30
+
31
+ ```md
32
+ ADR-ID: ADR-0001
33
+ Status: proposed | accepted | deprecated | superseded by ADR-0002
34
+ Affected requirements: REQ-001, REQ-004
35
+ ```
36
+
37
+ When a later change materially changes the decision or its rationale:
38
+
39
+ 1. create a new sequential ADR;
40
+ 2. state the new decision and why the prior trade-off changed;
41
+ 3. mark the earlier ADR `superseded by ADR-NNNN`;
42
+ 4. update the living spec requirements affected by that decision;
43
+ 5. let the structured contract trace carry detailed ticket/test/implementation lineage.
44
+
45
+ A ticket or implementation note never silently becomes the new architectural source of truth. If only implementation changes and the approved behavior/decision does not, keep the accepted ADR unchanged.
46
+
25
47
  ## Numbering
26
48
 
27
49
  Scan `docs/adr/` for the highest existing number and increment by one.
@@ -4,7 +4,7 @@ description: Challenge the plan against the existing domain model, sharpen termi
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Miya Daniel
7
- version: 0.4.0
7
+ version: 0.12.0
8
8
  ---
9
9
 
10
10
  # Grill With Docs (Domain Modeling & Architectural Alignment)
@@ -8,6 +8,7 @@ const {
8
8
  getSessionId,
9
9
  getSessionDir,
10
10
  } = require('./harness-state');
11
+ const { emitLessonTelemetry } = require('./telemetry');
11
12
 
12
13
  const SCHEMA_VERSION = 1;
13
14
  const TRIGGERS = new Set(['rule-of-3-recovery', 'verifier-fail-pass']);
@@ -134,6 +135,27 @@ function createLearningOpportunity(payload, input = {}) {
134
135
  updatedAt: observedAt,
135
136
  };
136
137
  atomicWriteJson(file, candidate);
138
+ const lifecycleReasonCodes = [...candidate.trigger.reasonCodes, `trigger:${triggerType}`];
139
+ emitLessonTelemetry({
140
+ event: 'learning_opportunity',
141
+ root,
142
+ payload,
143
+ sessionId,
144
+ candidateId,
145
+ observedAt,
146
+ status: 'success',
147
+ reasonCodes: lifecycleReasonCodes,
148
+ });
149
+ emitLessonTelemetry({
150
+ event: 'lesson_proposed',
151
+ root,
152
+ payload,
153
+ sessionId,
154
+ candidateId,
155
+ observedAt,
156
+ status: 'success',
157
+ reasonCodes: lifecycleReasonCodes,
158
+ });
137
159
  return { created: true, file, candidate };
138
160
  }
139
161
 
@@ -6,11 +6,26 @@ const path = require('path');
6
6
  const { getWorkspaceRoot, getStateRoot, getSessionDir } = require('./harness-state');
7
7
 
8
8
  const TELEMETRY_SCHEMA_VERSION = 1;
9
+ const LESSON_EVENT_TYPES = new Set([
10
+ 'learning_opportunity',
11
+ 'lesson_proposed',
12
+ 'lesson_screened',
13
+ 'lesson_evaluated',
14
+ 'lesson_accepted',
15
+ 'lesson_rejected',
16
+ 'lesson_inconclusive',
17
+ 'lesson_persisted',
18
+ 'lesson_retrieved',
19
+ 'lesson_validated',
20
+ 'lesson_regressed',
21
+ 'lesson_superseded',
22
+ ]);
9
23
  const EVENT_TYPES = new Set([
10
24
  'skill.invoke',
11
25
  'skill.loaded',
12
26
  'skill.complete',
13
27
  'tool.observed',
28
+ ...LESSON_EVENT_TYPES,
14
29
  ]);
15
30
  const STATUSES = new Set(['unknown', 'success', 'failure', 'aborted']);
16
31
  const FORBIDDEN_KEYS = new Set([
@@ -102,6 +117,29 @@ function telemetryFile(root, payload) {
102
117
  return path.join(getStateRoot(root || getWorkspaceRoot(payload), payload), 'telemetry', 'events.jsonl');
103
118
  }
104
119
 
120
+ function emitLessonTelemetry(input = {}) {
121
+ const event = String(input.event || '');
122
+ if (!LESSON_EVENT_TYPES.has(event)) {
123
+ return { ok: false, error: `unsupported lesson telemetry event: ${event}`, overheadMs: 0 };
124
+ }
125
+ const invocationId = localId('lesson', input.candidateId);
126
+ if (!invocationId) {
127
+ return { ok: false, error: 'lesson telemetry requires candidateId', overheadMs: 0 };
128
+ }
129
+ return emitTelemetry({
130
+ event,
131
+ host: ['claude', 'codex', 'opencode', 'unknown'].includes(input.host)
132
+ ? input.host
133
+ : detectHost(input.payload || {}),
134
+ observedAt: input.observedAt || nowIso(),
135
+ sessionId: localId('session', input.sessionId),
136
+ invocationId,
137
+ skillName: 'self-evolve',
138
+ status: STATUSES.has(input.status) ? input.status : 'unknown',
139
+ reasonCodes: input.reasonCodes || [],
140
+ }, { root: input.root, payload: input.payload });
141
+ }
142
+
105
143
  function emitTelemetry(input, options = {}) {
106
144
  const started = process.hrtime.bigint();
107
145
  try {
@@ -361,9 +399,11 @@ function readEvents(file) {
361
399
  module.exports = {
362
400
  EVENT_TYPES,
363
401
  FORBIDDEN_KEYS,
402
+ LESSON_EVENT_TYPES,
364
403
  STATUSES,
365
404
  TELEMETRY_SCHEMA_VERSION,
366
405
  detectHost,
406
+ emitLessonTelemetry,
367
407
  emitTelemetry,
368
408
  hostDuration,
369
409
  localId,
@@ -76,10 +76,18 @@ function workspaceFingerprint(cwd) {
76
76
  function shellProbeKey(payload, cwd) {
77
77
  const toolUseId = String(payload?.tool_use_id || payload?.toolUseId || '').trim();
78
78
  const tool = String(payload?.tool_name || payload?.tool || '');
79
+ if (toolUseId) {
80
+ // Host tool-use identity is stable across Pre/Post payload cwd drift.
81
+ return crypto.createHash('sha256')
82
+ .update('harness-shell-probe-v3\0')
83
+ .update(toolUseId).update('\0')
84
+ .update(tool)
85
+ .digest('hex');
86
+ }
87
+ // Compatibility fallback for hosts that do not expose tool_use_id.
79
88
  const command = commandOf(payload);
80
89
  return crypto.createHash('sha256')
81
- .update('harness-shell-probe-v2\0')
82
- .update(toolUseId || 'no-tool-use-id').update('\0')
90
+ .update('harness-shell-probe-v2-fallback\0')
83
91
  .update(tool).update('\0')
84
92
  .update(key(cwd)).update('\0')
85
93
  .update(command)
@@ -4,6 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { getWorkspaceRoot, getSessionDir, getSessionId, getStateRoot } = require('./harness-state');
6
6
  const { atomicWriteJson, readJson } = require('./fable-contracts');
7
+ const { workspaceFingerprint } = require('./workflow-isolation');
7
8
 
8
9
  const OPEN_STATES = new Set(['pending', 'active', 'running', 'failed', 'blocked', 'escaped']);
9
10
  const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
@@ -45,6 +46,25 @@ function saveWorkflow(context) { atomicWriteJson(context.file, context.workflow)
45
46
 
46
47
  const MUTATION_PROBE_KEY = /^[a-f0-9]{64}$/;
47
48
  const MUTATION_PROBE_WAIT = new Int32Array(new SharedArrayBuffer(4));
49
+ const MUTATION_PROBE_DEFAULT_TIMEOUT_MS = 120000;
50
+ const MUTATION_PROBE_RECLAIM_GRACE_MS = 5000;
51
+ const MUTATION_PROBE_MAX_TIMEOUT_MS = 30 * 60 * 1000;
52
+
53
+ function mutationProbeLeaseMs(timeoutMs) {
54
+ const parsed = Number(timeoutMs);
55
+ const executionMs = Number.isFinite(parsed) && parsed > 0
56
+ ? Math.min(parsed, MUTATION_PROBE_MAX_TIMEOUT_MS)
57
+ : MUTATION_PROBE_DEFAULT_TIMEOUT_MS;
58
+ return executionMs + MUTATION_PROBE_RECLAIM_GRACE_MS;
59
+ }
60
+
61
+ function mutationProbeExpired(probe, now = Date.now()) {
62
+ const reclaimAfterAt = Number(probe?.reclaimAfterAt);
63
+ if (Number.isFinite(reclaimAfterAt)) return now >= reclaimAfterAt;
64
+ const observedAt = Number(probe?.observedAt);
65
+ return Number.isFinite(observedAt) &&
66
+ now - observedAt >= mutationProbeLeaseMs(null);
67
+ }
48
68
 
49
69
  function mutationProbeDir(context) {
50
70
  return path.join(context.sessionDir, 'mutation-probes');
@@ -81,15 +101,25 @@ function withMutationProbeLock(context, callback) {
81
101
  finally { fs.rmSync(lockDir, { recursive: true, force: true }); }
82
102
  }
83
103
 
104
+ function probeCountsIteration(probe) {
105
+ return Boolean(probe && (probe.countIteration === true ||
106
+ (probe.countIteration === undefined && probe.reserveIteration === true)));
107
+ }
108
+
84
109
  function mutationProbeReservations(context) {
85
110
  const dir = mutationProbeDir(context);
86
111
  let files = [];
87
112
  try { files = fs.readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)); }
88
113
  catch (_) { return 0; }
114
+ const now = Date.now();
89
115
  let count = 0;
90
116
  for (const file of files) {
91
117
  const probe = readJson(path.join(dir, file));
92
- if (probe?.schemaVersion === 1 && probe.reserveIteration === true) count++;
118
+ if (probe?.schemaVersion !== 1 || probe.reserveIteration !== true) continue;
119
+ // A reservation remains active for the host/tool execution timeout plus
120
+ // a grace period. This prevents a genuinely in-flight command from being
121
+ // reclaimed merely because it ran longer than an arbitrary short delay.
122
+ if (!mutationProbeExpired(probe, now)) count++;
93
123
  }
94
124
  return count;
95
125
  }
@@ -115,22 +145,78 @@ function assertIterationCapacity(context, evidence) {
115
145
  return { applicable: true, value: budget.counters.iterations, reserved, limit };
116
146
  }
117
147
 
118
- function registerMutationProbe(context, key, fingerprint, reserveIteration) {
148
+ function reclaimStaleMutationProbes(context, keepKey) {
149
+ const dir = mutationProbeDir(context);
150
+ let files = [];
151
+ try { files = fs.readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)); }
152
+ catch (_) { return; }
153
+ const now = Date.now();
154
+ for (const name of files) {
155
+ const staleKey = name.slice(0, -'.json'.length);
156
+ if (staleKey === keepKey) continue;
157
+ const file = path.join(dir, name);
158
+ const probe = readJson(file);
159
+ if (!probe || probe.schemaVersion !== 1) continue;
160
+ if (!mutationProbeExpired(probe, now)) continue;
161
+ const countsIteration = probeCountsIteration(probe);
162
+ if (!probe.observationCwd) {
163
+ // Legacy probes predate stored observation roots and cannot be safely
164
+ // re-observed. Once outside the in-flight grace window they must not
165
+ // reserve capacity forever.
166
+ fs.unlinkSync(file);
167
+ continue;
168
+ }
169
+ let currentFingerprint;
170
+ try {
171
+ currentFingerprint = workspaceFingerprint(probe.observationCwd);
172
+ } catch (_) {
173
+ fs.unlinkSync(file);
174
+ context.workflow.lastMutationAt = now;
175
+ if (countsIteration && context.workflow.strategy === 'iterative-single') {
176
+ recordBudgetEvent(context, 'iteration', { evidence: 'shell:reclaimed-unobservable-workspace' });
177
+ } else {
178
+ saveWorkflow(context);
179
+ }
180
+ continue;
181
+ }
182
+ if (currentFingerprint !== probe.fingerprint) {
183
+ fs.unlinkSync(file);
184
+ context.workflow.lastMutationAt = now;
185
+ if (countsIteration && context.workflow.strategy === 'iterative-single') {
186
+ recordBudgetEvent(context, 'iteration', { evidence: 'shell:reclaimed-stale-workspace-mutation' });
187
+ } else {
188
+ saveWorkflow(context);
189
+ }
190
+ continue;
191
+ }
192
+ // No visible workspace effect after the grace window: this probe was
193
+ // orphaned by a denial/block/missing Post event. Discard it so it cannot
194
+ // reserve capacity or later attribute an unrelated mutation (#161).
195
+ fs.unlinkSync(file);
196
+ }
197
+ }
198
+
199
+ function registerMutationProbe(context, key, fingerprint, reserveIteration, observationCwd, timeoutMs) {
119
200
  if (!MUTATION_PROBE_KEY.test(String(key || '')) || !MUTATION_PROBE_KEY.test(String(fingerprint || ''))) {
120
201
  throw new Error('invalid mutation probe identity');
121
202
  }
122
203
  return withMutationProbeLock(context, () => {
123
204
  refreshWorkflowForProbe(context);
205
+ reclaimStaleMutationProbes(context, key);
124
206
  if (reserveIteration) assertIterationCapacity(context, 'shell:untrusted');
125
207
  const dir = mutationProbeDir(context);
126
208
  fs.mkdirSync(dir, { recursive: true });
127
209
  const file = mutationProbeFile(context, key);
128
210
  if (fs.existsSync(file)) throw new Error('mutation probe is already pending for this tool call');
211
+ const observedAt = Date.now();
129
212
  const probe = {
130
213
  schemaVersion: 1,
131
214
  fingerprint,
132
215
  reserveIteration: Boolean(reserveIteration),
133
- observedAt: Date.now(),
216
+ countIteration: Boolean(reserveIteration),
217
+ observationCwd: observationCwd ? path.resolve(observationCwd) : null,
218
+ observedAt,
219
+ reclaimAfterAt: observedAt + mutationProbeLeaseMs(timeoutMs),
134
220
  };
135
221
  atomicWriteJson(file, probe);
136
222
  return probe;
@@ -152,7 +238,7 @@ function settleMutationProbe(context, key, afterFingerprint, tool) {
152
238
  const changed = afterFingerprint !== probe.fingerprint;
153
239
  if (changed) {
154
240
  context.workflow.lastMutationAt = Date.now();
155
- if (probe.reserveIteration && context.workflow.strategy === 'iterative-single') {
241
+ if (probeCountsIteration(probe) && context.workflow.strategy === 'iterative-single') {
156
242
  recordBudgetEvent(context, 'iteration', { evidence: String(tool || 'shell') + ':observed-workspace-mutation' });
157
243
  } else {
158
244
  saveWorkflow(context);
@@ -162,6 +248,23 @@ function settleMutationProbe(context, key, afterFingerprint, tool) {
162
248
  });
163
249
  }
164
250
 
251
+ function settleMutationProbeConservative(context, key, tool, evidence = 'unobservable-workspace') {
252
+ return withMutationProbeLock(context, () => {
253
+ const file = mutationProbeFile(context, key);
254
+ const probe = readJson(file);
255
+ if (!probe || probe.schemaVersion !== 1) return null;
256
+ refreshWorkflowForProbe(context);
257
+ fs.unlinkSync(file);
258
+ context.workflow.lastMutationAt = Date.now();
259
+ if (probeCountsIteration(probe) && context.workflow.strategy === 'iterative-single') {
260
+ recordBudgetEvent(context, 'iteration', { evidence: String(tool || 'shell') + ':' + evidence });
261
+ } else {
262
+ saveWorkflow(context);
263
+ }
264
+ return { changed: true, probe, conservative: true };
265
+ });
266
+ }
267
+
165
268
  function discardMutationProbe(context, key) {
166
269
  return withMutationProbeLock(context, () => {
167
270
  const file = mutationProbeFile(context, key);
@@ -396,4 +499,4 @@ function readHookInput(decide) {
396
499
  process.stdin.on('error', finish);
397
500
  }
398
501
 
399
- module.exports = { OPEN_STATES, SAFE_ID, WORKFLOW_CONTROLLER_COMMANDS, isMajorWorkflow, loadWorkflow, saveWorkflow, budgetLimits, ensureWorkflowBudget, mutationProbeReservations, assertIterationCapacity, registerMutationProbe, peekMutationProbe, settleMutationProbe, discardMutationProbe, clearMutationProbes, recordBudgetEvent, resetWorkflowBudget, syncBudgetToRun, matchingRun, unresolvedStages, readHookInput };
502
+ module.exports = { OPEN_STATES, SAFE_ID, WORKFLOW_CONTROLLER_COMMANDS, isMajorWorkflow, loadWorkflow, saveWorkflow, budgetLimits, ensureWorkflowBudget, mutationProbeReservations, assertIterationCapacity, registerMutationProbe, peekMutationProbe, settleMutationProbe, settleMutationProbeConservative, discardMutationProbe, clearMutationProbes, recordBudgetEvent, resetWorkflowBudget, syncBudgetToRun, matchingRun, unresolvedStages, readHookInput };
@@ -2,7 +2,7 @@
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { getWorkspaceRoot, getSessionDir } = require('./lib/harness-state');
5
- const { loadWorkflow, saveWorkflow, peekMutationProbe, settleMutationProbe } = require('./lib/workflow-runtime');
5
+ const { loadWorkflow, saveWorkflow, isMajorWorkflow, peekMutationProbe, settleMutationProbe, settleMutationProbeConservative } = require('./lib/workflow-runtime');
6
6
  const { cwdOf, shellProbeKey, workspaceFingerprint, directMutationInWorkspace } = require('./lib/workflow-isolation');
7
7
  const { observeTool } = require('./lib/telemetry');
8
8
 
@@ -37,11 +37,17 @@ function observeWorkspaceMutation(payload, root) {
37
37
  if (!context.workflow || context.workflow.state === 'deferred') return false;
38
38
  const cwd = cwdOf(payload, root);
39
39
  const probeKey = shellProbeKey(payload, cwd);
40
- if (!peekMutationProbe(context, probeKey)) return false;
40
+ const probe = peekMutationProbe(context, probeKey);
41
+ if (!probe) return false;
42
+ const observationCwd = probe.observationCwd || cwd;
41
43
  let after;
42
44
  try {
43
- after = workspaceFingerprint(cwd);
45
+ after = workspaceFingerprint(observationCwd);
44
46
  } catch (error) {
47
+ if (!isMajorWorkflow(context.workflow)) {
48
+ const conservative = settleMutationProbeConservative(context, probeKey, toolName, 'unobservable-workspace');
49
+ return Boolean(conservative && conservative.changed);
50
+ }
45
51
  context.workflow.state = 'blocked';
46
52
  context.workflow.blockReason = 'mutation-observation-failed';
47
53
  saveWorkflow(context);
@@ -3,7 +3,7 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
- const { loadWorkflow, saveWorkflow, matchingRun, OPEN_STATES, ensureWorkflowBudget, recordBudgetEvent, resetWorkflowBudget, syncBudgetToRun, WORKFLOW_CONTROLLER_COMMANDS } = require('./lib/workflow-runtime');
6
+ const { loadWorkflow, saveWorkflow, matchingRun, OPEN_STATES, budgetLimits, ensureWorkflowBudget, recordBudgetEvent, resetWorkflowBudget, syncBudgetToRun, WORKFLOW_CONTROLLER_COMMANDS } = require('./lib/workflow-runtime');
7
7
  const { getWorkspaceRoot, readCurrentSession } = require('./lib/harness-state');
8
8
  const { atomicWriteJson, readJson } = require('./lib/fable-contracts');
9
9
 
@@ -32,6 +32,20 @@ function consumer() {
32
32
  throw new Error('Fable consumer unavailable');
33
33
  }
34
34
 
35
+ function activatePlan(workflow, plan) {
36
+ workflow.workflowPlan = plan;
37
+ workflow.strategy = plan.strategy;
38
+ workflow.strategySelection = plan.strategySelection;
39
+ workflow.tier = plan.tier;
40
+ workflow.requiredInvariants = plan.requiredInvariants || [];
41
+ workflow.suggestedSkills = plan.suggestedSkills || [];
42
+ workflow.verification = plan.verification;
43
+ workflow.mutationIsolation = plan.mutationIsolation || { required: false };
44
+ const budget = ensureWorkflowBudget(workflow);
45
+ budget.limits = budgetLimits(workflow);
46
+ budget.updatedAt = new Date().toISOString();
47
+ }
48
+
35
49
  function main() {
36
50
  const args = parseArgs(process.argv.slice(2));
37
51
  if (!WORKFLOW_CONTROLLER_COMMANDS.has(args.command)) throw new Error(usage());
@@ -47,10 +61,12 @@ function main() {
47
61
  if (isReplan) recordBudgetEvent(context, 'replan', { evidence: 'workflow-disposition:start' });
48
62
  const plan = workflow.pendingPlan || workflow.workflowPlan;
49
63
  if (!String(plan.strategy || '').startsWith('fable-')) {
64
+ activatePlan(workflow, plan);
50
65
  workflow.revision++;
51
66
  workflow.state = 'running';
67
+ workflow.escapes = [];
68
+ delete workflow.pendingPlan;
52
69
  delete workflow.blockReason;
53
- ensureWorkflowBudget(workflow);
54
70
  saveWorkflow(context);
55
71
  process.stdout.write(JSON.stringify({ state: workflow.state, workflowId: workflow.workflowId, revision: workflow.revision }) + '\n');
56
72
  return;
@@ -66,18 +82,13 @@ function main() {
66
82
  routerContract: { workflowPlan: plan }, stages, workspaceRoot: context.root,
67
83
  sessionId, workflowId: workflow.workflowId,
68
84
  });
69
- workflow.workflowPlan = plan;
70
- workflow.strategy = plan.strategy;
71
- workflow.tier = plan.tier;
72
- workflow.verification = plan.verification;
73
- workflow.mutationIsolation = plan.mutationIsolation || workflow.mutationIsolation;
85
+ activatePlan(workflow, plan);
74
86
  workflow.runId = run.runId;
75
87
  workflow.revision++;
76
88
  workflow.state = 'running';
77
89
  workflow.escapes = [];
78
90
  delete workflow.pendingPlan;
79
91
  delete workflow.blockReason;
80
- ensureWorkflowBudget(workflow);
81
92
  } else if (args.command === 'revision') {
82
93
  recordBudgetEvent(context, 'revision', { evidence: args.evidence || 'workflow-disposition:revision' });
83
94
  } else if (args.command === 'reset-budget') {
@@ -9,6 +9,15 @@ const { key, classifyShell, workspaceFingerprint, shellProbeKey, cwdOf, commandO
9
9
  const DIRECT = new Set(['Edit', 'Write', 'apply_patch']);
10
10
  const SHELL = new Set(['Bash', 'PowerShell', 'exec_command']);
11
11
 
12
+ function shellTimeoutMs(payload) {
13
+ const input = payload?.tool_input || payload?.input || {};
14
+ for (const value of [input.timeout_ms, input.timeoutMs, input.timeout, payload.timeout_ms, payload.timeoutMs]) {
15
+ const parsed = Number(value);
16
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
17
+ }
18
+ return null;
19
+ }
20
+
12
21
  function isController(command, cwd) {
13
22
  // One trusted local executable, literal arguments, no shell evaluation.
14
23
  if (/[;&|`\r\n<>$(){}]/.test(command)) return false;
@@ -62,11 +71,31 @@ function decide(payload) {
62
71
 
63
72
  // Shell safety and shell mutation accounting are intentionally separate.
64
73
  // `untrusted` means "not proven read-only", not "a mutation occurred".
65
- // Capture an opaque pre-execution workspace fingerprint; the post-tool
66
- // observer consumes an iteration only when workspace content actually changed.
67
- const fingerprint = workspaceFingerprint(cwd);
74
+ // Capture an opaque pre-execution workspace fingerprint when possible. A
75
+ // non-major workflow may run outside Git, where mutation observation is an
76
+ // accounting aid rather than an isolation boundary; degrade conservatively
77
+ // instead of blocking the user's command (#162).
78
+ let fingerprint;
79
+ try {
80
+ fingerprint = workspaceFingerprint(cwd);
81
+ } catch (error) {
82
+ if (isMajorWorkflow(workflow)) throw error;
83
+ if (workflow.strategy === 'iterative-single') {
84
+ recordBudgetEvent(context, 'iteration', { evidence: 'shell:unobservable-workspace' });
85
+ }
86
+ workflow.lastMutationAt = Date.now();
87
+ saveWorkflow(context);
88
+ return;
89
+ }
68
90
  const probeKey = shellProbeKey(payload, cwd);
69
- registerMutationProbe(context, probeKey, fingerprint, workflow.strategy === 'iterative-single');
91
+ registerMutationProbe(
92
+ context,
93
+ probeKey,
94
+ fingerprint,
95
+ workflow.strategy === 'iterative-single',
96
+ cwd,
97
+ shellTimeoutMs(payload)
98
+ );
70
99
  }
71
100
 
72
101
  readHookInput(decide);
@@ -4,7 +4,7 @@ description: "Scaffold a verified multi-agent workspace and select bounded speci
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Miya Daniel
7
- version: 0.10.0
7
+ version: 0.12.0
8
8
  ---
9
9
 
10
10
  # Multi-Agent Workspace
@@ -5,6 +5,18 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { getWorkspaceRoot, getRuntimeRoot, resolveProjectDocs } = require('./project-docs-resolver');
7
7
 
8
+ let emitLessonTelemetry = () => ({ ok: false, unavailable: true });
9
+ for (const relative of [
10
+ '../../hooks/scripts/lib/telemetry.js',
11
+ '../../../hooks/scripts/lib/telemetry.js',
12
+ ]) {
13
+ const candidate = path.resolve(__dirname, relative);
14
+ if (fs.existsSync(candidate)) {
15
+ ({ emitLessonTelemetry } = require(candidate));
16
+ break;
17
+ }
18
+ }
19
+
8
20
  function option(args, ...names) {
9
21
  for (const name of names) {
10
22
  const index = args.indexOf(name);
@@ -166,6 +178,22 @@ function retrieveMemoryRecords({ workspace, task = '', requirement = '', role =
166
178
  });
167
179
  }
168
180
 
181
+ for (const record of included) {
182
+ if (!record.origin?.lessonCandidateId) continue;
183
+ emitLessonTelemetry({
184
+ event: 'lesson_retrieved',
185
+ root,
186
+ sessionId: record.writer?.sessionId || null,
187
+ candidateId: record.origin.lessonCandidateId,
188
+ observedAt: new Date(nowMs).toISOString(),
189
+ status: 'success',
190
+ reasonCodes: [
191
+ 'scoped-memory-retrieval',
192
+ ...(record.retrieval?.reasonCodes || []),
193
+ ],
194
+ });
195
+ }
196
+
169
197
  return {
170
198
  schemaVersion: 1,
171
199
  workspace: root,