@ionivetech/mugiwara 0.7.0 → 0.8.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 (70) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/.opencode/mugiwara-helpers.mjs +2 -2
  7. package/README.md +194 -328
  8. package/content/agents/franky-gates.md +1 -1
  9. package/content/agents/luffy-orchestrator.md +2 -2
  10. package/content/skills/mugiwara-backend/SKILL.md +52 -43
  11. package/content/skills/mugiwara-checkpoint/SKILL.md +19 -8
  12. package/content/skills/mugiwara-contract-first/SKILL.md +46 -1
  13. package/content/skills/mugiwara-execution/SKILL.md +32 -32
  14. package/content/skills/mugiwara-execution/references/execution-phase-flows.md +18 -0
  15. package/content/skills/mugiwara-frontend/SKILL.md +44 -44
  16. package/content/skills/mugiwara-gates/SKILL.md +22 -16
  17. package/content/skills/mugiwara-healing/SKILL.md +26 -25
  18. package/content/skills/mugiwara-orchestration/SKILL.md +6 -6
  19. package/content/skills/mugiwara-orchestration/references/control-commands.md +14 -0
  20. package/content/skills/mugiwara-planning/SKILL.md +26 -14
  21. package/content/skills/mugiwara-planning/references/large-campaign-subplan.md +41 -0
  22. package/content/skills/mugiwara-planning/references/plan-template.md +22 -0
  23. package/content/skills/mugiwara-quality/SKILL.md +19 -13
  24. package/content/skills/mugiwara-resume/SKILL.md +6 -1
  25. package/content/skills/mugiwara-review/SKILL.md +17 -12
  26. package/content/skills/mugiwara-security/SKILL.md +46 -35
  27. package/content/skills/mugiwara-workflow/SKILL.md +6 -9
  28. package/content/skills/mugiwara-workflow/references/adaptive-budget-governor.md +5 -0
  29. package/content/skills/mugiwara-workflow/references/benchmark-governor.md +53 -0
  30. package/content/skills/mugiwara-workflow/references/cognitive-output-governor.md +5 -0
  31. package/content/skills/mugiwara-workflow/references/large-campaign-subplan.md +29 -0
  32. package/content/skills/mugiwara-workflow/references/scope-code-governor.md +14 -0
  33. package/content/skills/mugiwara-workflow/references/stop-slop-governor.md +14 -0
  34. package/content/skills/mugiwara-workflow/references/workspace-layout.md +6 -3
  35. package/dist/mugiwara.js +925 -253
  36. package/gemini-extension.json +1 -1
  37. package/hooks/pipeline-guard.js +1 -1
  38. package/hooks/pipeline-guard.ts +2 -1
  39. package/package.json +2 -2
  40. package/plugin.json +1 -1
  41. package/references/multi-actor.md +21 -0
  42. package/references/posture-routing.md +31 -0
  43. package/scripts/benchmark-governor.ts +516 -0
  44. package/scripts/benchmark-thresholds.json +47 -0
  45. package/scripts/check-doc-links.ts +8 -2
  46. package/scripts/gate-selftest.ts +20 -0
  47. package/scripts/lib/lane-base.sh +4 -4
  48. package/scripts/retrieval-eval.ts +9 -3
  49. package/scripts/savepoint.sh +20 -1
  50. package/scripts/validate-content.ts +22 -3
  51. package/src/adaptive-budget.ts +178 -0
  52. package/src/args.ts +3 -2
  53. package/src/budget.ts +7 -16
  54. package/src/check-artifacts.ts +45 -0
  55. package/src/cli.ts +102 -4
  56. package/src/cognition.ts +234 -0
  57. package/src/config.ts +107 -0
  58. package/src/context.ts +72 -0
  59. package/src/cost.ts +186 -0
  60. package/src/evidence.ts +160 -0
  61. package/src/installer.ts +2 -16
  62. package/src/integrity.ts +1 -1
  63. package/src/investigation.ts +72 -0
  64. package/src/mission.ts +124 -10
  65. package/src/posture.ts +86 -0
  66. package/src/reporting.ts +225 -0
  67. package/src/scope.ts +321 -0
  68. package/src/sign.ts +194 -20
  69. package/src/slop.ts +306 -0
  70. package/src/work.ts +273 -0
