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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +53 -0
- package/README.md +1 -0
- package/behavioral-evals/README.md +37 -0
- package/behavioral-evals/cases/self-evolve-retrieval-recurrence.yaml +48 -0
- package/behavioral-evals/paired-benchmark.js +211 -5
- package/ci/contract-integrity.test.js +68 -2
- package/ci/mechanism-2ah-workflow-lifecycle.test.js +258 -2
- package/ci/mechanism-32-paired-benchmark.test.js +139 -0
- package/ci/openai-plugin-package.test.js +17 -0
- package/ci/self-evolve-loop.test.js +37 -2
- package/ci/telemetry.test.js +31 -1
- package/docs/enforcement-locks.md +114 -0
- package/grill-with-docs/ADR-FORMAT.md +22 -0
- package/grill-with-docs/SKILL.md +1 -1
- package/hooks/scripts/lib/learning-opportunity.js +22 -0
- package/hooks/scripts/lib/telemetry.js +40 -0
- package/hooks/scripts/lib/workflow-isolation.js +10 -2
- package/hooks/scripts/lib/workflow-runtime.js +108 -5
- package/hooks/scripts/state-persist.js +9 -3
- package/hooks/scripts/workflow-disposition.js +19 -8
- package/hooks/scripts/workflow-gate.js +33 -4
- package/multi-agent-workspace/SKILL.md +1 -1
- package/multi-agent-workspace/scripts/index_memory.js +28 -0
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/contract-integrity/scripts/audit.js +536 -0
- package/plugins/harness-everything/hooks/scripts/lib/learning-opportunity.js +22 -0
- package/plugins/harness-everything/hooks/scripts/lib/telemetry.js +40 -0
- package/plugins/harness-everything/hooks/scripts/lib/workflow-isolation.js +10 -2
- package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +108 -5
- package/plugins/harness-everything/hooks/scripts/state-persist.js +9 -3
- package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +19 -8
- package/plugins/harness-everything/hooks/scripts/workflow-gate.js +33 -4
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/grill-with-docs/ADR-FORMAT.md +22 -0
- package/plugins/harness-everything/skills/grill-with-docs/SKILL.md +1 -1
- package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +1 -1
- package/plugins/harness-everything/skills/multi-agent-workspace/scripts/index_memory.js +28 -0
- package/plugins/harness-everything/skills/self-evolve/SKILL.md +1 -1
- package/plugins/harness-everything/skills/self-evolve/scripts/lesson-candidate.js +52 -0
- package/plugins/harness-everything/skills/to-spec/SKILL.md +1 -1
- package/plugins/harness-everything/skills/to-spec/references/process.md +26 -0
- package/plugins/harness-everything/skills/to-spec/templates/cli-reference.md +14 -0
- package/plugins/harness-everything/skills/to-spec/templates/dev-doc.md +14 -0
- package/plugins/harness-everything/skills/to-spec/templates/feature-spec.md +14 -0
- package/plugins/harness-everything/skills/to-spec/templates/schema-doc.md +14 -0
- package/plugins/harness-everything/skills/to-tickets/SKILL.md +1 -1
- package/plugins/harness-everything/skills/to-tickets/references/publishing-and-templates.md +20 -1
- package/plugins/harness-everything/skills/verification-loop/SKILL.md +3 -2
- package/plugins/harness-everything/skills/verification-loop/references/verification-phases.md +25 -1
- package/plugins/harness-everything/skills/verification-loop/scripts/contract-integrity-audit.js +31 -0
- package/plugins/harness-everything/skills/verification-loop/templates/verification-report.template.md +3 -0
- package/scripts/sync-openai-plugin.js +12 -0
- package/self-evolve/SKILL.md +1 -1
- package/self-evolve/scripts/lesson-candidate.js +52 -0
- package/telemetry/schemas/event.schema.json +141 -22
- package/telemetry/scripts/report.js +42 -0
- package/to-spec/SKILL.md +1 -1
- package/to-spec/references/process.md +26 -0
- package/to-spec/templates/cli-reference.md +14 -0
- package/to-spec/templates/dev-doc.md +14 -0
- package/to-spec/templates/feature-spec.md +14 -0
- package/to-spec/templates/schema-doc.md +14 -0
- package/to-tickets/SKILL.md +1 -1
- package/to-tickets/references/publishing-and-templates.md +20 -1
- package/verification-loop/SKILL.md +3 -2
- package/verification-loop/references/verification-phases.md +25 -1
- package/verification-loop/scripts/contract-integrity-audit.js +31 -0
- 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.
|
package/grill-with-docs/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
66
|
-
//
|
|
67
|
-
|
|
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(
|
|
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);
|
|
@@ -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,
|