supipowers 2.0.2 → 2.1.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.
Files changed (76) hide show
  1. package/README.md +5 -6
  2. package/package.json +4 -2
  3. package/skills/harness/SKILL.md +1 -0
  4. package/src/bootstrap.ts +5 -133
  5. package/src/config/defaults.ts +5 -5
  6. package/src/config/loader.ts +1 -0
  7. package/src/config/schema.ts +2 -6
  8. package/src/context-mode/knowledge/store.ts +381 -43
  9. package/src/context-mode/tools.ts +41 -3
  10. package/src/deps/registry.ts +1 -12
  11. package/src/fix-pr/assessment.ts +1 -0
  12. package/src/fix-pr/prompt-builder.ts +1 -0
  13. package/src/git/commit.ts +76 -18
  14. package/src/harness/command.ts +103 -6
  15. package/src/harness/default-agents/docs.md +39 -0
  16. package/src/harness/docs/config.ts +29 -0
  17. package/src/harness/docs/glob-match.ts +27 -0
  18. package/src/harness/docs/index-renderer.ts +82 -0
  19. package/src/harness/docs/provenance.ts +125 -0
  20. package/src/harness/docs/regen-decision.ts +167 -0
  21. package/src/harness/docs/representative-files.ts +175 -0
  22. package/src/harness/docs/source-hash.ts +106 -0
  23. package/src/harness/docs/validator.ts +233 -0
  24. package/src/harness/hooks/layer-context-inject.ts +35 -1
  25. package/src/harness/hooks/register.ts +24 -3
  26. package/src/harness/pipeline.ts +20 -5
  27. package/src/harness/pr-comment/baseline.ts +105 -0
  28. package/src/harness/pr-comment/ci-env.ts +120 -0
  29. package/src/harness/pr-comment/gh-poster.ts +227 -0
  30. package/src/harness/pr-comment/handler.ts +198 -0
  31. package/src/harness/pr-comment/render.ts +297 -0
  32. package/src/harness/pr-comment/status.ts +95 -0
  33. package/src/harness/pr-comment/types.ts +73 -0
  34. package/src/harness/pr-comment/workflow-summary.ts +47 -0
  35. package/src/harness/project-paths.ts +95 -0
  36. package/src/harness/stages/design.ts +1 -0
  37. package/src/harness/stages/discover.ts +1 -13
  38. package/src/harness/stages/docs.ts +708 -0
  39. package/src/harness/stages/implement-apply.ts +877 -0
  40. package/src/harness/stages/implement.ts +64 -51
  41. package/src/harness/stages/plan.ts +25 -16
  42. package/src/harness/stages/validate.ts +370 -0
  43. package/src/harness/storage.ts +142 -0
  44. package/src/harness/tools.ts +130 -0
  45. package/src/mempalace/bridge.ts +207 -41
  46. package/src/mempalace/config.ts +10 -4
  47. package/src/mempalace/format.ts +122 -6
  48. package/src/mempalace/hooks.ts +204 -56
  49. package/src/mempalace/installer-helper.ts +18 -4
  50. package/src/mempalace/python/mempalace_bridge.py +128 -3
  51. package/src/mempalace/runtime.ts +53 -16
  52. package/src/mempalace/schema.ts +151 -30
  53. package/src/mempalace/session-summary.ts +5 -0
  54. package/src/mempalace/tool.ts +17 -4
  55. package/src/mempalace/upstream-limits.ts +69 -0
  56. package/src/planning/approval-flow.ts +25 -2
  57. package/src/planning/planning-ask-tool.ts +34 -4
  58. package/src/planning/system-prompt.ts +1 -1
  59. package/src/tool-catalog/active-tool-controller.ts +0 -22
  60. package/src/tool-catalog/active-tool-planner.ts +0 -26
  61. package/src/tool-catalog/tool-groups.ts +1 -9
  62. package/src/types.ts +87 -8
  63. package/src/ui-design/session.ts +114 -8
  64. package/src/utils/executable.ts +10 -1
  65. package/src/workspace/state-paths.ts +1 -1
  66. package/src/commands/mcp.ts +0 -814
  67. package/src/mcp/activation.ts +0 -77
  68. package/src/mcp/config.ts +0 -223
  69. package/src/mcp/docs.ts +0 -154
  70. package/src/mcp/gateway.ts +0 -103
  71. package/src/mcp/lifecycle.ts +0 -79
  72. package/src/mcp/manager-tool.ts +0 -104
  73. package/src/mcp/mcpc.ts +0 -113
  74. package/src/mcp/registry.ts +0 -98
  75. package/src/mcp/triggers.ts +0 -62
  76. package/src/mcp/types.ts +0 -95