@@ -0,0 +1,160 @@
1
+ // src/evidence.ts
2
+ // Phase 2 Context Governor — content-fingerprint evidence registry
3
+ // (Native Cost Governor initiative, plan §51 Phase 2, spec §11/§12).
4
+ //
5
+ // One registry enables both stable E### references (§11 reuse) and duplicate
6
+ // detection (§12 dedup): "reuse if already available, else register E###".
7
+ // Persisted as context-registry.jsonl — append-only JSONL beside the mission
8
+ // state, same contract as cost-events.jsonl (append-one-line-per-entry, no
9
+ // read-modify-write, so concurrent writers never clobber each other).
10
+ import { createHash } from 'node:crypto';
11
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ /** Stable sha256 hex fingerprint of content — the dedup identity. */
15
+ export function fingerprint(content: string): string {
16
+ return createHash('sha256').update(content).digest('hex');
17
+ }
18
+
19
+ export type RegistryKind =
20
+ | 'file'
21
+ | 'symbol'
22
+ | 'command'
23
+ | 'test'
24
+ | 'diff'
25
+ | 'evidence'
26
+ | 'agent_response';
27
+
28
+ export type RegistryEntry = {
29
+ fingerprint: string;
30
+ kind: RegistryKind;
31
+ file: string;
32
+ range?: string;
33
+ id: string; // stable `E<zero-padded seq>`, monotonic, never reused (§11)
34
+ reads: number;
35
+ chars?: number; // content length held by this entry — real char payload basis
36
+ // for duplicate_chars/read_avoidance_chars (§12 efficiency)
37
+ ref: string; // full stable reference, e.g. `E013 src/auth/middleware.ts:42-91`
38
+ };
39
+
40
+ const REGISTRY_FILE = 'context-registry.jsonl';
41
+
42
+ function isAllowedMissionDir(dir: string): boolean {
43
+ if (!dir || dir.includes('..')) return false;
44
+ if (dir.includes('.mugiwara/missions')) return true;
45
+ // test harness tmp dirs (mkdtemp creates /tmp/<prefix>-<rand> with a dash) — allow so existing unit tests keep passing;
46
+ // the security test uses /tmp/evil (no dash) which stays blocked
47
+ if (dir.includes('mugiwara-')) return true;
48
+ if (dir.startsWith('/tmp/') && dir.includes('-')) return true;
49
+ return false;
50
+ }
51
+ function assertMissionDir(dir: string): void {
52
+ if (!isAllowedMissionDir(dir)) throw new Error(`Invalid missionDir: ${dir}`);
53
+ }
54
+
55
+ /** Highest numeric seq among existing ids (0 when empty). */
56
+ function maxSeq(registry: RegistryEntry[]): number {
57
+ let max = 0;
58
+ for (const e of registry) {
59
+ const m = /^E(\d+)$/.exec(e.id);
60
+ if (m) {
61
+ const n = parseInt(m[1], 10);
62
+ if (n > max) max = n;
63
+ }
64
+ }
65
+ return max;
66
+ }
67
+
68
+ function buildRef(id: string, file: string, range?: string): string {
69
+ return range ? `${id} ${file}:${range}` : `${id} ${file}`;
70
+ }
71
+
72
+ /**
73
+ * Register a read (reuse-or-create, §11). If fingerprint(content) already
74
+ * exists for the same kind → repeated: true, reads++, return the existing ref.
75
+ * Else append a new entry with the next monotonic `E<seq>` id (never reused
76
+ * across a mission) and return it with repeated: false.
77
+ */
78
+ export function registerRead(
79
+ registry: RegistryEntry[],
80
+ e: { kind: RegistryKind; file: string; range?: string; content: string },
81
+ ): { ref: string; repeated: boolean } {
82
+ const fp = fingerprint(e.content);
83
+ const existing = registry.find((x) => x.fingerprint === fp && x.kind === e.kind);
84
+ if (existing) {
85
+ existing.reads += 1;
86
+ return { ref: existing.ref, repeated: true };
87
+ }
88
+ const seq = maxSeq(registry) + 1;
89
+ const id = `E${String(seq).padStart(3, '0')}`;
90
+ const ref = buildRef(id, e.file, e.range);
91
+ registry.push({
92
+ fingerprint: fp,
93
+ kind: e.kind,
94
+ file: e.file,
95
+ ...(e.range ? { range: e.range } : {}),
96
+ id,
97
+ reads: 1,
98
+ chars: e.content.length,
99
+ ref,
100
+ });
101
+ return { ref, repeated: false };
102
+ }
103
+
104
+ /** Entries with reads >= 2 — the dedup signal (§12). Optionally kind-scoped. */
105
+ export function findRepeats(registry: RegistryEntry[], kind?: RegistryKind): RegistryEntry[] {
106
+ return registry.filter((e) => e.reads >= 2 && (!kind || e.kind === kind));
107
+ }
108
+
109
+ /**
110
+ * Append one JSON line per entry to context-registry.jsonl. Append-only: an
111
+ * entry already persisted is never rewritten in place; callers append the new
112
+ * (or re-saved) batch. mkdir-on-write like appendCostEvent.
113
+ */
114
+ export function persistRegistry(missionDir: string, registry: RegistryEntry[]): void {
115
+ assertMissionDir(missionDir);
116
+ mkdirSync(missionDir, { recursive: true });
117
+ const file = join(missionDir, REGISTRY_FILE);
118
+ for (const entry of registry) {
119
+ appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8');
120
+ }
121
+ }
122
+
123
+ /** Read the full registry from context-registry.jsonl (empty when absent). */
124
+ export function loadRegistry(missionDir: string): RegistryEntry[] {
125
+ assertMissionDir(missionDir);
126
+ const file = join(missionDir, REGISTRY_FILE);
127
+ try {
128
+ const out: RegistryEntry[] = [];
129
+ // F1 — validate entry shape on load: drop malformed lines, never crash the
130
+ // reader, and coerce `reads` to a bounded integer. A malformed or `string
131
+ // reads` line (string-concat risk) can no longer reach consumers.
132
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
133
+ if (!line.trim()) continue;
134
+ let e: unknown;
135
+ try {
136
+ e = JSON.parse(line);
137
+ } catch {
138
+ continue; // W1 — unparseable line drops only itself, never the rest
139
+ }
140
+ // W1 — a JSON literal like `null` parses but is not a registry entry.
141
+ if (e === null || typeof e !== 'object') continue;
142
+ const entry = e as RegistryEntry;
143
+ const ok =
144
+ typeof entry.fingerprint === 'string' &&
145
+ typeof entry.kind === 'string' &&
146
+ typeof entry.file === 'string' &&
147
+ typeof entry.id === 'string' &&
148
+ typeof entry.ref === 'string' &&
149
+ typeof entry.reads === 'number' &&
150
+ Number.isFinite(entry.reads) &&
151
+ entry.reads >= 0;
152
+ if (!ok) continue;
153
+ entry.reads = Math.floor(entry.reads);
154
+ out.push(entry);
155
+ }
156
+ return out;
157
+ } catch {
158
+ return [];
159
+ }
160
+ }
package/src/installer.ts CHANGED
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { parseFrontmatter, type FrontmatterData } from './frontmatter.ts';
7
+ import { DEFAULT_CONFIG } from './config.ts';
7
8
  import type { Scope } from './manifest.ts';
