@hmharness/agent 0.9.0 → 0.11.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.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { baseTools, readFileTool, writeFileTool, listDirTool, runCommandTool, rememberTool, seeImageTool } from './tools.ts';
2
2
  export { manifestFor, capabilityReport, authorize, type CapabilityManifest, type CapabilityRisk, type PolicyMode } from './capability.ts';
3
3
  export { checkpointProject, createProject, findProject, interruptProject, listProjects, loadProject, projectFor, releaseProject, restoreCheckpoint, resumeBundle, transitionProject, attachRun, newProjectId, type CheckpointRef, type DecisionEntry, type ProjectRecord, type ProjectState, } from './project.ts';
4
+ export { mechanicalGate, parseVerdict, runPipeline, type PipelineOptions, type PipelineReport, type StageRecord, type StageRole, } from './pipeline.ts';
4
5
  export { buildSystemPrompt } from './prompt.ts';
5
6
  export { strings, type Locale, type Strings } from './i18n.ts';
6
7
  export { makeSpawnTool, MAX_SPAWN_DEPTH, type SpawnBase } from './spawn.ts';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { baseTools, readFileTool, writeFileTool, listDirTool, runCommandTool, rememberTool, seeImageTool } from "./tools.js";
2
2
  export { manifestFor, capabilityReport, authorize } from "./capability.js";
3
3
  export { checkpointProject, createProject, findProject, interruptProject, listProjects, loadProject, projectFor, releaseProject, restoreCheckpoint, resumeBundle, transitionProject, attachRun, newProjectId, } from "./project.js";
4
+ export { mechanicalGate, parseVerdict, runPipeline, } from "./pipeline.js";
4
5
  export { buildSystemPrompt } from "./prompt.js";
5
6
  export { strings } from "./i18n.js";
6
7
  export { makeSpawnTool, MAX_SPAWN_DEPTH } from "./spawn.js";
