@ionivetech/mugiwara 0.6.5 → 0.7.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +3 -9
- package/.codex-plugin/plugin.json +2 -8
- package/.cursor-plugin/plugin.json +2 -8
- package/.kimi-plugin/plugin.json +2 -8
- package/.opencode/commands/mugiwara-continue.md +44 -31
- package/.opencode/commands/mugiwara-review.md +1 -1
- package/.opencode/commands/mugiwara-security.md +1 -1
- package/.opencode/commands/mugiwara.md +2 -2
- package/.opencode/plugins/mugiwara.mjs +3 -19
- package/AGENTS.md +6 -6
- package/GEMINI.md +3 -3
- package/README.md +82 -74
- package/content/agents/brook-healing.md +12 -11
- package/content/agents/chopper-checkpoint.md +14 -13
- package/content/agents/eval-runner.md +6 -9
- package/content/agents/franky-gates.md +7 -10
- package/content/agents/jinbe-security.md +10 -14
- package/content/agents/luffy-orchestrator.md +20 -22
- package/content/agents/memory-keeper.md +9 -12
- package/content/agents/nami-planner.md +7 -10
- package/content/agents/resume-coordinator.md +12 -15
- package/content/agents/robin-reviewer.md +13 -12
- package/content/agents/sanji-quality.md +9 -12
- package/content/agents/skeptic-verifier.md +7 -10
- package/content/agents/usopp-brainstorm.md +6 -9
- package/content/agents/zoro-execution.md +9 -12
- package/content/skills/mugiwara-backend/SKILL.md +4 -0
- package/content/skills/mugiwara-backend/references/database.md +61 -0
- package/content/skills/mugiwara-brainstorm/SKILL.md +1 -1
- package/content/skills/mugiwara-checkpoint/SKILL.md +17 -14
- package/content/skills/mugiwara-checkpoint/references/ledger-format.md +5 -5
- package/content/skills/mugiwara-contract-first/SKILL.md +2 -0
- package/content/skills/mugiwara-execution/SKILL.md +24 -24
- package/content/skills/mugiwara-execution/references/dispatch.md +3 -3
- package/content/skills/mugiwara-execution/references/resume-batching.md +2 -2
- package/content/skills/mugiwara-frontend/SKILL.md +5 -6
- package/content/skills/mugiwara-frontend/references/checklist.md +14 -0
- package/content/skills/mugiwara-gates/SKILL.md +14 -10
- package/content/skills/mugiwara-git/SKILL.md +7 -2
- package/content/skills/mugiwara-healing/SKILL.md +7 -5
- package/content/skills/mugiwara-healing/references/workers.md +2 -2
- package/content/skills/mugiwara-lessons/SKILL.md +2 -2
- package/content/skills/mugiwara-orchestration/SKILL.md +28 -26
- package/content/skills/mugiwara-orchestration/references/check-ins.md +17 -16
- package/content/skills/mugiwara-orchestration/references/closure.md +18 -13
- package/content/skills/mugiwara-orchestration/references/output-contract.md +9 -9
- package/content/skills/mugiwara-orchestration/references/triage-escalation.md +32 -22
- package/content/skills/mugiwara-planning/SKILL.md +5 -18
- package/content/skills/mugiwara-planning/references/plan-template.md +3 -3
- package/content/skills/mugiwara-quality/SKILL.md +10 -7
- package/content/skills/mugiwara-resume/SKILL.md +20 -22
- package/content/skills/mugiwara-review/SKILL.md +7 -2
- package/content/skills/mugiwara-review/references/five-axis-worksheet.md +1 -1
- package/content/skills/mugiwara-root-cause/SKILL.md +2 -0
- package/content/skills/mugiwara-security/SKILL.md +6 -2
- package/content/skills/mugiwara-ship/SKILL.md +2 -2
- package/content/skills/mugiwara-ship/references/cleanup.md +17 -16
- package/content/skills/mugiwara-testcases/SKILL.md +5 -3
- package/content/skills/mugiwara-workflow/SKILL.md +32 -35
- package/content/skills/mugiwara-workflow/references/workspace-layout.md +41 -40
- package/dist/mugiwara.js +1370 -153
- package/gemini-extension.json +1 -1
- package/hooks/auto-savepoint.js +114 -0
- package/hooks/auto-savepoint.ts +102 -0
- package/hooks/engagement-marker.js +59 -0
- package/hooks/engagement-marker.ts +97 -0
- package/hooks/hooks.json +41 -2
- package/hooks/mugiwara-mode-tracker.js +79 -0
- package/hooks/pipeline-guard.js +186 -0
- package/hooks/pipeline-guard.ts +270 -0
- package/hooks/session-start.js +109 -0
- package/hooks/session-start.ts +62 -45
- package/package.json +7 -3
- package/plugin.json +1 -1
- package/references/agent-protocol.md +15 -0
- package/references/complexity.md +77 -0
- package/references/definition-of-done.md +1 -1
- package/references/multi-actor.md +4 -7
- package/references/prose-style.md +54 -0
- package/references/skill-versioning.md +1 -1
- package/references/wave-banners.md +16 -16
- package/scripts/build-hooks.ts +56 -0
- package/scripts/check-doc-links.ts +40 -0
- package/scripts/conformance.ts +23 -16
- package/scripts/coverage-gate.ts +193 -0
- package/scripts/gate-selftest.ts +118 -7
- package/scripts/install.ps1 +5 -4
- package/scripts/install.sh +4 -4
- package/scripts/lane.sh +56 -6
- package/scripts/lib/patterns.sh +89 -0
- package/scripts/policy-force.ts +22 -0
- package/scripts/retrieval-eval.ts +1 -1
- package/scripts/savepoint.sh +249 -67
- package/scripts/validate-content.ts +22 -9
- package/scripts/verify-install.ts +156 -44
- package/src/args.ts +2 -2
- package/src/budget.ts +56 -0
- package/src/cli.ts +272 -4
- package/src/continue.ts +262 -0
- package/src/installer.ts +59 -15
- package/src/integrity.ts +158 -0
- package/src/mission.ts +228 -95
- package/src/policy.ts +156 -0
- package/src/provenance.ts +116 -0
- package/src/rollback.ts +95 -0
- package/src/routing.ts +69 -0
- package/src/run.ts +82 -0
- package/src/sign.ts +61 -0
- package/src/targets/claude.ts +140 -2
- package/src/targets/copilot.ts +20 -1
- package/src/targets/generic.ts +9 -4
- package/src/targets/opencode.ts +0 -1
- package/.opencode/commands/mugiwara-execute.md +0 -13
- package/.opencode/commands/mugiwara-heal.md +0 -13
- package/.opencode/commands/mugiwara-onboard.md +0 -15
- package/.opencode/commands/mugiwara-plan.md +0 -17
- package/.opencode/commands/mugiwara-ship.md +0 -13
- package/.opencode/commands/using-mugiwara.md +0 -20
- package/content/agents/onboarding-guide.md +0 -124
- package/content/skills/mugiwara-agent-security/SKILL.md +0 -58
- package/content/skills/mugiwara-agent-security/references/checklist.md +0 -20
- package/content/skills/mugiwara-context-budget/SKILL.md +0 -64
- package/content/skills/mugiwara-context-budget/references/context-budget.md +0 -37
- package/content/skills/mugiwara-pr/SKILL.md +0 -69
- package/content/skills/mugiwara-pr/references/verdict-format.md +0 -31
- package/content/skills/mugiwara-security/references/owasp-mapping.md +0 -30
- package/content/skills/mugiwara-sunset/SKILL.md +0 -82
- package/content/skills/using-mugiwara/SKILL.md +0 -51
- package/references/token-budget.md +0 -34
- package/scripts/evidence.sh +0 -81
- package/scripts/initiative.ts +0 -296
- package/scripts/mission-report.sh +0 -292
- package/scripts/onboard.ts +0 -266
- package/scripts/probe.ts +0 -40
package/src/mission.ts
CHANGED
|
@@ -1,18 +1,55 @@
|
|
|
1
1
|
// src/mission.ts
|
|
2
|
-
// Mission-state helpers for the mugiwara CLI
|
|
3
|
-
import { existsSync, rmSync, readFileSync, readdirSync, mkdirSync,
|
|
2
|
+
// Mission-state helpers for the mugiwara CLI.
|
|
3
|
+
import { existsSync, rmSync, readFileSync, readdirSync, mkdirSync, writeFileSync, renameSync, openSync, writeSync, closeSync } from 'node:fs';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
4
5
|
import { join } from 'node:path';
|
|
6
|
+
import { checkTrail, formatIssues } from './integrity.ts';
|
|
7
|
+
import { generateRollback } from './rollback.ts';
|
|
8
|
+
import { writeProvenance } from './provenance.ts';
|
|
9
|
+
import { rankFiles, renderRouting } from './routing.ts';
|
|
10
|
+
import { formatFootprint, measureContextChars, readBudgetConfig } from './budget.ts';
|
|
11
|
+
|
|
12
|
+
function isStateFile(f: string): boolean {
|
|
13
|
+
// state.json (solo) or <member>.json (team) — never continue*.json
|
|
14
|
+
const stem = f.replace(/\.json$/, '');
|
|
15
|
+
return f.endsWith('.json') && stem !== 'continue' && !stem.startsWith('continue-');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Primary state for closure artifacts: solo state.json wins over members. */
|
|
19
|
+
function primaryState(dir: string, files: string[]): Record<string, unknown> | null {
|
|
20
|
+
const name = files.includes('state.json') ? 'state.json' : files.find((f) => f.endsWith('.json') && f !== 'continue.json' && !f.startsWith('continue-'));
|
|
21
|
+
if (!name) return null;
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(join(dir, name), 'utf8')) as Record<string, unknown>;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Files the mission changed, base..branch. Empty on any git failure — routing is best-effort. */
|
|
30
|
+
function changedFiles(projectDir: string, state: Record<string, unknown> | null): string[] {
|
|
31
|
+
const base = typeof state?.base_sha === 'string' ? state.base_sha : '';
|
|
32
|
+
const branch = typeof state?.branch === 'string' ? state.branch : '';
|
|
33
|
+
if (!base || base === 'unknown' || !branch) return [];
|
|
34
|
+
try {
|
|
35
|
+
return execFileSync('git', ['diff', '--name-only', base, branch], {
|
|
36
|
+
cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
37
|
+
}).split(/\r?\n/).filter(Boolean);
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
5
42
|
|
|
6
43
|
function activeActor(projectDir: string): string | null {
|
|
7
|
-
// state now lives at .mugiwara/
|
|
44
|
+
// state now lives at .mugiwara/missions/<mission>/[member].json — scan the
|
|
8
45
|
// latest state file for its actor
|
|
9
|
-
const
|
|
10
|
-
if (!existsSync(
|
|
11
|
-
const missions = readdirSync(
|
|
46
|
+
const missionsDir = join(projectDir, '.mugiwara', 'missions');
|
|
47
|
+
if (!existsSync(missionsDir)) return null;
|
|
48
|
+
const missions = readdirSync(missionsDir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
|
|
12
49
|
let latest: { actor: string; updated: number } | null = null;
|
|
13
50
|
for (const mission of missions) {
|
|
14
|
-
const d = join(
|
|
15
|
-
for (const f of readdirSync(d).filter(
|
|
51
|
+
const d = join(missionsDir, mission);
|
|
52
|
+
for (const f of readdirSync(d).filter(isStateFile)) {
|
|
16
53
|
try {
|
|
17
54
|
const s = JSON.parse(readFileSync(join(d, f), 'utf8'));
|
|
18
55
|
const t = Date.parse(s.updated_at || '') || 0;
|
|
@@ -37,12 +74,9 @@ export function resetMission(projectDir: string, keepLogs: boolean, force?: bool
|
|
|
37
74
|
|
|
38
75
|
const removed: string[] = [];
|
|
39
76
|
const kept: string[] = [];
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
// mission state + continue folders — state/<mission>/, continue/<mission>/
|
|
45
|
-
for (const dir of ['state', 'continue']) {
|
|
77
|
+
// current layout: everything lives in missions/. Legacy pre-0.7 dirs are
|
|
78
|
+
// removed too so an upgraded project ends up with one layout, not two.
|
|
79
|
+
for (const dir of ['missions', 'spec', 'plans', 'results', 'review', 'issues', 'reports', 'state', 'continue']) {
|
|
46
80
|
const p = join(root, dir);
|
|
47
81
|
if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); removed.push(dir); }
|
|
48
82
|
}
|
|
@@ -53,11 +87,14 @@ export function resetMission(projectDir: string, keepLogs: boolean, force?: bool
|
|
|
53
87
|
if (existsSync(p)) { rmSync(p); removed.push(f); }
|
|
54
88
|
}
|
|
55
89
|
}
|
|
90
|
+
// lessons.md moved to the .mugiwara root; legacy home was logs/
|
|
56
91
|
if (!keepLogs) {
|
|
57
|
-
const p
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
92
|
+
for (const p of [join(root, 'lessons.md'), join(root, 'logs')]) {
|
|
93
|
+
if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); removed.push(p.startsWith(join(root, 'logs')) ? 'logs' : 'lessons.md'); }
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
if (existsSync(join(root, 'lessons.md'))) kept.push('lessons.md');
|
|
97
|
+
else if (existsSync(join(root, join('logs', 'lessons.md')))) kept.push(join('logs', 'lessons.md'));
|
|
61
98
|
}
|
|
62
99
|
for (const f of ['config', 'manifest.json', 'backup']) {
|
|
63
100
|
if (existsSync(join(root, f))) kept.push(f);
|
|
@@ -68,108 +105,204 @@ export function resetMission(projectDir: string, keepLogs: boolean, force?: bool
|
|
|
68
105
|
export function archiveMission(projectDir: string, mission: string, opts: { dryRun?: boolean } = {}): { report: string | null; removed: string[]; kept: string[]; index?: string } {
|
|
69
106
|
const { dryRun = false } = opts;
|
|
70
107
|
const root = join(projectDir, '.mugiwara');
|
|
71
|
-
// mission allowlist — same as savepoint.sh
|
|
108
|
+
// mission allowlist — same as savepoint.sh. Dot-only
|
|
72
109
|
// names (".", "..") would resolve upward through join(...,"..") and let
|
|
73
110
|
// rmSync reach state.json/config outside the mission dir.
|
|
74
111
|
if (!mission || /[^a-zA-Z0-9._-]/.test(mission) || /^\.+$/.test(mission)) throw new Error(`invalid mission name "${mission}" (allowlist: [a-zA-Z0-9._-], not a dot-path)`);
|
|
75
112
|
const removed: string[] = [];
|
|
76
113
|
const kept: string[] = [];
|
|
77
114
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
// bare names and the date-prefixed names the prose writes (audit-trail.md).
|
|
81
|
-
const belongs = (f: string): boolean => {
|
|
82
|
-
const base = f.replace(/^\d{4}-\d{2}-\d{2}-/, '');
|
|
83
|
-
return base === `${mission}.md` || base.startsWith(`${mission}-`);
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
// locate the report (the archive target that must survive). Reports are
|
|
87
|
-
// date-prefixed (`reports/YYYY-MM-DD-<mission>.md`); compare the stripped
|
|
88
|
-
// mission name so `bar-foo.md` is not mistaken for mission `foo`.
|
|
89
|
-
let report: string | null = null;
|
|
90
|
-
const reportsDir = join(root, 'reports');
|
|
91
|
-
if (existsSync(reportsDir)) {
|
|
92
|
-
const f = readdirSync(reportsDir).find(n => {
|
|
93
|
-
const m = n.match(/^(\d{4}-\d{2}-\d{2})-(.+)\.md$/);
|
|
94
|
-
return !!m && m[2] === mission;
|
|
95
|
-
});
|
|
96
|
-
if (f) report = join('reports', f);
|
|
97
|
-
}
|
|
115
|
+
const dir = join(root, 'missions', mission);
|
|
116
|
+
if (!existsSync(dir)) return { report: null, removed, kept };
|
|
98
117
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
kept.push(join('results', mission, f));
|
|
118
|
+
// Closure integrity gate: the trail validates itself before it
|
|
119
|
+
// folds. Dangling links, secrets, or missing evidence fail the archive.
|
|
120
|
+
if (!dryRun) {
|
|
121
|
+
const issues = checkTrail(dir, projectDir);
|
|
122
|
+
if (issues.length) {
|
|
123
|
+
throw new Error(`closure integrity gate failed — fix these before archiving:\n${formatIssues(issues)}`);
|
|
106
124
|
}
|
|
107
125
|
}
|
|
108
126
|
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
127
|
+
const files = readdirSync(dir);
|
|
128
|
+
const state = primaryState(dir, files);
|
|
129
|
+
// unique models across every stage's state file (A4) — collected HERE,
|
|
130
|
+
// before the fold deletes the .json files; team members and solo
|
|
131
|
+
// re-savepoints each record the model that ran their stage.
|
|
132
|
+
const stageModels = [...new Set(files.filter(isStateFile).map((f) => {
|
|
133
|
+
try {
|
|
134
|
+
const s = JSON.parse(readFileSync(join(dir, f), 'utf8')) as Record<string, unknown>;
|
|
135
|
+
return typeof s.model === 'string' ? s.model : '';
|
|
136
|
+
} catch { return ''; }
|
|
137
|
+
}).filter(Boolean))];
|
|
119
138
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
139
|
+
// Cost surface — always readable section for the report (T8)
|
|
140
|
+
let costSection = '';
|
|
141
|
+
if (!dryRun && state) {
|
|
142
|
+
const chars = measureContextChars(dir);
|
|
143
|
+
const budget = readBudgetConfig(projectDir);
|
|
144
|
+
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.`);
|
|
128
147
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
148
|
+
const est = typeof state.tokens_est === 'number' ? state.tokens_est : 0;
|
|
149
|
+
const src = typeof state.tokens_source === 'string' ? state.tokens_source : 'computed';
|
|
150
|
+
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;
|
|
155
|
+
const delta = effBudget ? (est <= effBudget ? `${(effBudget - est).toLocaleString()} under` : `${(est - effBudget).toLocaleString()} over`) : 'no budget configured';
|
|
156
|
+
const srcLabel = src === 'reported' ? 'provider-reported' : 'estimator';
|
|
157
|
+
// provider-reported rollup when any stage reported
|
|
158
|
+
let reportedTotal = 0;
|
|
159
|
+
let hasReported = false;
|
|
160
|
+
for (const f of files.filter(isStateFile)) {
|
|
161
|
+
try {
|
|
162
|
+
const s = JSON.parse(readFileSync(join(dir, f), 'utf8')) as Record<string, unknown>;
|
|
163
|
+
if (s.tokens_source === 'reported' && typeof s.tokens_est === 'number') {
|
|
164
|
+
reportedTotal += s.tokens_est;
|
|
165
|
+
hasReported = true;
|
|
166
|
+
}
|
|
167
|
+
} catch { /* corrupt — skip */ }
|
|
168
|
+
}
|
|
169
|
+
if (!hasReported && src === 'reported' && est > 0) {
|
|
170
|
+
reportedTotal = est;
|
|
171
|
+
hasReported = true;
|
|
138
172
|
}
|
|
173
|
+
costSection = [
|
|
174
|
+
'## Cost',
|
|
175
|
+
'',
|
|
176
|
+
'| Metric | Value |',
|
|
177
|
+
'|--------|-------|',
|
|
178
|
+
`| **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'} |`,
|
|
181
|
+
`| **Context footprint** | ${chars.toLocaleString()} chars${budget ? ` (budget ${budget.toLocaleString()})` : ' (no context budget configured)'} |`,
|
|
182
|
+
].join('\n');
|
|
183
|
+
if (hasReported) {
|
|
184
|
+
costSection += `\n| **Provider total** | ${reportedTotal.toLocaleString()} (provider-reported — sum of reported stages) |`;
|
|
185
|
+
}
|
|
186
|
+
costSection += '\n';
|
|
139
187
|
}
|
|
140
188
|
|
|
141
|
-
//
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
189
|
+
// Fold order: narrative artifacts first, wave evidence last (chronological).
|
|
190
|
+
const FOLD_TOP = ['decisions.md', 'blockers.md', 'review.md', 'security.md', 'spec.md'];
|
|
191
|
+
const fold: string[] = [];
|
|
192
|
+
for (const f of FOLD_TOP) {
|
|
193
|
+
if (files.includes(f)) fold.push(f);
|
|
146
194
|
}
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
195
|
+
// Flow artifacts: flows/ is the current layout; a legacy mission that still
|
|
196
|
+
// keeps waves/ folds from there so an upgrade never strands a trail.
|
|
197
|
+
const flowsDir = join(dir, 'flows');
|
|
198
|
+
const legacyWavesDir = join(dir, 'waves');
|
|
199
|
+
const artDir = existsSync(flowsDir) ? flowsDir : existsSync(legacyWavesDir) ? legacyWavesDir : flowsDir;
|
|
200
|
+
const artRel = artDir === legacyWavesDir ? 'waves' : 'flows';
|
|
201
|
+
if (existsSync(artDir)) {
|
|
202
|
+
for (const f of readdirSync(artDir).sort()) fold.push(join(artRel, f));
|
|
152
203
|
}
|
|
153
204
|
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
205
|
+
// The report survives: an existing report.md wins; otherwise the closure
|
|
206
|
+
// wave seeds it; otherwise it starts empty.
|
|
207
|
+
let report = '';
|
|
208
|
+
const reportPath = join(dir, 'report.md');
|
|
209
|
+
if (files.includes('report.md')) report = readFileSync(reportPath, 'utf8');
|
|
210
|
+
else if (existsSync(join(artDir, '06-closure.md'))) report = readFileSync(join(artDir, '06-closure.md'), 'utf8');
|
|
211
|
+
|
|
212
|
+
if (!dryRun) {
|
|
213
|
+
mkdirSync(dir, { recursive: true });
|
|
214
|
+
if (fold.length) {
|
|
215
|
+
const sections = fold.map((f) => {
|
|
216
|
+
const body = readFileSync(join(dir, f), 'utf8').trim();
|
|
217
|
+
const name = f.includes('/') ? (f.split('/').pop() ?? f) : f;
|
|
218
|
+
return `\n\n## Archived: ${name}\n\n${body}`;
|
|
219
|
+
}).join('');
|
|
220
|
+
// atomic: write the folded report to a temp file, then rename over the
|
|
221
|
+
// target. A crash mid-write must never leave a truncated report — the
|
|
222
|
+
// fold deletes the wave files right after, so a partial write loses them.
|
|
223
|
+
const tmp = `${reportPath}.tmp`;
|
|
224
|
+
const routingSection = state
|
|
225
|
+
? renderRouting(rankFiles(changedFiles(projectDir, state), {
|
|
226
|
+
mission,
|
|
227
|
+
evidence: Array.isArray(state.evidence) ? (state.evidence as string[]) : [],
|
|
228
|
+
sensitive_paths: Array.isArray(state.sensitive_paths) ? (state.sensitive_paths as string[]) : [],
|
|
229
|
+
} as never), mission)
|
|
230
|
+
: '';
|
|
231
|
+
writeFileSync(tmp, report.trimEnd() + sections + (routingSection || '') + (costSection ? `\n${costSection}\n` : '') + '\n');
|
|
232
|
+
renameSync(tmp, reportPath);
|
|
233
|
+
}
|
|
234
|
+
for (const f of fold) {
|
|
235
|
+
rmSync(join(dir, f), { force: true, recursive: true });
|
|
236
|
+
removed.push(join('missions', mission, f));
|
|
237
|
+
}
|
|
238
|
+
// session state dies with the mission
|
|
239
|
+
for (const f of files.filter((f) => f.endsWith('.json'))) rmSync(join(dir, f), { force: true });
|
|
240
|
+
// flows/ may now be empty — remove the folder
|
|
241
|
+
if (existsSync(artDir) && readdirSync(artDir).length === 0) rmSync(artDir, { recursive: true, force: true });
|
|
242
|
+
// report.md must exist after archive — the closed marker `mugiwara clean`
|
|
243
|
+
// filters on. A stale in-flight mission folds nothing, so seed a stub.
|
|
244
|
+
if (!existsSync(reportPath)) {
|
|
245
|
+
writeFileSync(reportPath, `# Mission: ${mission}\n\nArchived before closure — no wave artifacts were present.\n`);
|
|
246
|
+
}
|
|
247
|
+
// Closure artifacts: executable rollback map and the
|
|
248
|
+
// two-layer provenance record. Best-effort — absent git/base degrades.
|
|
249
|
+
if (state && typeof state.branch === 'string') {
|
|
250
|
+
const rb = generateRollback(projectDir, dir, {
|
|
251
|
+
mission,
|
|
252
|
+
branch: state.branch,
|
|
253
|
+
baseSha: typeof state.base_sha === 'string' ? state.base_sha : 'unknown',
|
|
254
|
+
});
|
|
255
|
+
if (rb) kept.push(join('missions', mission, rb.file));
|
|
256
|
+
try {
|
|
257
|
+
writeProvenance(projectDir, dir, {
|
|
258
|
+
mission,
|
|
259
|
+
actor: typeof state.actor === 'string' ? state.actor : '',
|
|
260
|
+
lane: typeof state.lane === 'string' ? state.lane : '',
|
|
261
|
+
mode: typeof state.mode === 'string' ? state.mode : '',
|
|
262
|
+
branch: state.branch,
|
|
263
|
+
tasks_done: Number(state.tasks_done) || 0,
|
|
264
|
+
tasks_total: Number(state.tasks_total) || 0,
|
|
265
|
+
evidence: Array.isArray(state.evidence) ? (state.evidence as string[]) : [],
|
|
266
|
+
models: stageModels,
|
|
267
|
+
});
|
|
268
|
+
kept.push(join('missions', mission, 'provenance.md'));
|
|
269
|
+
} catch { /* provenance is additive; archive proceeds */ }
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
for (const f of fold) removed.push(join('missions', mission, f));
|
|
158
273
|
}
|
|
274
|
+
removed.push(join('missions', mission, '<session state>'));
|
|
275
|
+
if (files.includes('plan.md')) kept.push(join('missions', mission, 'plan.md'));
|
|
276
|
+
if (files.includes('handoff.md')) kept.push(join('missions', mission, 'handoff.md'));
|
|
277
|
+
kept.push(join('missions', mission, 'report.md'));
|
|
159
278
|
|
|
160
|
-
// summary index: append one line per archived mission (retention aid)
|
|
161
|
-
//
|
|
279
|
+
// summary index: append one line per archived mission (retention aid).
|
|
280
|
+
// Atomic-append contract (finding A3): the line is written as ONE write()
|
|
281
|
+
// on an O_APPEND fd — POSIX positions O_APPEND writes atomically, so
|
|
282
|
+
// concurrent archivers never overwrite each other's bytes, and a small
|
|
283
|
+
// (<4k) single write does not interleave in practice. The pre-read
|
|
284
|
+
// idempotency check below still has a benign race window under true
|
|
285
|
+
// concurrency: two racers may both see the line missing and both append.
|
|
286
|
+
// That is a duplicate line, not a lost one — duplicates are the preferred
|
|
287
|
+
// failure mode; any future consumer of index.md must dedupe lines on read.
|
|
288
|
+
// Header creation stays racy-but-safe: two first-appends may each prepend
|
|
289
|
+
// "# Mission index\n\n", and header-only-plus-lines remains valid markdown
|
|
290
|
+
// either way.
|
|
162
291
|
let index: string | undefined;
|
|
163
|
-
const indexFile = join(root, '
|
|
164
|
-
const line = `- ${mission} — ${new Date().toISOString().slice(0, 10)}
|
|
292
|
+
const indexFile = join(root, 'index.md');
|
|
293
|
+
const line = `- ${mission} — ${new Date().toISOString().slice(0, 10)}\n`;
|
|
165
294
|
if (!dryRun) {
|
|
166
|
-
mkdirSync(join(root, 'reports'), { recursive: true });
|
|
167
295
|
const existing = existsSync(indexFile) ? readFileSync(indexFile, 'utf8') : '';
|
|
168
296
|
if (!existing.split(/\r?\n/).some(l => l.startsWith(`- ${mission} —`))) {
|
|
169
297
|
const header = existing ? '' : '# Mission index\n\n';
|
|
170
|
-
|
|
298
|
+
const fd = openSync(indexFile, 'a');
|
|
299
|
+
try {
|
|
300
|
+
writeSync(fd, header + line);
|
|
301
|
+
} finally {
|
|
302
|
+
closeSync(fd);
|
|
303
|
+
}
|
|
171
304
|
}
|
|
172
|
-
index =
|
|
305
|
+
index = 'index.md';
|
|
173
306
|
}
|
|
174
|
-
return { report, removed, kept, index };
|
|
307
|
+
return { report: join('missions', mission, 'report.md'), removed, kept, index };
|
|
175
308
|
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// src/policy.ts
|
|
2
|
+
// mugiwara.policy.yml — org rules that override crew judgement.
|
|
3
|
+
//
|
|
4
|
+
// One optional file at the repo root; absent means today's
|
|
5
|
+
// behavior everywhere. Policy only ever pushes UP (more scrutiny), never down:
|
|
6
|
+
// lanes forced to full, coverage thresholds raised, paths flagged for human
|
|
7
|
+
// approval.
|
|
8
|
+
//
|
|
9
|
+
// The parser is a deliberate YAML subset — nested maps, string arrays,
|
|
10
|
+
// scalars — enough for the documented schema and nothing more. No dependency:
|
|
11
|
+
// a governance file that needs an npm install to read would not be read.
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
export type MugiwaraPolicy = {
|
|
17
|
+
lanes?: { force_full?: string[] };
|
|
18
|
+
gates?: {
|
|
19
|
+
coverage?: { new?: number; modified?: number };
|
|
20
|
+
require_human_approval?: string[];
|
|
21
|
+
};
|
|
22
|
+
evidence?: { required?: string[] };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const POLICY_FILES = ['mugiwara.policy.yml', 'mugiwara.policy.yaml'];
|
|
26
|
+
const KNOWN_ROOTS = ['lanes', 'gates', 'evidence'];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Minimal YAML subset: maps, `- item` string lists, scalars.
|
|
30
|
+
*
|
|
31
|
+
* A `key:` with no value stays pending on its scope until a deeper line turns
|
|
32
|
+
* it into a map (first mapping child) or a list (first `- ` child). Scopes are
|
|
33
|
+
* keyed by the indent of their own key line; any line at indent <= a scope's
|
|
34
|
+
* key indent leaves that scope. Enough for the documented schema, nothing more.
|
|
35
|
+
*/
|
|
36
|
+
export function parsePolicyYaml(text: string): Record<string, unknown> {
|
|
37
|
+
const root: Record<string, unknown> = {};
|
|
38
|
+
type Scope = { keyIndent: number; obj: Record<string, unknown>; pending?: { key: string; indent: number } };
|
|
39
|
+
const stack: Scope[] = [{ keyIndent: -1, obj: root }];
|
|
40
|
+
const lines = text.split(/\r?\n/);
|
|
41
|
+
|
|
42
|
+
const process = (i: number): void => {
|
|
43
|
+
if (i >= lines.length) return;
|
|
44
|
+
const noComment = lines[i].replace(/(^|\s)#.*$/, '');
|
|
45
|
+
if (!noComment.trim()) return process(i + 1);
|
|
46
|
+
const indent = noComment.length - noComment.trimStart().length;
|
|
47
|
+
const line = noComment.trim();
|
|
48
|
+
|
|
49
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].keyIndent) stack.pop();
|
|
50
|
+
const top = stack[stack.length - 1];
|
|
51
|
+
|
|
52
|
+
// A pending `parent:` whose first child this line is → become a map scope,
|
|
53
|
+
// then dispatch this same line into the new scope. A `- ` child instead
|
|
54
|
+
// turns the pending key into a LIST below — it must not materialize a map.
|
|
55
|
+
if (top.pending && !line.startsWith('- ') && indent > top.pending.indent && !Array.isArray(top.obj[top.pending.key])) {
|
|
56
|
+
const { key, indent: pIndent } = top.pending;
|
|
57
|
+
delete top.pending;
|
|
58
|
+
const created: Record<string, unknown> = {};
|
|
59
|
+
top.obj[key] = created;
|
|
60
|
+
stack.push({ keyIndent: pIndent, obj: created });
|
|
61
|
+
return process(i);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (line.startsWith('- ')) {
|
|
65
|
+
if (top.pending) {
|
|
66
|
+
const existing = top.obj[top.pending.key];
|
|
67
|
+
const arr: unknown[] = Array.isArray(existing) ? existing : [];
|
|
68
|
+
arr.push(scalar(line.slice(2)));
|
|
69
|
+
top.obj[top.pending.key] = arr;
|
|
70
|
+
}
|
|
71
|
+
// stray items outside a pending key are ignored in this subset
|
|
72
|
+
return process(i + 1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const colon = line.indexOf(':');
|
|
76
|
+
if (colon === -1) return process(i + 1); // not a mapping line in this subset
|
|
77
|
+
const key = line.slice(0, colon).trim();
|
|
78
|
+
const rest = line.slice(colon + 1).trim();
|
|
79
|
+
if (rest === '') {
|
|
80
|
+
top.pending = { key, indent };
|
|
81
|
+
} else {
|
|
82
|
+
top.obj[key] = scalar(rest);
|
|
83
|
+
delete top.pending;
|
|
84
|
+
}
|
|
85
|
+
process(i + 1);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
process(0);
|
|
89
|
+
return root;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function scalar(v: string): unknown {
|
|
93
|
+
const t = v.trim().replace(/^["']|["']$/g, '');
|
|
94
|
+
if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
|
|
95
|
+
if (t === 'true') return true;
|
|
96
|
+
if (t === 'false') return false;
|
|
97
|
+
return t;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function loadPolicy(projectDir: string): MugiwaraPolicy | null {
|
|
101
|
+
for (const name of POLICY_FILES) {
|
|
102
|
+
const file = join(projectDir, name);
|
|
103
|
+
if (!existsSync(file)) continue;
|
|
104
|
+
return normalize(parsePolicyYaml(readFileSync(file, 'utf8')));
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalize(raw: Record<string, unknown>): MugiwaraPolicy {
|
|
110
|
+
// A typo'd root key would otherwise silently disable the rule it carried.
|
|
111
|
+
for (const k of Object.keys(raw)) {
|
|
112
|
+
if (!KNOWN_ROOTS.includes(k)) throw new Error(`unknown policy key "${k}" (known: ${KNOWN_ROOTS.join(', ')})`);
|
|
113
|
+
}
|
|
114
|
+
const out: MugiwaraPolicy = {};
|
|
115
|
+
const lanes = raw.lanes as Record<string, unknown> | undefined;
|
|
116
|
+
if (lanes && Array.isArray(lanes.force_full)) out.lanes = { force_full: strings(lanes.force_full) };
|
|
117
|
+
const gates = raw.gates as Record<string, unknown> | undefined;
|
|
118
|
+
if (gates) {
|
|
119
|
+
out.gates = {};
|
|
120
|
+
const cov = gates.coverage as Record<string, unknown> | undefined;
|
|
121
|
+
if (cov) {
|
|
122
|
+
out.gates.coverage = {};
|
|
123
|
+
if (typeof cov.new === 'number') out.gates.coverage.new = cov.new;
|
|
124
|
+
if (typeof cov.modified === 'number') out.gates.coverage.modified = cov.modified;
|
|
125
|
+
}
|
|
126
|
+
if (Array.isArray(gates.require_human_approval))
|
|
127
|
+
out.gates.require_human_approval = strings(gates.require_human_approval);
|
|
128
|
+
}
|
|
129
|
+
const evidence = raw.evidence as Record<string, unknown> | undefined;
|
|
130
|
+
if (evidence && Array.isArray(evidence.required)) out.evidence = { required: strings(evidence.required) };
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function strings(a: unknown[]): string[] {
|
|
135
|
+
return a.filter((x): x is string => typeof x === 'string' && x.length > 0);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Glob → RegExp: `**` crosses separators, `*` stays within one. */
|
|
139
|
+
export function globToRegExp(glob: string): RegExp {
|
|
140
|
+
const esc = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
141
|
+
const src = esc.replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\u0000/g, '.*');
|
|
142
|
+
return new RegExp(`^${src}$`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Any changed path matching any policy glob? Returns the matching globs. */
|
|
146
|
+
export function matchedGlobs(paths: string[], globs: string[]): string[] {
|
|
147
|
+
return globs.filter((g) => paths.some((p) => globToRegExp(g).test(p)));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Coverage thresholds: the max of the .mugiwara/config value (or its
|
|
152
|
+
* fallback) and any policy raise. Policy pushes up, never down.
|
|
153
|
+
*/
|
|
154
|
+
export function effectiveThreshold(configured: number, policyValue: number | undefined): number {
|
|
155
|
+
return Math.max(configured, policyValue ?? 0);
|
|
156
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// src/provenance.ts
|
|
2
|
+
// Provenance ledger: line-of-sight from a commit to the
|
|
3
|
+
// mission that produced it — which agent persona, which lane, verified by
|
|
4
|
+
// what evidence. Distributed in two layers:
|
|
5
|
+
// 1. a git note on refs/notes/mugiwara attached to the branch head
|
|
6
|
+
// (local precision archive; survives rebase via notes.rewriteRef)
|
|
7
|
+
// 2. provenance.md in the mission dir, ready to paste as a PR comment
|
|
8
|
+
// (the layer every hosting UI can show)
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const NOTES_REF = 'refs/notes/mugiwara';
|
|
14
|
+
|
|
15
|
+
/** Structural subset any mission state satisfies — keeps this import-free. */
|
|
16
|
+
export type NoteSource = {
|
|
17
|
+
mission: string;
|
|
18
|
+
actor: string;
|
|
19
|
+
lane: string;
|
|
20
|
+
mode: string;
|
|
21
|
+
branch: string;
|
|
22
|
+
tasks_done: number;
|
|
23
|
+
tasks_total: number;
|
|
24
|
+
evidence: string[];
|
|
25
|
+
models?: string[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function git(cwd: string, args: string[]): string {
|
|
29
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function modelLabel(): string {
|
|
33
|
+
return process.env.MUGIWARA_MODEL?.trim()
|
|
34
|
+
|| process.env.ANTHROPIC_MODEL?.trim()
|
|
35
|
+
|| 'model-unrecorded (set MUGIWARA_MODEL to attribute)';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The provenance block — identical wording in the note and the md file. */
|
|
39
|
+
export function buildNote(s: {
|
|
40
|
+
mission: string; actor: string; lane: string; mode: string; branch: string;
|
|
41
|
+
tasks_done: number; tasks_total: number; evidence: string[]; model?: string; models?: string[];
|
|
42
|
+
}): string {
|
|
43
|
+
const gates = s.evidence.length ? s.evidence.join(' · ') : 'no evidence recorded';
|
|
44
|
+
// Per-stage attribution (A4): when flow history recorded models, render the
|
|
45
|
+
// unique set — a mid-mission switch must not collapse to the last env value.
|
|
46
|
+
// With nothing recorded, keep the env-fallback label wording.
|
|
47
|
+
const uniqModels = [...new Set((s.models ?? []).filter(Boolean))];
|
|
48
|
+
const modelPart = uniqModels.length ? `model(s): ${uniqModels.join(', ')}` : (s.model ?? modelLabel());
|
|
49
|
+
return [
|
|
50
|
+
`mission: ${s.mission}`,
|
|
51
|
+
`agent: ${s.actor || 'unknown'} · ${modelPart} · lane ${s.lane} · mode ${s.mode}`,
|
|
52
|
+
`tasks: ${s.tasks_done}/${s.tasks_total}`,
|
|
53
|
+
`gates/evidence: ${gates}`,
|
|
54
|
+
`branch: ${s.branch}`,
|
|
55
|
+
'human review: pending (PR review is the terminal gate)',
|
|
56
|
+
].join('\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** PR-paste-ready markdown wrapper around the same facts. */
|
|
60
|
+
export function renderProvenanceMd(note: string, sha: string | null): string {
|
|
61
|
+
const lines = [
|
|
62
|
+
'# Provenance',
|
|
63
|
+
'',
|
|
64
|
+
'<!-- paste below into the PR description or a PR comment -->',
|
|
65
|
+
'',
|
|
66
|
+
'```',
|
|
67
|
+
note,
|
|
68
|
+
'```',
|
|
69
|
+
'',
|
|
70
|
+
sha ? `Commit: ${sha}` : 'Commit: not recorded (no git head resolved at closure)',
|
|
71
|
+
'',
|
|
72
|
+
'Query locally after pushing notes:',
|
|
73
|
+
'`git fetch origin refs/notes/mugiwara:refs/notes/mugiwara` then `mugiwara blame <path>`.',
|
|
74
|
+
];
|
|
75
|
+
return lines.join('\n') + '\n';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function attachGitNote(projectDir: string, branch: string, note: string): { sha: string } | null {
|
|
79
|
+
try {
|
|
80
|
+
let sha: string;
|
|
81
|
+
try {
|
|
82
|
+
sha = git(projectDir, ['rev-parse', '--verify', branch]);
|
|
83
|
+
} catch {
|
|
84
|
+
sha = git(projectDir, ['rev-parse', 'HEAD']);
|
|
85
|
+
}
|
|
86
|
+
git(projectDir, ['notes', '--ref=mugiwara', 'add', '-f', '-m', note, sha]);
|
|
87
|
+
return { sha };
|
|
88
|
+
} catch {
|
|
89
|
+
// not a repo, detached oddities, or notes disabled — degrade honestly
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `mugiwara blame <path>` — last commit that touched the path + its note. */
|
|
95
|
+
export function blamePath(projectDir: string, path: string): string {
|
|
96
|
+
let sha: string;
|
|
97
|
+
try {
|
|
98
|
+
sha = git(projectDir, ['log', '-1', '--format=%H', '--', path]);
|
|
99
|
+
} catch {
|
|
100
|
+
return `blame: not a git repository (${projectDir})`;
|
|
101
|
+
}
|
|
102
|
+
if (!sha) return `blame: no commit touches "${path}"`;
|
|
103
|
+
try {
|
|
104
|
+
const note = git(projectDir, ['notes', '--ref=mugiwara', 'show', sha]);
|
|
105
|
+
return `${path} @ ${sha.slice(0, 7)}\n${note}`;
|
|
106
|
+
} catch {
|
|
107
|
+
return `${path} @ ${sha.slice(0, 7)}\n(no mugiwara provenance note on this commit)`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Closure hook: write provenance.md + attach the git note. */
|
|
112
|
+
export function writeProvenance(projectDir: string, missionDir: string, state: NoteSource): void {
|
|
113
|
+
const note = buildNote(state);
|
|
114
|
+
const attached = attachGitNote(projectDir, state.branch, note);
|
|
115
|
+
writeFileSync(join(missionDir, 'provenance.md'), renderProvenanceMd(note, attached ? attached.sha : null));
|
|
116
|
+
}
|