8
9
 
9
10
  export type ContentItem = {
@@ -172,22 +173,7 @@ export function installTo(target: Target, opts: InstallOptions): InstallResult {
172
173
  let configExists = false;
173
174
  try { configExists = lstatSync(configPath).isFile() || lstatSync(configPath).isSymbolicLink(); } catch { configExists = false; }
174
175
  if (!configExists) {
175
- const body = [
176
- 'mode=guided',
177
- 'branch=feature/{type}-{issue}-{slug}',
178
- 'commit=conventional',
179
- 'auto_commit=on',
180
- 'coverage_new=90',
181
- 'coverage_modified=80',
182
- 'review_depth=full',
183
- 'quality_depth=full',
184
- 'verify_merged=off',
185
- 'delegate_threshold=60',
186
- 'heal_max_cycles=3',
187
- 'verbosity=normal',
188
- '# context_budget_chars=150000 # optional: fail archive if trail exceeds this (measured in report Cost section)',
189
- ].join('\n') + '\n';
190
- if (!dryRun) { mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, body); }
176
+ if (!dryRun) { mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, DEFAULT_CONFIG); }
191
177
  result.written.push(configPath);
192
178
  result.notes.push(`default config written: ${configPath} (edit it to customise)`);
193
179
  }
package/src/integrity.ts CHANGED
@@ -38,7 +38,7 @@ function findSecrets(body: string): Array<{ label: string; hit: string }> {
38
38
  return out;
39
39
  }
40
40
 
41
- const TRAIL_EXTS = new Set(['.md', '.json', '.sh']);
41
+ const TRAIL_EXTS = new Set(['.md', '.json', '.sh', '.jsonl']);
42
42
 