@@ -0,0 +1,73 @@
1
+ import { runLoop, type LoopResult, type ProviderConfig, type Registry, type ToolContext } from '@hmharness/kernel';
2
+ import type { DeviceTestOptions, DeviceTestStep } from '@hmharness/domain-harmony';
3
+ export type PipelineStage = 'plan' | 'code' | 'test' | 'review' | 'judge' | 'device';
4
+ /** stage labels used in runStage (repairer = the repair-loop role) */
5
+ export type StageRole = PipelineStage | 'repairer';
6
+ export interface StageRecord {
7
+ stage: StageRole;
8
+ attempt: number;
9
+ verdict: 'PASS' | 'FAIL' | 'n/a';
10
+ text: string;
11
+ turns: number;
12
+ toolUses: number;
13
+ reason: LoopResult['reason'];
14
+ }
15
+ export interface PipelineReport {
16
+ pipelineId: string;
17
+ task: string;
18
+ startedAt: string;
19
+ finishedAt: string;
20
+ status: 'completed' | 'budget' | 'error';
21
+ finalVerdict: 'PASS' | 'FAIL' | 'none';
22
+ stages: StageRecord[];
23
+ repairsUsed: number;
24
+ }
25
+ export interface PipelineOptions {
26
+ task: string;
27
+ provider: ProviderConfig;
28
+ registry: Registry;
29
+ ctx: ToolContext;
30
+ model: string;
31
+ home: string;
32
+ locale?: string;
33
+ /** per-stage turn cap (default 6) */
34
+ maxTurnsPerStage?: number;
35
+ /** repair-loop ceiling on FAIL verdicts (default 2) */
36
+ maxRepairs?: number;
37
+ /** hard global turn budget across stages (default 24) */
38
+ maxTotalTurns?: number;
39
+ signal?: AbortSignal;
40
+ /** injectable loop (tests); default kernel runLoop */
41
+ runLoopImpl?: typeof runLoop;
42
+ /** V3 device gate (ADR-0007): when set, run an on-device install/launch/
43
+ * log-marker/uninstall pass after the test stage and feed the four steps
44
+ * to the judge as mechanical evidence. Pure command execution - no model
45
+ * turns. */
46
+ deviceGate?: {
47
+ hdc: string;
48
+ target?: string;
49
+ hap: string;
50
+ bundle: string;
51
+ ability: string;
52
+ expectLog: string;
53
+ /** injectable runner (tests); default = domain-harmony runDeviceTest */
54
+ runDeviceTestImpl?: (o: DeviceTestOptions) => Promise<DeviceTestStep[]>;
55
+ };
56
+ }
57
+ /** Pull the judge's verdict line out of the final text; absence = FAIL
58
+ * (an judge that did not follow the contract did not render a verdict). */
59
+ export declare function parseVerdict(text: string): 'PASS' | 'FAIL';
60
+ /** Mechanical test gate (M2 ladder order): pure function, no model call.
61
+ * Returns null when there is nothing mechanical to assert. */
62
+ export declare function mechanicalGate(outputs: Array<{
63
+ name: string;
64
+ output: string;
65
+ isError: boolean;
66
+ }>, asserts?: Array<{
67
+ kind: 'contains' | 'not-contains';
68
+ value: string;
69
+ }>): {
70
+ pass: boolean;
71
+ detail: string;
72
+ } | null;
73
+ export declare function runPipeline(opts: PipelineOptions): Promise<PipelineReport>;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * @hmharness/agent - pipeline runtime (V3 first slice, ADR-0006)
3
+ * Blueprint V3 DoD chain, staged: plan → code → test → review → judge, with
4
+ * a bounded repair loop back to code+test on a FAIL verdict. Each stage is a
5
+ * runLoop call carrying the M7 role charter; the judge's `VERDICT: PASS|FAIL`
6
+ * line is the ONLY stage gate. Mechanical assertions run before any LLM judge
7
+ * (M2 evidence-ladder order, applied to orchestration).
8
+ *
9
+ * Everything lands as evidence: per-stage report (HMH_HOME/pipelines/<id>/)
10
+ * plus the M1 trajectories each runLoop already records.
11
+ */
12
+ import { mkdir, writeFile } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { runLoop } from '@hmharness/kernel';
15
+ import { buildSystemPrompt } from "./prompt.js";
16
+ import { roleCharter } from "./roles.js";
17
+ /** Pull the judge's verdict line out of the final text; absence = FAIL
18
+ * (an judge that did not follow the contract did not render a verdict). */
19
+ export function parseVerdict(text) {
20
+ const m = text.match(/VERDICT:\s*(PASS|FAIL)/i);
21
+ return m ? m[1].toUpperCase() : 'FAIL';
22
+ }
23
+ /** Mechanical test gate (M2 ladder order): pure function, no model call.
24
+ * Returns null when there is nothing mechanical to assert. */
25
+ export function mechanicalGate(outputs, asserts) {
26
+ const failedTool = outputs.find((o) => o.isError);
27
+ if (asserts && asserts.length > 0) {
28
+ for (const a of asserts) {
29
+ const hit = outputs.some((o) => o.output.toLowerCase().includes(a.value.toLowerCase()));
30
+ if (a.kind === 'contains' && !hit)
31
+ return { pass: false, detail: `mechanical: expected "${a.value}" in outputs` };
32
+ if (a.kind === 'not-contains' && hit)
33
+ return { pass: false, detail: `mechanical: forbidden "${a.value}" present` };
34
+ }
35
+ return { pass: true, detail: `mechanical assertions green (${asserts.length})` };
36
+ }
37
+ if (failedTool)
38
+ return { pass: false, detail: `mechanical: tool ${failedTool.name} errored` };
39
+ return null;
40
+ }
41
+ function stageMessages(opts, role, directive) {
42
+ const charter = roleCharter(role);
43
+ const system = buildSystemPrompt({
44
+ cwd: opts.ctx.cwd,
45
+ home: opts.home,
46
+ memory: '',
47
+ skills: '',
48
+ insights: '',
49
+ model: opts.model,
50
+ ...(opts.locale ? { locale: opts.locale } : {}),
51
+ }) + (charter ? '\n\n' + charter : '');
52
+ return [
53
+ { role: 'system', content: system },
54
+ { role: 'user', content: directive },
55
+ ];
56
+ }
57
+ const DIRECTIVES = {
58
+ plan: (task) => `Goal: ${task}\nProduce the numbered implementation plan (each step names its verification). Do not execute anything.`,
59
+ code: (task, extra) => `Goal: ${task}\n${extra}\nImplement the plan now (surgical edits, cheapest verification per step).`,
60
+ test: (task, extra) => `Goal: ${task}\n${extra}\nRun the verifications from the plan; probe edge cases; report input -> actual vs expected for each probe.`,
61
+ review: (_task, extra) => `Review the changes produced so far on disk.\n${extra}\nFindings first, severity-ordered, file:line evidence, no fixes.`,
62
+ judge: (_task, extra) => `Render the verdict for this pipeline run from EVIDENCE only.\n${extra}\nEnd with exactly "VERDICT: PASS" or "VERDICT: FAIL" plus one line why.`,
63
+ };
64
+ export async function runPipeline(opts) {
65
+ const maxTurns = opts.maxTurnsPerStage ?? 6;
66
+ const maxRepairs = opts.maxRepairs ?? 2;
67
+ const totalBudget = opts.maxTotalTurns ?? 24;
68
+ const run = opts.runLoopImpl ?? runLoop;
69
+ const pipelineId = `pipe_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
70
+ const dir = join(opts.home, 'pipelines', pipelineId);
71
+ await mkdir(dir, { recursive: true });
72
+ const stages = [];
73
+ let repairs = 0;
74
+ let spent = 0;
75
+ let status = 'completed';
76
+ const startedAt = new Date().toISOString();
77
+ const runStage = async (stage, attempt, directive) => {
78
+ // maxTurns is only a soft checkpoint in the kernel loop - the HARD caps
79
+ // are maxTotalTurns (clamped to this stage's remaining budget share) and
80
+ // a tightened idle detector, so one rambling stage cannot eat the whole
81
+ // pipeline budget (real smoke: review burned 47 turns without them).
82
+ const remaining = Math.max(1, totalBudget - spent);
83
+ const r = await run({
84
+ provider: opts.provider,
85
+ registry: opts.registry,
86
+ messages: stageMessages(opts, stage, directive),
87
+ ctx: opts.ctx,
88
+ maxTurns,
89
+ maxTotalTurns: Math.min(maxTurns, remaining),
90
+ maxIdleTurns: stage === 'plan' ? 3 : 4,
91
+ ...(opts.signal ? { signal: opts.signal } : {}),
92
+ });
93
+ spent += r.turns;
94
+ const rec = {
95
+ stage,
96
+ attempt,
97
+ verdict: stage === 'judge' ? parseVerdict(r.text) : 'n/a',
98
+ text: r.text.slice(0, 4000),
99
+ turns: r.turns,
100
+ toolUses: r.toolUses,
101
+ reason: r.reason,
102
+ };
103
+ stages.push(rec);
104
+ try {
105
+ await writeFile(join(dir, `stage-${String(stages.length).padStart(2, '0')}-${stage}.json`), JSON.stringify(rec, null, 2) + '\n', 'utf8');
106
+ }
107
+ catch { /* best-effort persistence */ }
108
+ return rec;
109
+ };
110
+ /** Device gate: run the four-step on-device pass, record it as a 'device'
111
+ * stage (no model turns spent), and mirror it into the repair context.
112
+ * Defined BEFORE the try block - the try body calls it (TDZ otherwise). */
113
+ const runDeviceGate = async () => {
114
+ const g = opts.deviceGate;
115
+ const runner = g.runDeviceTestImpl ?? (await import('@hmharness/domain-harmony')).runDeviceTest;
116
+ let steps;
117
+ try {
118
+ steps = await runner({ hdc: g.hdc, ...(g.target ? { target: g.target } : {}), hap: g.hap, bundle: g.bundle, ability: g.ability, expectLog: g.expectLog });
119
+ }
120
+ catch (err) {
121
+ steps = [{ step: 'device-gate', pass: false, detail: String(err).slice(0, 300) }];
122
+ }
123
+ const allPass = steps.every((s) => s.pass);
124
+ const rec = {
125
+ stage: 'device',
126
+ attempt: repairs + 1,
127
+ verdict: allPass ? 'PASS' : 'FAIL',
128
+ text: steps.map((s) => `${s.pass ? 'PASS' : 'FAIL'} ${s.step}: ${s.detail}`).join('\n').slice(0, 4000),
129
+ turns: 0,
130
+ toolUses: 0,
131
+ reason: 'final',
132
+ };
133
+ stages.push(rec);
134
+ try {
135
+ await writeFile(join(dir, `stage-${String(stages.length).padStart(2, '0')}-device.json`), JSON.stringify(rec, null, 2) + '\n', 'utf8');
136
+ }
137
+ catch { /* best-effort */ }
138
+ };
139
+ try {
140
+ // 1. plan
141
+ const plan = await runStage('plan', 1, DIRECTIVES.plan(opts.task, ''));
142
+ if (spent >= totalBudget) {
143
+ status = 'budget';
144
+ return await finish();
145
+ }
146
+ // 2. code (carries the plan)
147
+ await runStage('code', 1, DIRECTIVES.code(opts.task, `Approved plan:\n${plan.text.slice(0, 2000)}`));
148
+ if (spent >= totalBudget) {
149
+ status = 'budget';
150
+ return await finish();
151
+ }
152
+ // 3. test + repair loop (code+test rerun carries the reviewer/judge findings)
153
+ let testOut = await runStage('test', 1, DIRECTIVES.test(opts.task, `Plan:\n${plan.text.slice(0, 1500)}`));
154
+ if (spent >= totalBudget) {
155
+ status = 'budget';
156
+ return await finish();
157
+ }
158
+ // 3b. device gate (V3 slice, ADR-0007): mechanical on-device evidence,
159
+ // zero model turns; judge sees it in the evidence summary
160
+ if (opts.deviceGate)
161
+ await runDeviceGate();
162
+ let review = await runStage('review', 1, DIRECTIVES.review(opts.task, ''));
163
+ if (spent >= totalBudget) {
164
+ status = 'budget';
165
+ return await finish();
166
+ }
167
+ let judge = await runStage('judge', 1, DIRECTIVES.judge(opts.task, evidenceSummary(stages)));
168
+ while (judge.verdict === 'FAIL' && repairs < maxRepairs && spent < totalBudget) {
169
+ repairs++;
170
+ const findings = `${review.text.slice(0, 800)}\n\nJudge findings:\n${judge.text.slice(0, 800)}`;
171
+ await runStage('repairer', repairs, `Repair round ${repairs}.\n${findings}\nReproduce, fix minimally, re-verify.`);
172
+ if (spent >= totalBudget) {
173
+ status = 'budget';
174
+ break;
175
+ }
176
+ testOut = await runStage('test', repairs + 1, DIRECTIVES.test(opts.task, `Plan:\n${plan.text.slice(0, 1500)}\nRepair ${repairs} applied - re-verify.`));
177
+ if (opts.deviceGate)
178
+ await runDeviceGate();
179
+ review = await runStage('review', repairs + 1, DIRECTIVES.review(opts.task, `Repair ${repairs} was applied; focus on it.`));
180
+ if (spent >= totalBudget) {
181
+ status = 'budget';
182
+ break;
183
+ }
184
+ judge = await runStage('judge', repairs + 1, DIRECTIVES.judge(opts.task, evidenceSummary(stages)));
185
+ }
186
+ return await finish(judge && judge.verdict !== 'n/a' ? judge.verdict : 'FAIL');
187
+ }
188
+ catch (err) {
189
+ status = 'error';
190
+ stages.push({ stage: 'judge', attempt: 0, verdict: 'FAIL', text: String(err).slice(0, 500), turns: 0, toolUses: 0, reason: 'final' });
191
+ return await finish('FAIL');
192
+ }
193
+ async function finish(verdict = 'none') {
194
+ const report = {
195
+ pipelineId,
196
+ task: opts.task,
197
+ startedAt,
198
+ finishedAt: new Date().toISOString(),
199
+ status,
200
+ finalVerdict: verdict,
201
+ stages,
202
+ repairsUsed: repairs,
203
+ };
204
+ try {
205
+ await writeFile(join(dir, 'pipeline.report.json'), JSON.stringify(report, null, 2) + '\n', 'utf8');
206
+ }
207
+ catch { /* best-effort */ }
208
+ return report;
209
+ }
210
+ }
211
+ function evidenceSummary(stages) {
212
+ const relevant = stages.filter((s) => s.stage === 'test' || s.stage === 'review' || s.stage === 'device');
213
+ return relevant.map((s) => `[${s.stage} #${s.attempt}] ${s.text.slice(0, 600)}`).join('\n\n').slice(0, 3000);
214
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/agent",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",