@@ -0,0 +1,708 @@
1
+ /**
2
+ * DOCS stage runner.
3
+ *
4
+ * In the `extensive` tier this stage produces one per-layer agent-only knowledge document
5
+ * at `docs/layers/<id>.md` plus a mechanical index at `docs/README.md`. The first 30 LOC
6
+ * of each layer doc (the `## Agent context` section) replaces the addendum the
7
+ * `layer-context-inject` hook would otherwise derive from `docs/architecture.md`.
8
+ *
9
+ * In the `simple` tier the stage is a no-op (returns `skipped`).
10
+ *
11
+ * The stage:
12
+ * 1. Reads the persisted design spec to recover the layer rules.
13
+ * 2. Enumerates files per layer glob (cwd-relative, sorted) and picks representative
14
+ * files for the subagent input bundle.
15
+ * 3. Computes a deterministic `sourceHash` per layer.
16
+ * 4. Decides which layers need to regen, skip, or are user-edited (preserved).
17
+ * 5. Dispatches subagents in parallel (bounded by config); each subagent calls
18
+ * `harness_docs_record` exactly once. The tool validates synchronously.
19
+ * 6. On success, atomically promotes the staged docs to the repo: layer files first,
20
+ * index last.
21
+ *
22
+ * Failure mode: any layer that fails validation twice causes the stage to return
23
+ * `blocked` with a structured per-layer error list. Already-staged layers stay in
24
+ * staging; the index is not written; the previous repo doc tree is untouched.
25
+ */
26
+
27
+ import * as crypto from "node:crypto";
28
+ import * as fs from "node:fs";
29
+ import * as path from "node:path";
30
+
31
+ import type { Platform } from "../../platform/types.js";
32
+ import type {
33
+ HarnessDocsConfig,
34
+ HarnessLayerRule,
35
+ } from "../../types.js";
36
+ import {
37
+ loadHarnessDesignSpecJson,
38
+ loadHarnessDiscover,
39
+ loadHarnessDocsLayerStaging,
40
+ loadHarnessSession,
41
+ promoteHarnessDocsToRepo,
42
+ saveHarnessDocsIndexStaging,
43
+ } from "../storage.js";
44
+ import {
45
+ getHarnessRepoDocsLayerPath,
46
+ getHarnessRepoDocsReadmePath,
47
+ } from "../project-paths.js";
48
+ import {
49
+ type HarnessStageRunResult,
50
+ type HarnessStageRunner,
51
+ type HarnessStageRunnerContext,
52
+ buildHarnessAgentDisplayName,
53
+ nowIso,
54
+ } from "../stage-runner.js";
55
+ import {
56
+ computeLayerSourceHash,
57
+ type PeerLayerFingerprint,
58
+ type RepresentativeFileFingerprint,
59
+ sha256,
60
+ } from "../docs/source-hash.js";
61
+ import { decideRegenSet } from "../docs/regen-decision.js";
62
+ import {
63
+ renderRepresentativeBlock,
64
+ selectRepresentativeFiles,
65
+ type RepresentativeFileEntry,
66
+ } from "../docs/representative-files.js";
67
+ import { renderDocsIndex } from "../docs/index-renderer.js";
68
+ import { matchesLayerGlob } from "../docs/glob-match.js";
69
+ import { resolveDocsConfig } from "../docs/config.js";
70
+ import {
71
+ registerDocsLayerExpectation,
72
+ clearDocsLayerExpectation,
73
+ } from "../tools.js";
74
+
75
+ const DOCS_AGENT_PROMPT_PATH = new URL("../default-agents/docs.md", import.meta.url);
76
+
77
+ export interface DocsStageInput {
78
+ /**
79
+ * Optional override of the tier read from the session manifest. When absent, the stage
80
+ * resolves tier from the session manifest (`docsTier`), falling back to `"simple"`.
81
+ */
82
+ tierOverride?: "simple" | "extensive";
83
+ /** Hard cap on layers to dispatch in this run (defensive; bounded by config too). */
84
+ maxUnitsOverride?: number;
85
+ /** Test-only hook: replace `platform.createAgentSession` for deterministic runs. */
86
+ agentSessionFactory?: (
87
+ platform: Platform,
88
+ options: { cwd: string; agentId: string; agentDisplayName: string },
89
+ ) => Promise<AgentSessionLike>;
90
+ }
91
+
92
+ interface AgentSessionLike {
93
+ prompt(text: string, opts?: { expandPromptTemplates?: boolean }): Promise<void>;
94
+ dispose(): Promise<void>;
95
+ }
96
+
97
+ export class HarnessDocsStage implements HarnessStageRunner {
98
+ readonly stage = "docs" as const;
99
+
100
+ constructor(private readonly input: DocsStageInput = {}) {}
101
+
102
+ async isReady(ctx: HarnessStageRunnerContext): Promise<boolean> {
103
+ // Requires the design spec to exist (Design must be complete).
104
+ return loadHarnessDesignSpecJson(ctx.paths, ctx.cwd, ctx.sessionId).ok;
105
+ }
106
+
107
+ async isComplete(ctx: HarnessStageRunnerContext): Promise<boolean> {
108
+ const tier = await this.resolveTier(ctx);
109
+ if (tier === "simple") return true;
110
+
111
+ const designResult = loadHarnessDesignSpecJson(ctx.paths, ctx.cwd, ctx.sessionId);
112
+ if (!designResult.ok) return false;
113
+ const layers = designResult.value.layerRules;
114
+ if (layers.length < 2) return true; // degenerate "single" — no per-layer docs
115
+
116
+ const config = resolveDocsConfig(ctx.paths, ctx.cwd);
117
+ const promptVersion = await readPromptVersion();
118
+ const expected = await computeAllLayerSourceHashes({
119
+ ctx,
120
+ layers,
121
+ promptVersion,
122
+ });
123
+ for (const layer of layers) {
124
+ const docPath = getHarnessRepoDocsLayerPath(ctx.paths, ctx.cwd, layer.layer);
125
+ if (!fs.existsSync(docPath)) return false;
126
+ const contents = fs.readFileSync(docPath, "utf8");
127
+ const recorded = extractFrontmatterSourceHash(contents);
128
+ const expectedHash = expected.get(layer.layer);
129
+ if (!recorded || !expectedHash) return false;
130
+ if (recorded !== expectedHash) return false;
131
+ }
132
+ // Index must exist too.
133
+ const indexPath = getHarnessRepoDocsReadmePath(ctx.paths, ctx.cwd);
134
+ if (!fs.existsSync(indexPath)) return false;
135
+ void config; // currently unused at completion-check time; reserved for future LOC checks.
136
+ return true;
137
+ }
138
+
139
+ async run(ctx: HarnessStageRunnerContext): Promise<HarnessStageRunResult> {
140
+ const tier = await this.resolveTier(ctx);
141
+ if (tier === "simple") {
142
+ return {
143
+ status: "skipped",
144
+ stage: this.stage,
145
+ artifactPaths: [],
146
+ details: { reason: "docs tier=simple; per-layer docs disabled" },
147
+ };
148
+ }
149
+
150
+ const designResult = loadHarnessDesignSpecJson(ctx.paths, ctx.cwd, ctx.sessionId);
151
+ if (!designResult.ok) {
152
+ return {
153
+ status: "blocked",
154
+ stage: this.stage,
155
+ artifactPaths: [],
156
+ blocker: {
157
+ code: "design-spec-missing",
158
+ message: "docs stage requires a persisted design spec.",
159
+ },
160
+ };
161
+ }
162
+
163
+ const layers = designResult.value.layerRules;
164
+ if (layers.length === 0) {
165
+ return {
166
+ status: "blocked",
167
+ stage: this.stage,
168
+ artifactPaths: [],
169
+ blocker: {
170
+ code: "layer-rules-missing",
171
+ message: "docs stage requires ≥1 layer rule (Design produces these).",
172
+ },
173
+ };
174
+ }
175
+ if (layers.length < 2) {
176
+ // Degenerate single-layer architecture: Tier 1 docs cover it; nothing to do.
177
+ return {
178
+ status: "skipped",
179
+ stage: this.stage,
180
+ artifactPaths: [],
181
+ details: { reason: "fewer than 2 layer rules; per-layer docs collapse to Tier 1" },
182
+ };
183
+ }
184
+
185
+ const config = resolveDocsConfig(ctx.paths, ctx.cwd);
186
+ const maxUnits = this.input.maxUnitsOverride ?? config.max_units;
187
+ if (layers.length > maxUnits) {
188
+ return {
189
+ status: "blocked",
190
+ stage: this.stage,
191
+ artifactPaths: [],
192
+ blocker: {
193
+ code: "too-many-layers",
194
+ message: `docs stage refuses to run on ${layers.length} layers (cap=${maxUnits}); raise harness.docs.max_units or shrink the layer set.`,
195
+ },
196
+ };
197
+ }
198
+
199
+ const promptVersion = await readPromptVersion();
200
+ const layerInputs = await buildLayerInputs({ ctx, layers, promptVersion });
201
+
202
+ const expectedHashes = new Map(
203
+ layerInputs.map((entry) => [entry.layer.layer, entry.sourceHash] as const),
204
+ );
205
+ const decision = decideRegenSet({
206
+ paths: ctx.paths,
207
+ cwd: ctx.cwd,
208
+ layers,
209
+ expectedSourceHashes: expectedHashes,
210
+ });
211
+
212
+ const regenLayers = layerInputs.filter((entry) => decision.regen.includes(entry.layer.layer));
213
+
214
+ // Subagent dispatch — bounded parallelism.
215
+ const recordedAt = nowIso(ctx);
216
+ const dispatchErrors: { layerId: string; errors: string[] }[] = [];
217
+ if (regenLayers.length > 0) {
218
+ const limit = config.max_concurrent_subagents ?? regenLayers.length;
219
+ await runWithConcurrencyLimit(regenLayers, Math.max(1, limit), async (entry) => {
220
+ const result = await orchestrateLayerSubagent({
221
+ ctx,
222
+ entry,
223
+ config,
224
+ recordedAt,
225
+ factory: this.input.agentSessionFactory,
226
+ });
227
+ if (!result.ok) {
228
+ dispatchErrors.push({ layerId: entry.layer.layer, errors: result.errors });
229
+ }
230
+ });
231
+ }
232
+
233
+ if (dispatchErrors.length > 0) {
234
+ return {
235
+ status: "blocked",
236
+ stage: this.stage,
237
+ artifactPaths: [],
238
+ blocker: {
239
+ code: "doc-generation-failed",
240
+ message: `docs stage failed for ${dispatchErrors.length} layer(s): ${dispatchErrors
241
+ .map((e) => `${e.layerId} → ${e.errors.join("; ")}`)
242
+ .join(" | ")}`,
243
+ },
244
+ details: { failedLayers: dispatchErrors },
245
+ };
246
+ }
247
+
248
+ // Render the index from the merged (regenerated + skipped) layer set. User-edited
249
+ // layers are still listed — the index points at the file the user maintains.
250
+ const indexMarkdown = renderDocsIndex({
251
+ layers,
252
+ sessionId: ctx.sessionId,
253
+ generatedAt: recordedAt,
254
+ maxLoc: config.max_index_loc,
255
+ });
256
+ const indexStaged = saveHarnessDocsIndexStaging(
257
+ ctx.paths,
258
+ ctx.cwd,
259
+ ctx.sessionId,
260
+ indexMarkdown,
261
+ );
262
+ if (!indexStaged.ok) {
263
+ return {
264
+ status: "failed",
265
+ stage: this.stage,
266
+ artifactPaths: [],
267
+ error: `failed to stage docs/README.md: ${indexStaged.error.message}`,
268
+ };
269
+ }
270
+
271
+ // Atomic promotion. Only promote layers we actually generated this run plus any layers
272
+ // we previously promoted whose staging still exists from a prior run; user-edited
273
+ // layers are not touched. We collect the set from `regen` ∪ `skip` (skip = file is
274
+ // already in the repo and up-to-date; do nothing). The index is the only thing that
275
+ // needs a refresh when skip-only.
276
+ const layersToPromote = decision.regen;
277
+ const promotion = promoteHarnessDocsToRepo(
278
+ ctx.paths,
279
+ ctx.cwd,
280
+ ctx.sessionId,
281
+ layersToPromote,
282
+ );
283
+ if (!promotion.ok) {
284
+ return {
285
+ status: "failed",
286
+ stage: this.stage,
287
+ artifactPaths: [],
288
+ error: `failed to promote docs to repo: ${promotion.error.message}`,
289
+ };
290
+ }
291
+
292
+ const artifactPaths: string[] = [
293
+ ...promotion.value.layerPaths.map((p) => path.relative(ctx.cwd, p)),
294
+ path.relative(ctx.cwd, promotion.value.indexPath),
295
+ ];
296
+ return {
297
+ status: "completed",
298
+ stage: this.stage,
299
+ artifactPaths,
300
+ details: {
301
+ regenerated: decision.regen,
302
+ skipped: decision.skip,
303
+ userEdited: decision.userEdited,
304
+ tier: "extensive",
305
+ },
306
+ };
307
+ }
308
+
309
+ private async resolveTier(ctx: HarnessStageRunnerContext): Promise<"simple" | "extensive"> {
310
+ if (this.input.tierOverride) return this.input.tierOverride;
311
+ const session = loadHarnessSession(ctx.paths, ctx.cwd, ctx.sessionId);
312
+ if (session.ok && session.value.docsTier) return session.value.docsTier;
313
+ return "simple";
314
+ }
315
+ }
316
+
317
+ // ── Internals ──────────────────────────────────────────────────────────────
318
+
319
+ interface LayerInputs {
320
+ layer: HarnessLayerRule;
321
+ globPaths: string[];
322
+ representativeEntries: RepresentativeFileEntry[];
323
+ representativeFingerprints: RepresentativeFileFingerprint[];
324
+ sourceHash: string;
325
+ }
326
+
327
+ async function buildLayerInputs(input: {
328
+ ctx: HarnessStageRunnerContext;
329
+ layers: readonly HarnessLayerRule[];
330
+ promptVersion: string;
331
+ }): Promise<LayerInputs[]> {
332
+ const allRepoFiles = collectRepoFiles(input.ctx.cwd);
333
+ const goldenPrinciples = readGoldenPrinciples(input.ctx.cwd);
334
+ const peerByLayer = new Map<string, PeerLayerFingerprint[]>();
335
+ for (const layer of input.layers) {
336
+ peerByLayer.set(
337
+ layer.layer,
338
+ input.layers
339
+ .filter((peer) => peer.layer !== layer.layer)
340
+ .map((peer) => ({ id: peer.layer, description: peer.description ?? "" })),
341
+ );
342
+ }
343
+
344
+ const out: LayerInputs[] = [];
345
+ for (const layer of input.layers) {
346
+ const globPaths = filterFilesForLayer(allRepoFiles, layer);
347
+ const repSelection = selectRepresentativeFiles({
348
+ cwd: input.ctx.cwd,
349
+ files: globPaths,
350
+ });
351
+ const representativeFingerprints: RepresentativeFileFingerprint[] = repSelection.entries.map(
352
+ (entry) => ({ path: entry.path, contentHash: entry.contentHash }),
353
+ );
354
+ const sourceHash = computeLayerSourceHash({
355
+ layerRule: layer,
356
+ globPaths,
357
+ representativeFiles: representativeFingerprints,
358
+ goldenPrinciples,
359
+ peerLayers: peerByLayer.get(layer.layer) ?? [],
360
+ promptVersion: input.promptVersion,
361
+ });
362
+ out.push({
363
+ layer,
364
+ globPaths,
365
+ representativeEntries: repSelection.entries,
366
+ representativeFingerprints,
367
+ sourceHash,
368
+ });
369
+ }
370
+ return out;
371
+ }
372
+
373
+ async function computeAllLayerSourceHashes(input: {
374
+ ctx: HarnessStageRunnerContext;
375
+ layers: readonly HarnessLayerRule[];
376
+ promptVersion: string;
377
+ }): Promise<Map<string, string>> {
378
+ const inputs = await buildLayerInputs(input);
379
+ return new Map(inputs.map((entry) => [entry.layer.layer, entry.sourceHash] as const));
380
+ }
381
+
382
+ function readGoldenPrinciples(cwd: string): string[] {
383
+ const principlesPath = path.join(cwd, "docs", "golden-principles.md");
384
+ if (!fs.existsSync(principlesPath)) return [];
385
+ try {
386
+ const md = fs.readFileSync(principlesPath, "utf8");
387
+ return md
388
+ .split("\n")
389
+ .map((line) => line.trim())
390
+ .filter((line) => /^\d+\.\s+/.test(line))
391
+ .map((line) => line.replace(/^\d+\.\s+/, ""));
392
+ } catch {
393
+ return [];
394
+ }
395
+ }
396
+
397
+ function filterFilesForLayer(allFiles: readonly string[], layer: HarnessLayerRule): string[] {
398
+ const out: string[] = [];
399
+ for (const file of allFiles) {
400
+ for (const glob of layer.globs) {
401
+ if (matchesLayerGlob(file, glob)) {
402
+ out.push(file);
403
+ break;
404
+ }
405
+ }
406
+ }
407
+ out.sort();
408
+ return out;
409
+ }
410
+
411
+ /**
412
+ * Walk the repo tree once, returning forward-slashed paths relative to `cwd`. Excludes
413
+ * common directories that should never count toward any layer (node_modules, .git,
414
+ * build outputs).
415
+ */
416
+ function collectRepoFiles(cwd: string): string[] {
417
+ const out: string[] = [];
418
+ const skip = new Set<string>([
419
+ "node_modules",
420
+ ".git",
421
+ "dist",
422
+ "build",
423
+ ".omp",
424
+ "coverage",
425
+ ".cache",
426
+ ".next",
427
+ ]);
428
+
429
+ function walk(absolute: string, relative: string): void {
430
+ let entries: fs.Dirent[];
431
+ try {
432
+ entries = fs.readdirSync(absolute, { withFileTypes: true });
433
+ } catch {
434
+ return;
435
+ }
436
+ for (const entry of entries) {
437
+ if (entry.name.startsWith(".") && entry.name !== ".github") {
438
+ if (skip.has(entry.name)) continue;
439
+ // Allow some dotfiles but skip dotdirs above the cutoff.
440
+ }
441
+ if (entry.isDirectory()) {
442
+ if (skip.has(entry.name)) continue;
443
+ walk(path.join(absolute, entry.name), path.posix.join(relative, entry.name));
444
+ } else if (entry.isFile()) {
445
+ out.push(relative === "" ? entry.name : path.posix.join(relative, entry.name));
446
+ }
447
+ }
448
+ }
449
+
450
+ walk(cwd, "");
451
+ return out;
452
+ }
453
+
454
+ async function readPromptVersion(): Promise<string> {
455
+ try {
456
+ const filePath = path.normalize(decodeURI(DOCS_AGENT_PROMPT_PATH.pathname));
457
+ const contents = fs.readFileSync(filePath, "utf8");
458
+ return sha256(contents);
459
+ } catch {
460
+ // Fallback: a stable string so tests that don't ship the prompt still hash deterministically.
461
+ return crypto.createHash("sha256").update("harness-docs-prompt-fallback", "utf8").digest("hex");
462
+ }
463
+ }
464
+
465
+ async function readPromptText(): Promise<string> {
466
+ try {
467
+ const filePath = path.normalize(decodeURI(DOCS_AGENT_PROMPT_PATH.pathname));
468
+ return fs.readFileSync(filePath, "utf8");
469
+ } catch {
470
+ return "";
471
+ }
472
+ }
473
+
474
+ interface OrchestrateLayerInput {
475
+ ctx: HarnessStageRunnerContext;
476
+ entry: LayerInputs;
477
+ config: HarnessDocsConfig;
478
+ recordedAt: string;
479
+ factory?: DocsStageInput["agentSessionFactory"];
480
+ }
481
+
482
+ interface OrchestrateLayerResult {
483
+ ok: boolean;
484
+ errors: string[];
485
+ }
486
+
487
+ async function orchestrateLayerSubagent(input: OrchestrateLayerInput): Promise<OrchestrateLayerResult> {
488
+ const { ctx, entry, config } = input;
489
+ registerDocsLayerExpectation(ctx.sessionId, entry.layer.layer, {
490
+ expectedSourceHash: entry.sourceHash,
491
+ maxDocLoc: config.max_per_doc_loc,
492
+ maxAgentContextLoc: config.agent_context_loc,
493
+ });
494
+
495
+ try {
496
+ const promptText = await readPromptText();
497
+ const assignment = [
498
+ promptText.trim(),
499
+ "",
500
+ await buildDocsAssignment(ctx, entry, input.recordedAt),
501
+ ].join("\n");
502
+
503
+ // First attempt.
504
+ const firstAttempt = await dispatchSubagent({
505
+ platform: ctx.platform,
506
+ ctx,
507
+ entry,
508
+ assignment,
509
+ factory: input.factory,
510
+ attempt: 1,
511
+ });
512
+ if (firstAttempt.ok) return firstAttempt;
513
+
514
+ // Single retry-on-overlength: feed the validation errors back.
515
+ const retryAssignment = [
516
+ assignment,
517
+ "",
518
+ "## Previous attempt rejected",
519
+ "Your previous `harness_docs_record` call was rejected with the following errors:",
520
+ ...firstAttempt.errors.map((err) => `- ${err}`),
521
+ "",
522
+ "Fix every error and call `harness_docs_record` again. This is your final attempt.",
523
+ ].join("\n");
524
+
525
+ const retry = await dispatchSubagent({
526
+ platform: ctx.platform,
527
+ ctx,
528
+ entry,
529
+ assignment: retryAssignment,
530
+ factory: input.factory,
531
+ attempt: 2,
532
+ });
533
+ return retry;
534
+ } finally {
535
+ clearDocsLayerExpectation(ctx.sessionId, entry.layer.layer);
536
+ }
537
+ }
538
+
539
+ async function dispatchSubagent(input: {
540
+ platform: Platform;
541
+ ctx: HarnessStageRunnerContext;
542
+ entry: LayerInputs;
543
+ assignment: string;
544
+ factory?: DocsStageInput["agentSessionFactory"];
545
+ attempt: number;
546
+ }): Promise<OrchestrateLayerResult> {
547
+ const agentId = `harness-docs-${input.ctx.sessionId}-${input.entry.layer.layer}-attempt-${input.attempt}`;
548
+ const agentDisplayName = buildHarnessAgentDisplayName("docs", input.entry.layer.layer);
549
+
550
+ let session: AgentSessionLike | null = null;
551
+ try {
552
+ if (input.factory) {
553
+ session = await input.factory(input.platform, {
554
+ cwd: input.ctx.cwd,
555
+ agentId,
556
+ agentDisplayName,
557
+ });
558
+ } else {
559
+ session = await input.platform.createAgentSession({
560
+ cwd: input.ctx.cwd,
561
+ agentId,
562
+ agentDisplayName,
563
+ });
564
+ }
565
+ await session.prompt(input.assignment, { expandPromptTemplates: false });
566
+ } catch (error) {
567
+ return {
568
+ ok: false,
569
+ errors: [
570
+ `subagent dispatch failed: ${error instanceof Error ? error.message : String(error)}`,
571
+ ],
572
+ };
573
+ } finally {
574
+ if (session) {
575
+ try {
576
+ await session.dispose();
577
+ } catch {
578
+ /* best-effort */
579
+ }
580
+ }
581
+ }
582
+
583
+ // Confirm staged output landed; this is the success signal regardless of what the
584
+ // subagent says, because the tool handler is the gatekeeper.
585
+ const staged = loadHarnessDocsLayerStaging(
586
+ input.platform.paths,
587
+ input.ctx.cwd,
588
+ input.ctx.sessionId,
589
+ input.entry.layer.layer,
590
+ );
591
+ if (!staged.ok) {
592
+ return {
593
+ ok: false,
594
+ errors: [
595
+ `subagent did not produce a staged doc for layer ${input.entry.layer.layer} (the harness_docs_record call may have been rejected by the validator).`,
596
+ ],
597
+ };
598
+ }
599
+ return { ok: true, errors: [] };
600
+ }
601
+
602
+ async function buildDocsAssignment(
603
+ ctx: HarnessStageRunnerContext,
604
+ entry: LayerInputs,
605
+ recordedAt: string,
606
+ ): Promise<string> {
607
+ const discoverResult = loadHarnessDiscover(ctx.paths, ctx.cwd, ctx.sessionId);
608
+ const discover = discoverResult.ok ? discoverResult.value : null;
609
+ const goldenPrinciples = readGoldenPrinciples(ctx.cwd);
610
+ const designResult = loadHarnessDesignSpecJson(ctx.paths, ctx.cwd, ctx.sessionId);
611
+ const peers = designResult.ok
612
+ ? designResult.value.layerRules
613
+ .filter((peer) => peer.layer !== entry.layer.layer)
614
+ .map((peer) => `- ${peer.layer}: ${peer.description ?? "(no description)"}`)
615
+ : [];
616
+
617
+ const lines: string[] = [];
618
+ lines.push(`# Per-layer agent docs assignment · ${entry.layer.layer}`);
619
+ lines.push("");
620
+ lines.push(`Session id: ${ctx.sessionId}`);
621
+ lines.push(`Layer id: ${entry.layer.layer}`);
622
+ lines.push(`Recorded at: ${recordedAt}`);
623
+ lines.push("");
624
+ lines.push("## Layer rule");
625
+ lines.push(`- id: ${entry.layer.layer}`);
626
+ lines.push(`- globs: ${entry.layer.globs.join(", ")}`);
627
+ lines.push(`- description: ${entry.layer.description ?? "(none)"}`);
628
+ lines.push(
629
+ `- permitted imports: ${entry.layer.allowedImports.length > 0 ? entry.layer.allowedImports.join(", ") : "(none)"}`,
630
+ );
631
+ lines.push(
632
+ `- forbidden imports: ${entry.layer.forbiddenImports.length > 0 ? entry.layer.forbiddenImports.join(", ") : "(none)"}`,
633
+ );
634
+ lines.push("");
635
+ lines.push("## All files in this layer");
636
+ if (entry.globPaths.length === 0) {
637
+ lines.push("(none)");
638
+ } else {
639
+ for (const file of entry.globPaths.slice(0, 200)) lines.push(`- ${file}`);
640
+ if (entry.globPaths.length > 200) lines.push(`…and ${entry.globPaths.length - 200} more`);
641
+ }
642
+ lines.push("");
643
+ lines.push("## Representative files");
644
+ lines.push(renderRepresentativeBlock(entry.representativeEntries));
645
+ lines.push("");
646
+ lines.push("## Golden principles (already enforced repo-wide; reference, do not restate)");
647
+ if (goldenPrinciples.length === 0) {
648
+ lines.push("(none recorded)");
649
+ } else {
650
+ for (const principle of goldenPrinciples) lines.push(`- ${principle}`);
651
+ }
652
+ lines.push("");
653
+ lines.push("## Peer layers");
654
+ if (peers.length === 0) lines.push("(none)");
655
+ else lines.push(...peers);
656
+ lines.push("");
657
+ lines.push("## Repo facts");
658
+ lines.push(`- languages: ${discover?.languages.join(", ") ?? "(unknown)"}`);
659
+ lines.push(`- frameworks: ${discover?.frameworks.join(", ") ?? "(unknown)"}`);
660
+ lines.push(`- package manager: ${discover?.packageManagers.join(", ") ?? "(unknown)"}`);
661
+ lines.push("");
662
+ lines.push("## Tool invocation");
663
+ lines.push(`You MUST call harness_docs_record exactly once with sessionId=${ctx.sessionId}, layerId=${entry.layer.layer}, and the full markdown body.`);
664
+ lines.push(`Embed sourceHash: ${entry.sourceHash} verbatim in the frontmatter.`);
665
+ return lines.join("\n");
666
+ }
667
+
668
+ function extractFrontmatterSourceHash(markdown: string): string | null {
669
+ // Skip optional provenance marker line.
670
+ let body = markdown;
671
+ if (body.startsWith("<!--")) {
672
+ const newline = body.indexOf("\n");
673
+ if (newline > 0) body = body.slice(newline + 1);
674
+ }
675
+ if (!body.startsWith("---")) return null;
676
+ const firstNewline = body.indexOf("\n");
677
+ if (firstNewline < 0) return null;
678
+ const closeIdx = body.indexOf("\n---", firstNewline);
679
+ if (closeIdx < 0) return null;
680
+ const inner = body.slice(firstNewline + 1, closeIdx);
681
+ for (const line of inner.split("\n")) {
682
+ const match = line.match(/^sourceHash\s*:\s*(.+)\s*$/);
683
+ if (match) return match[1].trim();
684
+ }
685
+ return null;
686
+ }
687
+
688
+ async function runWithConcurrencyLimit<T>(
689
+ items: readonly T[],
690
+ limit: number,
691
+ worker: (item: T) => Promise<void>,
692
+ ): Promise<void> {
693
+ if (items.length === 0) return;
694
+ const queue = [...items];
695
+ const lanes: Promise<void>[] = [];
696
+ for (let i = 0; i < Math.min(limit, items.length); i += 1) {
697
+ lanes.push(
698
+ (async () => {
699
+ while (queue.length > 0) {
700
+ const next = queue.shift();
701
+ if (next === undefined) break;
702
+ await worker(next);
703
+ }
704
+ })(),
705
+ );
706
+ }
707
+ await Promise.all(lanes);
708
+ }