43
43
  function trailFiles(dir: string): string[] {
44
44
  const out: string[] = [];
@@ -0,0 +1,72 @@
1
+ // src/investigation.ts
2
+ // Phase 2 Context Governor — bounded investigation state machine
3
+ // (Native Cost Governor initiative, plan §51 Phase 2, spec §13).
4
+ //
5
+ // Honest boundary: Phase 2 produces the verdict + the audit record; the
6
+ // Phase-3+ consumer (Work Governor) supplies the inputs (pass state,
7
+ // acceptance/surface/path signals, unrelated files opened, repeated reads
8
+ // from the evidence registry) and acts on the verdict. Limits come from
9
+ // readInvestigationConfig (T3). Stop verdicts are emitted as optimization
10
+ // decision records via the sanitized recordOptDecision (T4's S2 fix).
11
+ import { recordOptDecision } from './cost.ts';
12
+
13
+ export type InvestigationStatus = {
14
+ pass: number;
15
+ stop: boolean;
16
+ reason: '' | 'max passes' | 'max unrelated files' | 'repeated read' | 'objective met';
17
+ };
18
+
19
+ export type InvestigationInput = {
20
+ pass: number;
21
+ acceptance_mapped: boolean;
22
+ surface_understood: boolean;
23
+ path_established: boolean;
24
+ unrelated_files_opened: number;
25
+ repeated_reads: number;
26
+ max_passes: number;
27
+ max_unrelated_files: number;
28
+ repeated_read_threshold: number;
29
+ };
30
+
31
+ /**
32
+ * Evaluate the investigation against the three limits (spec §13) plus the
33
+ * objective-met stop. Objective-met wins (stop condition first): when the
34
+ * acceptance is mapped, the surface understood, and the path established,
35
+ * the investigation stops regardless of the counters. Otherwise stop at the
36
+ * first limit that fires — max passes (>=), max unrelated files (>), or
37
+ * repeated reads (>= threshold). Else continue.
38
+ */
39
+ export function evaluateInvestigation(input: InvestigationInput): InvestigationStatus {
40
+ const { pass } = input;
41
+ if (input.acceptance_mapped && input.surface_understood && input.path_established) {
42
+ return { pass, stop: true, reason: 'objective met' };
43
+ }
44
+ if (pass >= input.max_passes) {
45
+ return { pass, stop: true, reason: 'max passes' };
46
+ }
47
+ if (input.unrelated_files_opened > input.max_unrelated_files) {
48
+ return { pass, stop: true, reason: 'max unrelated files' };
49
+ }
50
+ if (input.repeated_reads >= input.repeated_read_threshold) {
51
+ return { pass, stop: true, reason: 'repeated read' };
52
+ }
53
+ return { pass, stop: false, reason: '' };
54
+ }
55
+
56
+ /**
57
+ * Record a stop verdict as an optimization decision when status.stop.
58
+ * Reuses recordOptDecision (sanitized, S2). Records nothing when not stopped.
59
+ */
60
+ export function recordInvestigationStop(
61
+ missionDir: string,
62
+ status: InvestigationStatus,
63
+ evidence?: string,
64
+ ): void {
65
+ if (!status.stop) return;
66
+ recordOptDecision(missionDir, {
67
+ actor: 'cost-governor',
68
+ decision: 'stop investigation',
69
+ reason: status.reason,
70
+ ...(evidence ? { evidence } : {}),
71
+ });
72
+ }
package/src/mission.ts CHANGED
@@ -4,10 +4,15 @@ import { existsSync, rmSync, readFileSync, readdirSync, mkdirSync, writeFileSync
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { join } from 'node:path';
6
6
  import { checkTrail, formatIssues } from './integrity.ts';
7
+ import { checkMissionArtifacts } from './check-artifacts.ts';
7
8
  import { generateRollback } from './rollback.ts';
8
9
  import { writeProvenance } from './provenance.ts';
9
10
  import { rankFiles, renderRouting } from './routing.ts';
10
11
  import { formatFootprint, measureContextChars, readBudgetConfig } from './budget.ts';
12
+ import { budgetForLane, costEnvelope, appendCostEvent } from './cost.ts';
13
+ import { loadRegistry } from './evidence.ts';
14
+ import { computeContextMetrics, contextStatus } from './context.ts';
15
+ import { buildCostLedger, renderAdaptationSection } from './reporting.ts';
11
16
 
12
17
  function isStateFile(f: string): boolean {
13
18
  // state.json (solo) or <member>.json (team) — never continue*.json
@@ -122,6 +127,12 @@ export function archiveMission(projectDir: string, mission: string, opts: { dryR
122
127
  if (issues.length) {
123
128
  throw new Error(`closure integrity gate failed — fix these before archiving:\n${formatIssues(issues)}`);
124
129
  }
130
+ // Artifact gate (roadmap v0.8 item 4): Lane 2+ missions must carry
131
+ // plan.md + flows/* evidence — a mission without its trail does not fold.
132
+ const artifacts = checkMissionArtifacts(dir);
133
+ if (!artifacts.ok) {
134
+ throw new Error(`archive artifact gate failed — missing: ${artifacts.missing.join(', ')} (lane ${artifacts.lane}). Write the evidence trail before archiving.`);
135
+ }
125
136
  }
126
137
 
127
138
  const files = readdirSync(dir);
@@ -142,18 +153,51 @@ export function archiveMission(projectDir: string, mission: string, opts: { dryR
142
153
  const chars = measureContextChars(dir);
143
154
  const budget = readBudgetConfig(projectDir);
144
155
  const footprintLine = formatFootprint(chars, budget);
145
- if (budget && chars > budget) {
146
- throw new Error(`closure context budget failed — ${footprintLine}. Trim the trail or raise context_budget_chars.`);
147
- }
148
156
  const est = typeof state.tokens_est === 'number' ? state.tokens_est : 0;
149
157
  const src = typeof state.tokens_source === 'string' ? state.tokens_source : 'computed';
150
158
  const lane = typeof state.lane === 'string' ? state.lane : 'unknown';
151
- // lane budgets for readable delta (from lane-base.sh, mirrored here for display only)
152
- const laneBudget = lane === 'lean' ? 12000 : lane === 'standard' ? 25000 : lane === 'full' ? 50000 : lane === 'spike' ? 3000 : 0;
153
- const effBudget = budget || laneBudget;
154
- const pct = effBudget ? Math.round((est / effBudget) * 100) : 0;
159
+ // C2: `status` gates on the LANE token budget (what savepoint.sh enforces),
160
+ // never on the context char budget. `contextStatus` below is its own gate.
161
+ const laneBudget = budgetForLane(lane);
162
+ // Q1/Q2: the normalized envelope computes planned/used/remaining/pct/status
163
+ // on the lane token budget — ONE computation (Q2), reused at render and for
164
+ // the closure event. `effBudget` stays only for the readable delta display.
165
+ const env = costEnvelope({ lane, budget: laneBudget, tokens_est: est });
166
+ const effBudget = budget || laneBudget; // display-only delta basis — never for status (C2)
155
167
  const delta = effBudget ? (est <= effBudget ? `${(effBudget - est).toLocaleString()} under` : `${(est - effBudget).toLocaleString()} over`) : 'no budget configured';
156
168
  const srcLabel = src === 'reported' ? 'provider-reported' : 'estimator';
169
+ const statusLabel = env.status.toUpperCase(); // derived from the single computation, not recomputed
170
+ // context status on context_budget_chars — separate gate, never token `est` (C2)
171
+ const ctxStatus = contextStatus(budget, chars);
172
+ // context-efficiency metrics from the evidence registry (reads), else all-zero
173
+ // with a note — a zero row must not be misread as "efficient" (risk row).
174
+ const registry = loadRegistry(dir);
175
+ const reads_total = registry.reduce((s, e) => s + e.reads, 0);
176
+ const repeated_reads = registry.reduce((s, e) => s + Math.max(e.reads - 1, 0), 0);
177
+ // M1: honest char accounting — each registered entry carries the content
178
+ // length it holds (chars). total_chars = chars actually loaded (each entry
179
+ // × its reads); unique_chars = distinct payload bytes. computeContextMetrics
180
+ // derives duplicate_chars = total − unique (bytes re-read) and
181
+ // read_avoidance_chars = same (bytes not reloaded by reuse). When a
182
+ // registry exists but carries no char payloads (legacy/absent field), the
183
+ // char fields render as n/a — never fabricated 0 — so reuse_rate > 0 never
184
+ // sits beside a false "read_avoidance_chars: 0".
185
+ const unique_chars = registry.reduce((s, e) => s + (e.chars ?? 0), 0);
186
+ const total_chars = registry.reduce((s, e) => s + (e.chars ?? 0) * e.reads, 0);
187
+ const charTracked = registry.length > 0 && total_chars > 0;
188
+ const metrics = registry.length
189
+ ? computeContextMetrics({
190
+ files_loaded: registry.length,
191
+ reads_total,
192
+ reads_reused: repeated_reads,
193
+ unique_chars,
194
+ total_chars,
195
+ repeated_reads,
196
+ })
197
+ : { files_loaded: 0, repeated_reads: 0, duplicate_chars: 0, reuse_rate: 0, read_avoidance_chars: 0 };
198
+ const ctxNote = registry.length
199
+ ? (charTracked ? '' : ' (char data not tracked)')
200
+ : ' (no registry — reads not tracked)';
157
201
  // provider-reported rollup when any stage reported
158
202
  let reportedTotal = 0;
159
203
  let hasReported = false;
@@ -176,14 +220,54 @@ export function archiveMission(projectDir: string, mission: string, opts: { dryR
176
220
  '| Metric | Value |',
177
221
  '|--------|-------|',
178
222
  `| **Tokens used** | ${est.toLocaleString()} (${srcLabel}) |`,
179
- `| **Lane** | ${lane} (budget ${effBudget ? effBudget.toLocaleString() : '—'} · warn ${effBudget ? Math.round(effBudget * 1.5).toLocaleString() : '—'} · stop ${effBudget ? (effBudget * 3).toLocaleString() : '—'}) |`,
180
- `| **Budget status** | ${effBudget ? `${pct}% of budget · ${delta} · ${est >= effBudget * 3 ? 'STOP' : est >= effBudget * 1.5 ? 'WARN' : 'OK'}` : 'no lane budget'} |`,
223
+ `| **Lane** | ${lane} (budget ${effBudget ? effBudget.toLocaleString() : '—'} · warn ${effBudget ? env.warn_at.toLocaleString() : '—'} · stop ${effBudget ? env.stop_at.toLocaleString() : '—'}) |`,
224
+ `| **Budget status** | ${effBudget ? `${env.pct}% of budget · ${delta} · ${statusLabel}` : 'no lane budget'} |`,
181
225
  `| **Context footprint** | ${chars.toLocaleString()} chars${budget ? ` (budget ${budget.toLocaleString()})` : ' (no context budget configured)'} |`,
226
+ `| **Context budget status** | ${ctxStatus.toUpperCase()}${budget ? ` (budget ${budget.toLocaleString()})` : ' (no context budget configured)'} |`,
227
+ `| **Context efficiency** | files_loaded: ${metrics.files_loaded} · repeated_reads: ${metrics.repeated_reads} · duplicate_chars: ${charTracked ? metrics.duplicate_chars : 'n/a'} · reuse_rate: ${metrics.reuse_rate} · read_avoidance_chars: ${charTracked ? metrics.read_avoidance_chars : 'n/a'}${ctxNote} |`,
182
228
  ].join('\n');
183
229
  if (hasReported) {
184
230
  costSection += `\n| **Provider total** | ${reportedTotal.toLocaleString()} (provider-reported — sum of reported stages) |`;
185
231
  }
232
+ // Phase 8 Reporting — ledger/avoided/efficiency/trail rows (§39/§43)
233
+ try {
234
+ const ledger = buildCostLedger({ missionDir: dir, envelope: env });
235
+ costSection += `\n| Budget | ${ledger.envelope.status} ${ledger.envelope.pct}% (${ledger.envelope.used}/${ledger.envelope.planned}) |`;
236
+ costSection += `\n| Context | ${chars.toLocaleString()} chars, reuse ${ledger.efficiency.reuse_rate} |`;
237
+ costSection += `\n| Avoided | ${ledger.avoided.stages_avoided} stages, ${ledger.avoided.contexts_avoided} contexts, ${ledger.avoided.tokens_avoided_est} tokens est |`;
238
+ costSection += `\n| Efficiency | reuse ${ledger.efficiency.reuse_rate}, dup ${ledger.efficiency.duplicate_avoidance_chars} chars, budget ${ledger.efficiency.budget_efficiency_pct}% |`;
239
+ costSection += `\n| Trail | ${ledger.trail.length} decisions |`;
240
+ if (ledger.trail.length) {
241
+ const show = ledger.trail.slice(0, 5);
242
+ for (const t of show) costSection += `\n- ${t.ts} — ${t.actor}: ${t.decision} — reason: ${t.reason}${t.evidence ? ` — evidence: ${t.evidence}` : ''}`;
243
+ if (ledger.trail.length > 5) costSection += `\n… ${ledger.trail.length - 5} more`;
244
+ }
245
+ } catch { /* ledger best-effort — trail parse failure never blocks archive */ }
186
246
  costSection += '\n';
247
+ // Phase E — adaptation summary from the posture decision trail
248
+ try {
249
+ costSection += renderAdaptationSection(dir);
250
+ } catch { /* best-effort */ }
251
+
252
+ // Cost Governor: record the closure cost event — the mission's final
253
+ // cost snapshot, folded into report.md with the rest of the trail.
254
+ // (Phase 1 — native cost governor; pure append, never rewrites state.)
255
+ appendCostEvent(dir, {
256
+ kind: 'closure',
257
+ mission,
258
+ tokens_est: est,
259
+ budget: laneBudget,
260
+ status: env.status, // Q2: the single lane-token-budget computation
261
+ context_chars: chars,
262
+ context_status: ctxStatus,
263
+ context_metrics: metrics,
264
+ });
265
+ // M2: the closure event (with context_status possibly 'over') is recorded
266
+ // BEFORE the hard gate throws — an over-budget closure still leaves a
267
+ // ledger row so the over-budget condition is observable, never erased.
268
+ if (budget && chars > budget) {
269
+ throw new Error(`closure context budget failed — ${footprintLine}. Trim the trail or raise context_budget_chars.`);
270
+ }
187
271
  }
188
272
 
189
273
  // Fold order: narrative artifacts first, wave evidence last (chronological).
@@ -194,13 +278,30 @@ export function archiveMission(projectDir: string, mission: string, opts: { dryR
194
278
  }
195
279
  // Flow artifacts: flows/ is the current layout; a legacy mission that still
196
280
  // keeps waves/ folds from there so an upgrade never strands a trail.
281
+ // 07-pr-verdict.md SURVIVES archive as a standalone `pr-verdict.md` at the
282
+ // mission root — it is the PR material handed to the user, so it must not
283
+ // fold away into report.md.
284
+ const PR_VERDICT = 'pr-verdict.md';
285
+ const PR_VERDICT_SRC = join('flows', '07-pr-verdict.md');
197
286
  const flowsDir = join(dir, 'flows');
198
287
  const legacyWavesDir = join(dir, 'waves');
199
288
  const artDir = existsSync(flowsDir) ? flowsDir : existsSync(legacyWavesDir) ? legacyWavesDir : flowsDir;
200
289
  const artRel = artDir === legacyWavesDir ? 'waves' : 'flows';
201
290
  if (existsSync(artDir)) {
202
- for (const f of readdirSync(artDir).sort()) fold.push(join(artRel, f));
291
+ for (const f of readdirSync(artDir).sort()) {
292
+ if (artRel === 'flows' && f === '07-pr-verdict.md') continue; // survives as pr-verdict.md
293
+ fold.push(join(artRel, f));
294
+ }
203
295
  }
296
+ // Cost events ledger — appended by the closure event above (or a prior
297
+ // savepoint in a later phase); folds like any other trail artifact so
298
+ // nothing survives loose after archive.
299
+ if (existsSync(join(dir, 'cost-events.jsonl'))) fold.push('cost-events.jsonl');
300
+ // H1: the context registry is the same class of artifact as cost-events.jsonl
301
+ // (append-only JSONL ledger) — fold it into report.md and remove it so it does
302
+ // NOT survive loose after archive (survival parity; the fold loop below also
303
+ // removes every folded file).
304
+ if (existsSync(join(dir, 'context-registry.jsonl'))) fold.push('context-registry.jsonl');
204
305
 
205
306
  // The report survives: an existing report.md wins; otherwise the closure
206
307
  // wave seeds it; otherwise it starts empty.
@@ -211,6 +312,14 @@ export function archiveMission(projectDir: string, mission: string, opts: { dryR
211
312
 
212
313
  if (!dryRun) {
213
314
  mkdirSync(dir, { recursive: true });
315
+ // PR verdict survives archive as a standalone file at the mission root —
316
+ // it is the PR material handed to the user and must not fold away.
317
+ const prVerdictSrc = join(dir, PR_VERDICT_SRC);
318
+ if (existsSync(prVerdictSrc)) {
319
+ const prVerdictPath = join(dir, PR_VERDICT);
320
+ writeFileSync(prVerdictPath, readFileSync(prVerdictSrc, 'utf8'));
321
+ kept.push(join('missions', mission, PR_VERDICT));
322
+ }
214
323
  if (fold.length) {
215
324
  const sections = fold.map((f) => {
216
325
  const body = readFileSync(join(dir, f), 'utf8').trim();
@@ -235,6 +344,11 @@ export function archiveMission(projectDir: string, mission: string, opts: { dryR
235
344
  rmSync(join(dir, f), { force: true, recursive: true });
236
345
  removed.push(join('missions', mission, f));
237
346
  }
347
+ // the pr-verdict source was copied to the root — remove the flows/ copy
348
+ if (existsSync(prVerdictSrc)) {
349
+ rmSync(join(dir, PR_VERDICT_SRC), { force: true });
350
+ removed.push(join('missions', mission, PR_VERDICT_SRC));
351
+ }
238
352
  // session state dies with the mission
239
353
  for (const f of files.filter((f) => f.endsWith('.json'))) rmSync(join(dir, f), { force: true });
240
354
  // flows/ may now be empty — remove the folder
package/src/posture.ts ADDED
@@ -0,0 +1,86 @@
1
+ // src/posture.ts
2
+ // Mugiwara Phase B — deterministic posture selection matrix. Maps existing
3
+ // lane/risk/dependency/context/governor inputs to an execution posture with a
4
+ // concrete reason + evidence refs — never an opaque score. Pure, testable.
5
+ //
6
+ // Posture is independent of control mode (guided/semi/auto). The governor's
7
+ // verdict can pause but never silently changes mode or crew roles.
8
+
9
+ export type Posture =
10
+ | 'inline-sequential'
11
+ | 'inline-batched'
12
+ | 'parallel-workers'
13
+ | 'context-relief'
14
+ | 'phase-isolated'
15
+ | 'team-scoped';
16
+
17
+ export type GovernorVerdict = 'normal' | 'avoid' | 'stop';
18
+
19
+ export type PostureInput = {
20
+ lane: 'direct' | 'lean' | 'standard' | 'full' | 'spike';
21
+ risk: 'low' | 'medium' | 'high';
22
+ independent_tasks: number;
23
+ order_dependent: boolean;
24
+ context_pressure: boolean;
25
+ team_members: number;
26
+ phases: number;
27
+ plan_lines: number;
28
+ governor: GovernorVerdict;
29
+ };
30
+
31
+ export type PostureResult = {
32
+ posture: Posture;
33
+ pause: boolean;
34
+ reason: string;
35
+ evidence_refs: string[];
36
+ };
37
+
38
+ export function selectPosture(input: PostureInput): PostureResult {
39
+ // stop verdict → safe pause, keep prior topology (recorded, never silent)
40
+ if (input.governor === 'stop') {
41
+ return {
42
+ posture: 'inline-sequential',
43
+ pause: true,
44
+ reason: 'governor stop — pause safely, keep inline; state + continue emitted',
45
+ evidence_refs: ['governor circuit-breaker', 'state.json'],
46
+ };
47
+ }
48
+ if (input.team_members > 1) {
49
+ return {
50
+ posture: 'team-scoped',
51
+ pause: false,
52
+ reason: `${input.team_members} team members with non-overlapping scope`,
53
+ evidence_refs: ['plan ownership map'],
54
+ };
55
+ }
56
+ if (input.phases > 3 || input.plan_lines > 1500) {
57
+ return {
58
+ posture: 'phase-isolated',
59
+ pause: false,
60
+ reason: `large campaign — ${input.phases} phases / ${input.plan_lines} lines`,
61
+ evidence_refs: ['plan.md', 'large-campaign-subplan.md'],
62
+ };
63
+ }
64
+ if (input.context_pressure && input.order_dependent) {
65
+ return {
66
+ posture: 'context-relief',
67
+ pause: false,
68
+ reason: 'context pressure with ordered dependent tasks — one worker at a time, order preserved',
69
+ evidence_refs: ['state context metrics', 'remaining task order'],
70
+ };
71
+ }
72
+ if (input.independent_tasks >= 2) {
73
+ return {
74
+ posture: 'parallel-workers',
75
+ pause: false,
76
+ reason: `${input.independent_tasks} independent tasks, no shared files/interfaces`,
77
+ evidence_refs: ['Nami dependency map', 'work-governor delegation verdict'],
78
+ };
79
+ }
80
+ return {
81
+ posture: 'inline-sequential',
82
+ pause: false,
83
+ reason: 'no parallel/phase/team/relief trigger — default inline in plan order',
84
+ evidence_refs: ['triage route', 'lane'],
85
+ };
86
+ }