@hmharness/agent 0.9.0 → 0.10.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,58 @@
1
+ import { runLoop, type LoopResult, type ProviderConfig, type Registry, type ToolContext } from '@hmharness/kernel';
2
+ export type PipelineStage = 'plan' | 'code' | 'test' | 'review' | 'judge';
3
+ /** stage labels used in runStage (repairer = the repair-loop role) */
4
+ export type StageRole = PipelineStage | 'repairer';
5
+ export interface StageRecord {
6
+ stage: StageRole;
7
+ attempt: number;
8
+ verdict: 'PASS' | 'FAIL' | 'n/a';
9
+ text: string;
10
+ turns: number;
11
+ toolUses: number;
12
+ reason: LoopResult['reason'];
13
+ }
14
+ export interface PipelineReport {
15
+ pipelineId: string;
16
+ task: string;
17
+ startedAt: string;
18
+ finishedAt: string;
19
+ status: 'completed' | 'budget' | 'error';
20
+ finalVerdict: 'PASS' | 'FAIL' | 'none';
21
+ stages: StageRecord[];
22
+ repairsUsed: number;
23
+ }
24
+ export interface PipelineOptions {
25
+ task: string;
26
+ provider: ProviderConfig;
27
+ registry: Registry;
28
+ ctx: ToolContext;
29
+ model: string;
30
+ home: string;
31
+ locale?: string;
32
+ /** per-stage turn cap (default 6) */
33
+ maxTurnsPerStage?: number;
34
+ /** repair-loop ceiling on FAIL verdicts (default 2) */
35
+ maxRepairs?: number;
36
+ /** hard global turn budget across stages (default 24) */
37
+ maxTotalTurns?: number;
38
+ signal?: AbortSignal;
39
+ /** injectable loop (tests); default kernel runLoop */
40
+ runLoopImpl?: typeof runLoop;
41
+ }
42
+ /** Pull the judge's verdict line out of the final text; absence = FAIL
43
+ * (an judge that did not follow the contract did not render a verdict). */
44
+ export declare function parseVerdict(text: string): 'PASS' | 'FAIL';
45
+ /** Mechanical test gate (M2 ladder order): pure function, no model call.
46
+ * Returns null when there is nothing mechanical to assert. */
47
+ export declare function mechanicalGate(outputs: Array<{
48
+ name: string;
49
+ output: string;
50
+ isError: boolean;
51
+ }>, asserts?: Array<{
52
+ kind: 'contains' | 'not-contains';
53
+ value: string;
54
+ }>): {
55
+ pass: boolean;
56
+ detail: string;
57
+ } | null;
58
+ export declare function runPipeline(opts: PipelineOptions): Promise<PipelineReport>;
@@ -0,0 +1,179 @@
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
+ try {
111
+ // 1. plan
112
+ const plan = await runStage('plan', 1, DIRECTIVES.plan(opts.task, ''));
113
+ if (spent >= totalBudget) {
114
+ status = 'budget';
115
+ return await finish();
116
+ }
117
+ // 2. code (carries the plan)
118
+ await runStage('code', 1, DIRECTIVES.code(opts.task, `Approved plan:\n${plan.text.slice(0, 2000)}`));
119
+ if (spent >= totalBudget) {
120
+ status = 'budget';
121
+ return await finish();
122
+ }
123
+ // 3. test + repair loop (code+test rerun carries the reviewer/judge findings)
124
+ let testOut = await runStage('test', 1, DIRECTIVES.test(opts.task, `Plan:\n${plan.text.slice(0, 1500)}`));
125
+ if (spent >= totalBudget) {
126
+ status = 'budget';
127
+ return await finish();
128
+ }
129
+ let review = await runStage('review', 1, DIRECTIVES.review(opts.task, ''));
130
+ if (spent >= totalBudget) {
131
+ status = 'budget';
132
+ return await finish();
133
+ }
134
+ let judge = await runStage('judge', 1, DIRECTIVES.judge(opts.task, evidenceSummary(stages)));
135
+ while (judge.verdict === 'FAIL' && repairs < maxRepairs && spent < totalBudget) {
136
+ repairs++;
137
+ const findings = `${review.text.slice(0, 800)}\n\nJudge findings:\n${judge.text.slice(0, 800)}`;
138
+ await runStage('repairer', repairs, `Repair round ${repairs}.\n${findings}\nReproduce, fix minimally, re-verify.`);
139
+ if (spent >= totalBudget) {
140
+ status = 'budget';
141
+ break;
142
+ }
143
+ testOut = await runStage('test', repairs + 1, DIRECTIVES.test(opts.task, `Plan:\n${plan.text.slice(0, 1500)}\nRepair ${repairs} applied - re-verify.`));
144
+ review = await runStage('review', repairs + 1, DIRECTIVES.review(opts.task, `Repair ${repairs} was applied; focus on it.`));
145
+ if (spent >= totalBudget) {
146
+ status = 'budget';
147
+ break;
148
+ }
149
+ judge = await runStage('judge', repairs + 1, DIRECTIVES.judge(opts.task, evidenceSummary(stages)));
150
+ }
151
+ return await finish(judge && judge.verdict !== 'n/a' ? judge.verdict : 'FAIL');
152
+ }
153
+ catch (err) {
154
+ status = 'error';
155
+ stages.push({ stage: 'judge', attempt: 0, verdict: 'FAIL', text: String(err).slice(0, 500), turns: 0, toolUses: 0, reason: 'final' });
156
+ return await finish('FAIL');
157
+ }
158
+ async function finish(verdict = 'none') {
159
+ const report = {
160
+ pipelineId,
161
+ task: opts.task,
162
+ startedAt,
163
+ finishedAt: new Date().toISOString(),
164
+ status,
165
+ finalVerdict: verdict,
166
+ stages,
167
+ repairsUsed: repairs,
168
+ };
169
+ try {
170
+ await writeFile(join(dir, 'pipeline.report.json'), JSON.stringify(report, null, 2) + '\n', 'utf8');
171
+ }
172
+ catch { /* best-effort */ }
173
+ return report;
174
+ }
175
+ }
176
+ function evidenceSummary(stages) {
177
+ const relevant = stages.filter((s) => s.stage === 'test' || s.stage === 'review');
178
+ return relevant.map((s) => `[${s.stage} #${s.attempt}] ${s.text.slice(0, 600)}`).join('\n\n').slice(0, 3000);
179
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/agent",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",