@hmharness/agent 0.10.0 → 0.12.0

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.
@@ -1,5 +1,6 @@
1
1
  import { runLoop, type LoopResult, type ProviderConfig, type Registry, type ToolContext } from '@hmharness/kernel';
2
- export type PipelineStage = 'plan' | 'code' | 'test' | 'review' | 'judge';
2
+ import type { DeviceTestOptions, DeviceTestStep } from '@hmharness/domain-harmony';
3
+ export type PipelineStage = 'plan' | 'code' | 'test' | 'review' | 'judge' | 'device';
3
4
  /** stage labels used in runStage (repairer = the repair-loop role) */
4
5
  export type StageRole = PipelineStage | 'repairer';
5
6
  export interface StageRecord {
@@ -20,6 +21,12 @@ export interface PipelineReport {
20
21
  finalVerdict: 'PASS' | 'FAIL' | 'none';
21
22
  stages: StageRecord[];
22
23
  repairsUsed: number;
24
+ /** set when opts.release ran on a PASS verdict (ADR-0008) */
25
+ release?: {
26
+ projectId: string;
27
+ version: string;
28
+ checkpointId: string;
29
+ };
23
30
  }
24
31
  export interface PipelineOptions {
25
32
  task: string;
@@ -38,6 +45,28 @@ export interface PipelineOptions {
38
45
  signal?: AbortSignal;
39
46
  /** injectable loop (tests); default kernel runLoop */
40
47
  runLoopImpl?: typeof runLoop;
48
+ /** V3 device gate (ADR-0007): when set, run an on-device install/launch/
49
+ * log-marker/uninstall pass after the test stage and feed the four steps
50
+ * to the judge as mechanical evidence. Pure command execution - no model
51
+ * turns. */
52
+ deviceGate?: {
53
+ hdc: string;
54
+ target?: string;
55
+ hap: string;
56
+ bundle: string;
57
+ ability: string;
58
+ expectLog: string;
59
+ /** injectable runner (tests); default = domain-harmony runDeviceTest */
60
+ runDeviceTestImpl?: (o: DeviceTestOptions) => Promise<DeviceTestStep[]>;
61
+ };
62
+ /** V3 release slice (ADR-0008): when the final verdict is PASS, bind the
63
+ * work to the workspace's Project Runtime (M8): attach the run, checkpoint
64
+ * the tree, and record a release pinned to that checkpoint. FAIL/budget
65
+ * never release - no verdict, no version. */
66
+ release?: {
67
+ version: string;
68
+ notes?: string;
69
+ };
41
70
  }
42
71
  /** Pull the judge's verdict line out of the final text; absence = FAIL
43
72
  * (an judge that did not follow the contract did not render a verdict). */
package/dist/pipeline.js CHANGED
@@ -73,6 +73,7 @@ export async function runPipeline(opts) {
73
73
  let repairs = 0;
74
74
  let spent = 0;
75
75
  let status = 'completed';
76
+ let releaseTick;
76
77
  const startedAt = new Date().toISOString();
77
78
  const runStage = async (stage, attempt, directive) => {
78
79
  // maxTurns is only a soft checkpoint in the kernel loop - the HARD caps
@@ -107,6 +108,35 @@ export async function runPipeline(opts) {
107
108
  catch { /* best-effort persistence */ }
108
109
  return rec;
109
110
  };
111
+ /** Device gate: run the four-step on-device pass, record it as a 'device'
112
+ * stage (no model turns spent), and mirror it into the repair context.
113
+ * Defined BEFORE the try block - the try body calls it (TDZ otherwise). */
114
+ const runDeviceGate = async () => {
115
+ const g = opts.deviceGate;
116
+ const runner = g.runDeviceTestImpl ?? (await import('@hmharness/domain-harmony')).runDeviceTest;
117
+ let steps;
118
+ try {
119
+ steps = await runner({ hdc: g.hdc, ...(g.target ? { target: g.target } : {}), hap: g.hap, bundle: g.bundle, ability: g.ability, expectLog: g.expectLog });
120
+ }
121
+ catch (err) {
122
+ steps = [{ step: 'device-gate', pass: false, detail: String(err).slice(0, 300) }];
123
+ }
124
+ const allPass = steps.every((s) => s.pass);
125
+ const rec = {
126
+ stage: 'device',
127
+ attempt: repairs + 1,
128
+ verdict: allPass ? 'PASS' : 'FAIL',
129
+ text: steps.map((s) => `${s.pass ? 'PASS' : 'FAIL'} ${s.step}: ${s.detail}`).join('\n').slice(0, 4000),
130
+ turns: 0,
131
+ toolUses: 0,
132
+ reason: 'final',
133
+ };
134
+ stages.push(rec);
135
+ try {
136
+ await writeFile(join(dir, `stage-${String(stages.length).padStart(2, '0')}-device.json`), JSON.stringify(rec, null, 2) + '\n', 'utf8');
137
+ }
138
+ catch { /* best-effort */ }
139
+ };
110
140
  try {
111
141
  // 1. plan
112
142
  const plan = await runStage('plan', 1, DIRECTIVES.plan(opts.task, ''));
@@ -126,6 +156,10 @@ export async function runPipeline(opts) {
126
156
  status = 'budget';
127
157
  return await finish();
128
158
  }
159
+ // 3b. device gate (V3 slice, ADR-0007): mechanical on-device evidence,
160
+ // zero model turns; judge sees it in the evidence summary
161
+ if (opts.deviceGate)
162
+ await runDeviceGate();
129
163
  let review = await runStage('review', 1, DIRECTIVES.review(opts.task, ''));
130
164
  if (spent >= totalBudget) {
131
165
  status = 'budget';
@@ -141,6 +175,8 @@ export async function runPipeline(opts) {
141
175
  break;
142
176
  }
143
177
  testOut = await runStage('test', repairs + 1, DIRECTIVES.test(opts.task, `Plan:\n${plan.text.slice(0, 1500)}\nRepair ${repairs} applied - re-verify.`));
178
+ if (opts.deviceGate)
179
+ await runDeviceGate();
144
180
  review = await runStage('review', repairs + 1, DIRECTIVES.review(opts.task, `Repair ${repairs} was applied; focus on it.`));
145
181
  if (spent >= totalBudget) {
146
182
  status = 'budget';
@@ -148,7 +184,23 @@ export async function runPipeline(opts) {
148
184
  }
149
185
  judge = await runStage('judge', repairs + 1, DIRECTIVES.judge(opts.task, evidenceSummary(stages)));
150
186
  }
151
- return await finish(judge && judge.verdict !== 'n/a' ? judge.verdict : 'FAIL');
187
+ const verdict = judge && judge.verdict !== 'n/a' ? judge.verdict : 'FAIL';
188
+ // V3 release slice: only a PASS verdict earns a version (ADR-0008)
189
+ if (verdict === 'PASS' && opts.release) {
190
+ try {
191
+ const P = await import("./project.js");
192
+ const proj = await P.projectFor(opts.home, opts.ctx.cwd);
193
+ await P.attachRun(opts.home, proj, pipelineId);
194
+ const cp = await P.checkpointProject(opts.home, proj, `pipeline ${pipelineId}`);
195
+ await P.releaseProject(opts.home, proj, opts.release.version, opts.release.notes ?? `pipeline ${pipelineId} VERDICT: PASS (${stages.length} stages, ${repairs} repairs)`);
196
+ releaseTick = { projectId: proj.projectId, version: opts.release.version, checkpointId: cp.id };
197
+ }
198
+ catch (err) {
199
+ // release is bookkeeping - record the failure, never fail the pipeline
200
+ stages.push({ stage: 'judge', attempt: 0, verdict: 'n/a', text: `release binding failed: ${String(err).slice(0, 200)}`, turns: 0, toolUses: 0, reason: 'final' });
201
+ }
202
+ }
203
+ return await finish(verdict);
152
204
  }
153
205
  catch (err) {
154
206
  status = 'error';
@@ -165,6 +217,7 @@ export async function runPipeline(opts) {
165
217
  finalVerdict: verdict,
166
218
  stages,
167
219
  repairsUsed: repairs,
220
+ ...(releaseTick ? { release: releaseTick } : {}),
168
221
  };
169
222
  try {
170
223
  await writeFile(join(dir, 'pipeline.report.json'), JSON.stringify(report, null, 2) + '\n', 'utf8');
@@ -174,6 +227,6 @@ export async function runPipeline(opts) {
174
227
  }
175
228
  }
176
229
  function evidenceSummary(stages) {
177
- const relevant = stages.filter((s) => s.stage === 'test' || s.stage === 'review');
230
+ const relevant = stages.filter((s) => s.stage === 'test' || s.stage === 'review' || s.stage === 'device');
178
231
  return relevant.map((s) => `[${s.stage} #${s.attempt}] ${s.text.slice(0, 600)}`).join('\n\n').slice(0, 3000);
179
232
  }
package/dist/project.js CHANGED
@@ -192,6 +192,13 @@ export async function checkpointProject(home, rec, label) {
192
192
  else {
193
193
  const rel = `artifacts/cp-${Date.now().toString(36)}`;
194
194
  const dest = join(projDir(home, rec.projectId), rel);
195
+ // the copy fallback cannot handle the workspace containing the destination
196
+ // (workspace == home in degenerate setups) - say so plainly instead of
197
+ // letting cp throw EINVAL halfway through
198
+ const norm = (p) => resolve(p).replace(/[\\/]+$/, '').toLowerCase();
199
+ if (norm(dest).startsWith(norm(rec.workspace) + '\\') || norm(dest).startsWith(norm(rec.workspace) + '/')) {
200
+ throw new Error(`checkpoint copy fallback requires the project workspace (${rec.workspace}) to be outside HMH_HOME (${home})`);
201
+ }
195
202
  await mkdir(dest, { recursive: true });
196
203
  await cp(rec.workspace, dest, { recursive: true, filter: (src) => !COPY_SKIP.has(src.split(/[\\/]/).pop() ?? '') });
197
204
  let files = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/agent",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "hmharness agent execution layer: base tools, system prompt, sub-agent spawn, and the shared task runner that frontends (cli, web) drive.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "build": "tsc -p tsconfig.build.json"
16
16
  },
17
17
  "dependencies": {
18
- "@hmharness/domain-harmony": "0.8.0",
18
+ "@hmharness/domain-harmony": "0.11.0",
19
19
  "@hmharness/domain-ops": "0.8.0",
20
20
  "@hmharness/evolution": "0.9.0",
21
21
  "@hmharness/kernel": "0.9.0",