@event4u/agent-config 8.4.0 → 8.4.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/CHANGELOG.md +6 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/mcp/registry-manifest.json +1 -1
- package/package.json +4 -1
- package/src/agent-src/README.md +64 -0
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +497 -0
- package/src/agent-src/scripts/scan-seeder-violations.php +145 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +1087 -0
- package/src/agent-src/templates/AGENTS.md +34 -0
- package/src/agent-src/templates/scripts/README.md +195 -0
- package/src/agent-src/templates/scripts/check_memory.ts +662 -0
- package/src/agent-src/templates/scripts/check_memory_proposal.ts +370 -0
- package/src/agent-src/templates/scripts/historical-bug-patterns.example.yml +84 -0
- package/src/agent-src/templates/scripts/implement_ticket/__main__.ts +27 -0
- package/src/agent-src/templates/scripts/memory_hash.ts +355 -0
- package/src/agent-src/templates/scripts/memory_lookup.ts +1089 -0
- package/src/agent-src/templates/scripts/memory_report.ts +868 -0
- package/src/agent-src/templates/scripts/memory_signal.ts +444 -0
- package/src/agent-src/templates/scripts/memory_status.ts +215 -0
- package/src/agent-src/templates/scripts/ownership-map.example.yml +87 -0
- package/src/agent-src/templates/scripts/pr-risk-config.example.yml +76 -0
- package/src/agent-src/templates/scripts/pr_review_routing.ts +544 -0
- package/src/agent-src/templates/scripts/pr_risk_review.ts +0 -0
- package/src/agent-src/templates/scripts/telemetry/aggregator.ts +0 -0
- package/src/agent-src/templates/scripts/telemetry/boundary.ts +164 -0
- package/src/agent-src/templates/scripts/telemetry/engagement.ts +479 -0
- package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +394 -0
- package/src/agent-src/templates/scripts/telemetry/settings.ts +210 -0
- package/src/agent-src/templates/scripts/telemetry_record.ts +276 -0
- package/src/agent-src/templates/scripts/telemetry_report.ts +211 -0
- package/src/agent-src/templates/scripts/telemetry_status.ts +333 -0
- package/src/agent-src/templates/scripts/tier_usage_report.ts +619 -0
- package/src/agent-src/templates/scripts/work_engine/__main__.ts +14 -0
- package/src/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +1261 -0
- package/src/agent-src/templates/scripts/work_engine/_lib/user_global_paths.ts +329 -0
- package/src/agent-src/templates/scripts/work_engine/cli.ts +206 -0
- package/src/agent-src/templates/scripts/work_engine/cli_args.ts +249 -0
- package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +225 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/analyze.ts +125 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/implement.ts +189 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/index.ts +94 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/memory.ts +193 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/plan.ts +267 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +518 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/report.ts +379 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +268 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +258 -0
- package/src/agent-src/templates/scripts/work_engine/directives/index.ts +32 -0
- package/src/agent-src/templates/scripts/work_engine/directives/mixed/contract.ts +243 -0
- package/src/agent-src/templates/scripts/work_engine/directives/mixed/index.ts +108 -0
- package/src/agent-src/templates/scripts/work_engine/directives/mixed/stitch.ts +259 -0
- package/src/agent-src/templates/scripts/work_engine/directives/mixed/ui.ts +216 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/_passthrough.ts +40 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +241 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +216 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +506 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +325 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/index.ts +102 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +462 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +474 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +352 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.ts +33 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.ts +213 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/index.ts +111 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.ts +126 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/report.ts +112 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/test.ts +164 -0
- package/src/agent-src/templates/scripts/work_engine/dispatcher.ts +515 -0
- package/src/agent-src/templates/scripts/work_engine/emitters.ts +119 -0
- package/src/agent-src/templates/scripts/work_engine/errors.ts +24 -0
- package/src/agent-src/templates/scripts/work_engine/hook_bootstrap.ts +104 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.ts +189 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.ts +41 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.ts +89 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +193 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.ts +304 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.ts +110 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.ts +118 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/index.ts +17 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.ts +161 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.ts +45 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/trace.ts +134 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/context.ts +94 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/events.ts +58 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/exceptions.ts +85 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/index.ts +27 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/registry.ts +66 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/runner.ts +90 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/settings.ts +260 -0
- package/src/agent-src/templates/scripts/work_engine/input_builders.ts +260 -0
- package/src/agent-src/templates/scripts/work_engine/intent/classify.ts +466 -0
- package/src/agent-src/templates/scripts/work_engine/migration/v0_to_v1.ts +531 -0
- package/src/agent-src/templates/scripts/work_engine/orchestration.ts +366 -0
- package/src/agent-src/templates/scripts/work_engine/persona_policy.ts +97 -0
- package/src/agent-src/templates/scripts/work_engine/resolvers/diff.ts +135 -0
- package/src/agent-src/templates/scripts/work_engine/resolvers/file.ts +175 -0
- package/src/agent-src/templates/scripts/work_engine/resolvers/prompt.ts +115 -0
- package/src/agent-src/templates/scripts/work_engine/scoring/confidence.ts +415 -0
- package/src/agent-src/templates/scripts/work_engine/scoring/decision_engine.ts +466 -0
- package/src/agent-src/templates/scripts/work_engine/scoring/decision_trace.ts +298 -0
- package/src/agent-src/templates/scripts/work_engine/scoring/memory_visibility.ts +444 -0
- package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +252 -0
- package/src/agent-src/templates/scripts/work_engine/stack/runner.ts +745 -0
- package/src/agent-src/templates/scripts/work_engine/state.ts +1151 -0
- package/src/agent-src/templates/scripts/work_engine/state_io.ts +413 -0
- package/src/install/atomic.ts +111 -0
- package/src/install/conflict.ts +226 -0
- package/src/install/detect.ts +280 -0
- package/src/install/paths.ts +90 -0
- package/src/install/plan.ts +205 -0
- package/src/install/preflight.ts +210 -0
- package/src/install/selectedTools.ts +52 -0
- package/src/install/toolDetection.ts +120 -0
- package/src/install/txlog.ts +161 -0
- package/src/install/types.ts +175 -0
- package/src/install/wizard-plan.ts +216 -0
- package/src/scripts/prepack-check.mjs +108 -5
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State-file I/O helpers for the CLI entry point.
|
|
3
|
+
*
|
|
4
|
+
* TypeScript twin of `work_engine/state_io.py` (ADR-200 py2ts Phase 1 —
|
|
5
|
+
* work_engine foundation). Public API names stay snake_case to mirror the
|
|
6
|
+
* Python module 1:1 (per ADR-200 — Python style is part of the contract).
|
|
7
|
+
*
|
|
8
|
+
* Extracted from `cli.py` in P2.3 of `road-to-post-pr29-optimize.md`. Holds
|
|
9
|
+
* the format-preserving load/save pair, the v0 legacy serialiser, the JSON
|
|
10
|
+
* reader, the `DeliveryState` projection helpers, and the legacy-file
|
|
11
|
+
* migration hint. Behaviour is byte-identical to the pre-split version —
|
|
12
|
+
* Goldens stay green.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { DEFAULT_STATE_FILE, LEGACY_STATE_FILE, _FMT_V0, _FMT_V1 } from './cli_args.js';
|
|
19
|
+
import {
|
|
20
|
+
DeliveryState,
|
|
21
|
+
type Any as DeliveryAny,
|
|
22
|
+
} from './delivery_state.js';
|
|
23
|
+
import { _CLIError } from './errors.js';
|
|
24
|
+
import type {
|
|
25
|
+
WorkState} from './state.js';
|
|
26
|
+
import {
|
|
27
|
+
DEFAULT_DIRECTIVE_SET,
|
|
28
|
+
DEFAULT_INTENT,
|
|
29
|
+
SCHEMA_VERSION,
|
|
30
|
+
SchemaError,
|
|
31
|
+
type Dict,
|
|
32
|
+
type JsonValue,
|
|
33
|
+
from_dict as _state_from_dict,
|
|
34
|
+
to_dict as _state_to_dict,
|
|
35
|
+
} from './state.js';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Surface a migration hint when only the pre-1.15.0 file is present.
|
|
39
|
+
*
|
|
40
|
+
* The dispatcher renamed the default state file from
|
|
41
|
+
* `.implement-ticket-state.json` to `.work-state.json` in 1.15.0
|
|
42
|
+
* (alongside the `implement_ticket → work_engine` package move).
|
|
43
|
+
* Existing checkouts that still carry the legacy file would otherwise
|
|
44
|
+
* fail with a generic "no state file" message. This helper detects
|
|
45
|
+
* the legacy file in the same directory and points the user at the
|
|
46
|
+
* one-shot migration command instead.
|
|
47
|
+
*
|
|
48
|
+
* Only fires when `state_file` has the canonical default name and
|
|
49
|
+
* sits next to a legacy file — explicit `--state-file` overrides
|
|
50
|
+
* bypass the hint so power users can carry their own naming scheme.
|
|
51
|
+
*/
|
|
52
|
+
export function _maybe_raise_legacy_hint(state_file: string): void {
|
|
53
|
+
// Python compares `state_file.name` (the basename) against the default's
|
|
54
|
+
// basename. The constants are plain strings (no directory part), so
|
|
55
|
+
// `path.basename(state_file)` vs the constant matches `Path.name`.
|
|
56
|
+
if (path.basename(state_file) !== path.basename(DEFAULT_STATE_FILE)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// `state_file.with_name(LEGACY_STATE_FILE.name)` — same directory, legacy
|
|
60
|
+
// basename.
|
|
61
|
+
const legacy_candidate = path.join(path.dirname(state_file), path.basename(LEGACY_STATE_FILE));
|
|
62
|
+
if (!_isFile(legacy_candidate)) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
throw new _CLIError(
|
|
66
|
+
`Found legacy state file ${legacy_candidate} but no ` +
|
|
67
|
+
`${state_file}. The default state file was renamed in 1.15.0. ` +
|
|
68
|
+
`Run \`node node_modules/.bin/tsx work_engine/migration/v0_to_v1.ts ` +
|
|
69
|
+
`${legacy_candidate}\` to migrate, or pass \`--state-file ` +
|
|
70
|
+
`${legacy_candidate}\` to keep using the old name. See ` +
|
|
71
|
+
'docs/MIGRATION.md.',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Load `state_file` and tag it with the wire format detected. */
|
|
76
|
+
export function _load(state_file: string): [WorkState, string] {
|
|
77
|
+
const data = _read_json(state_file);
|
|
78
|
+
if (!_isPlainDict(data)) {
|
|
79
|
+
throw new _CLIError(
|
|
80
|
+
`State file ${state_file} must carry a JSON object; ` +
|
|
81
|
+
`got ${_pyTypeName(data)}.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const d = data as Record<string, JsonValue>;
|
|
85
|
+
|
|
86
|
+
// v1 declares `version`; v0 has none. Anything else is invalid.
|
|
87
|
+
if (_get(d, 'version', undefined) === SCHEMA_VERSION) {
|
|
88
|
+
try {
|
|
89
|
+
return [_state_from_dict(d as JsonValue), _FMT_V1];
|
|
90
|
+
} catch (exc) {
|
|
91
|
+
if (exc instanceof SchemaError) {
|
|
92
|
+
throw new _CLIError(`State file shape is invalid: ${exc.message}`);
|
|
93
|
+
}
|
|
94
|
+
throw exc;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if ('version' in d) {
|
|
98
|
+
throw new _CLIError(
|
|
99
|
+
`State file shape is invalid: unsupported version ` +
|
|
100
|
+
`${_pyRepr(_get(d, 'version', undefined))}; expected ${SCHEMA_VERSION}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (!('ticket' in d)) {
|
|
104
|
+
throw new _CLIError(
|
|
105
|
+
"State file shape is invalid: missing 'ticket' (v0) or " +
|
|
106
|
+
"'version' (v1) — file is neither shape.",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const migrated = migrate_payload(d);
|
|
111
|
+
return [_state_from_dict(migrated as JsonValue), _FMT_V0];
|
|
112
|
+
} catch (exc) {
|
|
113
|
+
if (exc instanceof SchemaError) {
|
|
114
|
+
throw new _CLIError(`State file shape is invalid: ${exc.message}`);
|
|
115
|
+
}
|
|
116
|
+
throw exc;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Project `work` into a `DeliveryState` for handler dispatch.
|
|
122
|
+
*
|
|
123
|
+
* R1 P4 S1 (Option A2): handlers continue to consume `DeliveryState`
|
|
124
|
+
* with `state.ticket`; the `WorkState` wrapper exists at the CLI
|
|
125
|
+
* boundary so the dispatcher's directive-set selection has a v1
|
|
126
|
+
* state object to read `directive_set` from. Mutable containers
|
|
127
|
+
* (`memory`, `changes`, `outcomes`, `questions`) are passed
|
|
128
|
+
* by reference — in-place mutations land on both objects without an
|
|
129
|
+
* explicit sync. Reassignments (`state.plan = …`, `state.report = …`)
|
|
130
|
+
* are mirrored back by {@link _sync_back}.
|
|
131
|
+
*/
|
|
132
|
+
export function _to_delivery(work: WorkState): DeliveryState {
|
|
133
|
+
return new DeliveryState({
|
|
134
|
+
ticket: work.input.data as Record<string, DeliveryAny>,
|
|
135
|
+
persona: work.persona,
|
|
136
|
+
memory: work.memory as Array<Record<string, DeliveryAny>>,
|
|
137
|
+
plan: work.plan,
|
|
138
|
+
changes: work.changes as Array<Record<string, DeliveryAny>>,
|
|
139
|
+
tests: work.tests,
|
|
140
|
+
verify: work.verify,
|
|
141
|
+
outcomes: work.outcomes,
|
|
142
|
+
questions: work.questions,
|
|
143
|
+
report: work.report,
|
|
144
|
+
ui_audit: work.ui_audit as Record<string, DeliveryAny> | null,
|
|
145
|
+
app_spec: work.app_spec as Record<string, DeliveryAny> | null,
|
|
146
|
+
ui_design: work.ui_design as Record<string, DeliveryAny> | null,
|
|
147
|
+
ui_scaffold: work.ui_scaffold as Record<string, DeliveryAny> | null,
|
|
148
|
+
ui_review: work.ui_review as Record<string, DeliveryAny> | null,
|
|
149
|
+
ui_polish: work.ui_polish as Record<string, DeliveryAny> | null,
|
|
150
|
+
contract: work.contract as Record<string, DeliveryAny> | null,
|
|
151
|
+
stitch: work.stitch as Record<string, DeliveryAny> | null,
|
|
152
|
+
stack: work.stack as Record<string, DeliveryAny> | null,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Mirror handler mutations from `delivery` back into `work`.
|
|
158
|
+
*
|
|
159
|
+
* Container fields are shared by reference (see {@link _to_delivery})
|
|
160
|
+
* so the assignment is a no-op for those — we still mirror them
|
|
161
|
+
* defensively to cover the case where a handler reassigned the
|
|
162
|
+
* attribute (`state.memory = [new_list]`) instead of mutating in
|
|
163
|
+
* place.
|
|
164
|
+
*/
|
|
165
|
+
export function _sync_back(work: WorkState, delivery: DeliveryState): void {
|
|
166
|
+
work.input.data = delivery.ticket as Dict;
|
|
167
|
+
work.persona = delivery.persona;
|
|
168
|
+
work.memory = delivery.memory as Dict[];
|
|
169
|
+
work.plan = delivery.plan as JsonValue;
|
|
170
|
+
work.changes = delivery.changes as Dict[];
|
|
171
|
+
work.tests = delivery.tests as JsonValue;
|
|
172
|
+
work.verify = delivery.verify as JsonValue;
|
|
173
|
+
work.outcomes = delivery.outcomes;
|
|
174
|
+
work.questions = delivery.questions;
|
|
175
|
+
work.report = delivery.report;
|
|
176
|
+
work.ui_audit = delivery.ui_audit as Dict | null;
|
|
177
|
+
work.app_spec = delivery.app_spec as Dict | null;
|
|
178
|
+
work.ui_design = delivery.ui_design as Dict | null;
|
|
179
|
+
work.ui_scaffold = delivery.ui_scaffold as Dict | null;
|
|
180
|
+
work.ui_review = delivery.ui_review as Dict | null;
|
|
181
|
+
work.ui_polish = delivery.ui_polish as Dict | null;
|
|
182
|
+
work.contract = delivery.contract as Dict | null;
|
|
183
|
+
work.stitch = delivery.stitch as Dict | null;
|
|
184
|
+
work.stack = delivery.stack as Dict | null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Persist `work` in the wire format it was loaded with.
|
|
189
|
+
*
|
|
190
|
+
* v1 emits the canonical envelope via {@link _state_to_dict}; v0 emits the
|
|
191
|
+
* legacy flat shape that `DeliveryState.asdict` used to produce, byte-identical
|
|
192
|
+
* to the pre-Phase-4 output so the Golden Transcript replay stays green.
|
|
193
|
+
*/
|
|
194
|
+
export function _save(state_file: string, work: WorkState, fmt: string): void {
|
|
195
|
+
fs.mkdirSync(path.dirname(state_file), { recursive: true });
|
|
196
|
+
const payload = fmt === _FMT_V1 ? _state_to_dict(work) : _to_v0_dict(work);
|
|
197
|
+
fs.writeFileSync(
|
|
198
|
+
state_file,
|
|
199
|
+
_jsonDumps(payload) + '\n',
|
|
200
|
+
'utf-8',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Serialise `work` in the legacy v0 wire format.
|
|
206
|
+
*
|
|
207
|
+
* Field order matches `DeliveryState` declaration order so
|
|
208
|
+
* pre-Phase-4 state files round-trip byte-equal.
|
|
209
|
+
*/
|
|
210
|
+
export function _to_v0_dict(work: WorkState): Dict {
|
|
211
|
+
return {
|
|
212
|
+
ticket: work.input.data,
|
|
213
|
+
persona: work.persona,
|
|
214
|
+
memory: work.memory,
|
|
215
|
+
plan: work.plan,
|
|
216
|
+
changes: work.changes,
|
|
217
|
+
tests: work.tests,
|
|
218
|
+
verify: work.verify,
|
|
219
|
+
outcomes: work.outcomes,
|
|
220
|
+
questions: work.questions,
|
|
221
|
+
report: work.report,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function _read_json(p: string): JsonValue {
|
|
226
|
+
let raw: string;
|
|
227
|
+
try {
|
|
228
|
+
raw = fs.readFileSync(p, 'utf-8');
|
|
229
|
+
} catch (exc) {
|
|
230
|
+
// Python `OSError` → "Cannot read {path}: {exc}".
|
|
231
|
+
throw new _CLIError(`Cannot read ${p}: ${_osErrorText(exc, p)}`);
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
return JSON.parse(raw) as JsonValue;
|
|
235
|
+
} catch (exc) {
|
|
236
|
+
// Python `json.JSONDecodeError` → "Invalid JSON in {path}: {exc}".
|
|
237
|
+
throw new _CLIError(`Invalid JSON in ${p}: ${(exc as Error).message}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Inlined v0→v1 payload migration ──────────────────────────────────────
|
|
242
|
+
//
|
|
243
|
+
// The Python source imports `migrate_payload` from
|
|
244
|
+
// `work_engine.migration.v0_to_v1`. A `.ts` twin may not import a `.py`
|
|
245
|
+
// (ADR-200), and the full `v0_to_v1` module (with its CLI / `migrate_file`
|
|
246
|
+
// surface) lands in a later phase. The `migrate_payload` slice `_load` needs
|
|
247
|
+
// is reproduced here verbatim so the load path stays byte-identical; when the
|
|
248
|
+
// `v0_to_v1.ts` twin lands it can re-export this or replace it 1:1.
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Return the v1 form of `payload`.
|
|
252
|
+
*
|
|
253
|
+
* A payload that already declares `version: 1` is returned unchanged
|
|
254
|
+
* (deep-copied via `JSON.parse(JSON.stringify(...))` so the caller cannot
|
|
255
|
+
* accidentally mutate the input). Anything else is treated as v0 and wrapped:
|
|
256
|
+
* `ticket` becomes `input.data`, `input.kind` is set to `"ticket"`, and the
|
|
257
|
+
* engine defaults are filled in.
|
|
258
|
+
*
|
|
259
|
+
* @throws SchemaError If the payload is not a dict, declares a higher version
|
|
260
|
+
* than this migration knows about, or lacks a `ticket` key.
|
|
261
|
+
*/
|
|
262
|
+
export function migrate_payload(payload: JsonValue): Dict {
|
|
263
|
+
if (!_isPlainDict(payload)) {
|
|
264
|
+
throw new SchemaError(
|
|
265
|
+
`v0 state must be a JSON object; got ${_pyTypeName(payload)}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const p = payload as Record<string, JsonValue>;
|
|
269
|
+
|
|
270
|
+
const declared_version = _get(p, 'version', undefined);
|
|
271
|
+
if (declared_version === SCHEMA_VERSION) {
|
|
272
|
+
return JSON.parse(JSON.stringify(p)) as Dict;
|
|
273
|
+
}
|
|
274
|
+
if (declared_version !== undefined && declared_version !== null) {
|
|
275
|
+
throw new SchemaError(
|
|
276
|
+
`cannot migrate from version ${_pyRepr(declared_version)} to ` +
|
|
277
|
+
`${SCHEMA_VERSION}; this script only handles v0 (no version key)`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!('ticket' in p)) {
|
|
282
|
+
throw new SchemaError(
|
|
283
|
+
"v0 state must carry a 'ticket' key; got keys: " +
|
|
284
|
+
_pyReprList(_sortedStr(Object.keys(p))),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const ticket = p['ticket'];
|
|
288
|
+
if (!_isPlainDict(ticket)) {
|
|
289
|
+
throw new SchemaError(
|
|
290
|
+
`v0 state.ticket must be a JSON object; got ${_pyTypeName(ticket)}`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
version: SCHEMA_VERSION,
|
|
296
|
+
input: { kind: 'ticket', data: ticket },
|
|
297
|
+
intent: DEFAULT_INTENT,
|
|
298
|
+
directive_set: DEFAULT_DIRECTIVE_SET,
|
|
299
|
+
persona: _get(p, 'persona', 'senior-engineer') as JsonValue,
|
|
300
|
+
memory: [...((_get(p, 'memory', []) as JsonValue[]) ?? [])],
|
|
301
|
+
plan: _get(p, 'plan', null) as JsonValue,
|
|
302
|
+
changes: [...((_get(p, 'changes', []) as JsonValue[]) ?? [])],
|
|
303
|
+
tests: _get(p, 'tests', null) as JsonValue,
|
|
304
|
+
verify: _get(p, 'verify', null) as JsonValue,
|
|
305
|
+
outcomes: { ...((_get(p, 'outcomes', {}) as Dict) ?? {}) },
|
|
306
|
+
questions: [...((_get(p, 'questions', []) as JsonValue[]) ?? [])],
|
|
307
|
+
report: _get(p, 'report', '') as JsonValue,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Python-parity primitives ────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Mirror Python `json.dumps(obj, indent=2, ensure_ascii=False)`.
|
|
315
|
+
*
|
|
316
|
+
* For round-tripped JSON, `JSON.stringify` with a 2-space indent matches
|
|
317
|
+
* CPython byte-for-byte: 2-space indent, `": "` key separator, `{}` / `[]`
|
|
318
|
+
* for empties, non-ASCII verbatim. Same rationale as `state.ts::jsonDumps`:
|
|
319
|
+
* no float/int tag survives JSON parsing, so the CPython `N.0` divergence
|
|
320
|
+
* cannot arise here.
|
|
321
|
+
*/
|
|
322
|
+
function _jsonDumps(obj: Dict): string {
|
|
323
|
+
return JSON.stringify(obj, null, 2);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** `dict.get(key, default)`. */
|
|
327
|
+
function _get(obj: Record<string, JsonValue>, key: string, dflt: unknown): unknown {
|
|
328
|
+
return key in obj ? obj[key] : dflt;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Python `isinstance(x, dict)` — plain object only (not array, not null). */
|
|
332
|
+
function _isPlainDict(value: unknown): boolean {
|
|
333
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function _isFile(p: string): boolean {
|
|
337
|
+
try {
|
|
338
|
+
return fs.statSync(p).isFile();
|
|
339
|
+
} catch {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Python `type(x).__name__` for the JSON value shapes the messages emit. */
|
|
345
|
+
function _pyTypeName(value: unknown): string {
|
|
346
|
+
if (value === null || value === undefined) {
|
|
347
|
+
return 'NoneType';
|
|
348
|
+
}
|
|
349
|
+
if (Array.isArray(value)) {
|
|
350
|
+
return 'list';
|
|
351
|
+
}
|
|
352
|
+
switch (typeof value) {
|
|
353
|
+
case 'string':
|
|
354
|
+
return 'str';
|
|
355
|
+
case 'boolean':
|
|
356
|
+
return 'bool';
|
|
357
|
+
case 'number':
|
|
358
|
+
return Number.isInteger(value) ? 'int' : 'float';
|
|
359
|
+
case 'object':
|
|
360
|
+
return 'dict';
|
|
361
|
+
default:
|
|
362
|
+
return typeof value;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Python `repr(x)` for the scalar shapes interpolated via `{value!r}`. */
|
|
367
|
+
function _pyRepr(value: unknown): string {
|
|
368
|
+
if (value === null || value === undefined) {
|
|
369
|
+
return 'None';
|
|
370
|
+
}
|
|
371
|
+
if (value === true) {
|
|
372
|
+
return 'True';
|
|
373
|
+
}
|
|
374
|
+
if (value === false) {
|
|
375
|
+
return 'False';
|
|
376
|
+
}
|
|
377
|
+
if (typeof value === 'string') {
|
|
378
|
+
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
379
|
+
}
|
|
380
|
+
return String(value);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Python `repr(list_of_str)` — `['a', 'b']`. */
|
|
384
|
+
function _pyReprList(values: string[]): string {
|
|
385
|
+
return '[' + values.map((v) => _pyRepr(v)).join(', ') + ']';
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Python `sorted(list_of_str)` — code-point ascending. */
|
|
389
|
+
function _sortedStr(values: string[]): string[] {
|
|
390
|
+
return [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Approximate the text of Python's `OSError` for a failed read. Python
|
|
395
|
+
* formats `errno`-based messages like "[Errno 2] No such file or directory:
|
|
396
|
+
* 'path'". Node's error `.message` is shaped differently, so this is one of
|
|
397
|
+
* the few non-byte-identical surfaces — the error *channel* (exit 2 via
|
|
398
|
+
* `_CLIError`) and the *prefix* ("Cannot read <path>: ") stay identical;
|
|
399
|
+
* only the trailing OS detail differs. Tests normalise this trailing detail.
|
|
400
|
+
*/
|
|
401
|
+
function _osErrorText(exc: unknown, p: string): string {
|
|
402
|
+
const e = exc as NodeJS.ErrnoException;
|
|
403
|
+
if (e && e.code === 'ENOENT') {
|
|
404
|
+
return `[Errno 2] No such file or directory: '${p}'`;
|
|
405
|
+
}
|
|
406
|
+
if (e && e.code === 'EISDIR') {
|
|
407
|
+
return `[Errno 21] Is a directory: '${p}'`;
|
|
408
|
+
}
|
|
409
|
+
if (e && e.code === 'EACCES') {
|
|
410
|
+
return `[Errno 13] Permission denied: '${p}'`;
|
|
411
|
+
}
|
|
412
|
+
return e && e.message ? e.message : String(exc);
|
|
413
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file writer — Phase A4 primitive.
|
|
3
|
+
*
|
|
4
|
+
* 1:1 port of the `tempfile.mkstemp` + `os.replace` pattern used across
|
|
5
|
+
* `scripts/install.py` (see `_write_consumer_bridge_marker` ~line 3036).
|
|
6
|
+
*
|
|
7
|
+
* Contract:
|
|
8
|
+
* 1. Open a sibling `.tmp.<rand>` file in the destination directory
|
|
9
|
+
* so the rename below is a same-filesystem operation (POSIX rename
|
|
10
|
+
* atomicity guarantee).
|
|
11
|
+
* 2. Write the payload, `fsync` to durable storage.
|
|
12
|
+
* 3. `rename()` to the final target — atomic on POSIX and Windows
|
|
13
|
+
* (Node's `fs.renameSync` maps to `MoveFileExA` with REPLACE).
|
|
14
|
+
* 4. On any failure between (1) and (3), unlink the temp file so we
|
|
15
|
+
* do not leak `.tmp.*` siblings.
|
|
16
|
+
*
|
|
17
|
+
* Sync API on purpose — the apply phase is fundamentally sequential
|
|
18
|
+
* (transaction-log ordering matters) and async I/O would add a real
|
|
19
|
+
* cost without any throughput win on the workloads we hit (≤ a few
|
|
20
|
+
* thousand small files per install).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
closeSync,
|
|
25
|
+
fsyncSync,
|
|
26
|
+
mkdirSync,
|
|
27
|
+
openSync,
|
|
28
|
+
readFileSync,
|
|
29
|
+
renameSync,
|
|
30
|
+
unlinkSync,
|
|
31
|
+
writeSync,
|
|
32
|
+
} from 'node:fs';
|
|
33
|
+
import { dirname, join } from 'node:path';
|
|
34
|
+
|
|
35
|
+
/** Options accepted by {@link atomicWriteFile}. */
|
|
36
|
+
export interface AtomicWriteOptions {
|
|
37
|
+
/** POSIX file mode (default `0o644`). Ignored on Windows. */
|
|
38
|
+
readonly mode?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Write `data` to `target` atomically.
|
|
43
|
+
*
|
|
44
|
+
* Throws on any I/O failure — the caller (apply phase) catches and
|
|
45
|
+
* surfaces an `E_DISK_FULL` / `E_PERM` SSE error frame (Phase B1).
|
|
46
|
+
*
|
|
47
|
+
* Parent directory is created with `recursive: true` so callers do not
|
|
48
|
+
* have to pre-mkdir each subtree.
|
|
49
|
+
*/
|
|
50
|
+
export function atomicWriteFile(
|
|
51
|
+
target: string,
|
|
52
|
+
data: Buffer | string,
|
|
53
|
+
options: AtomicWriteOptions = {},
|
|
54
|
+
): void {
|
|
55
|
+
const mode = options.mode ?? 0o644;
|
|
56
|
+
const parent = dirname(target);
|
|
57
|
+
mkdirSync(parent, { recursive: true });
|
|
58
|
+
|
|
59
|
+
const tmp = join(parent, `.tmp.${process.pid}.${randSuffix()}`);
|
|
60
|
+
let fd: number | null = null;
|
|
61
|
+
try {
|
|
62
|
+
fd = openSync(tmp, 'w', mode);
|
|
63
|
+
const buf = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
|
|
64
|
+
writeSync(fd, buf, 0, buf.length, 0);
|
|
65
|
+
fsyncSync(fd);
|
|
66
|
+
closeSync(fd);
|
|
67
|
+
fd = null;
|
|
68
|
+
renameSync(tmp, target);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (fd !== null) {
|
|
71
|
+
try {
|
|
72
|
+
closeSync(fd);
|
|
73
|
+
} catch {
|
|
74
|
+
/* swallow — outer error wins */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
unlinkSync(tmp);
|
|
79
|
+
} catch {
|
|
80
|
+
/* file may not exist yet — swallow */
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Append `line` to `target` atomically (read-modify-write).
|
|
88
|
+
*
|
|
89
|
+
* Wraps {@link atomicWriteFile} after a read of the current contents.
|
|
90
|
+
* Used by the transaction-log writer (`txlog.ts`) to grow the JSONL
|
|
91
|
+
* file one entry at a time without leaving a half-written line on
|
|
92
|
+
* crash. Newline is enforced — caller passes the bare payload.
|
|
93
|
+
*/
|
|
94
|
+
export function atomicAppendLine(target: string, line: string): void {
|
|
95
|
+
let existing = '';
|
|
96
|
+
try {
|
|
97
|
+
existing = readFileSync(target, 'utf8');
|
|
98
|
+
} catch {
|
|
99
|
+
existing = '';
|
|
100
|
+
}
|
|
101
|
+
const sep = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
|
102
|
+
atomicWriteFile(target, `${existing}${sep}${line}\n`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function randSuffix(): string {
|
|
106
|
+
// 12 hex chars — sufficient to avoid collisions under concurrent
|
|
107
|
+
// installs (we do not run them in production, but tests do).
|
|
108
|
+
return Math.floor(Math.random() * 0xffffffffffff)
|
|
109
|
+
.toString(16)
|
|
110
|
+
.padStart(12, '0');
|
|
111
|
+
}
|