@ionivetech/mugiwara 0.8.1 → 0.8.2
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 +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.kimi-plugin/plugin.json +1 -1
- package/content/skills/mugiwara-orchestration/SKILL.md +2 -2
- package/content/skills/mugiwara-workflow/SKILL.md +19 -19
- package/dist/mugiwara.js +143 -36
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/gate-selftest.ts +156 -1
- package/scripts/lane-base.ts +16 -0
- package/scripts/lane.sh +5 -1
- package/scripts/lib/lane-base.sh +1 -1
- package/scripts/savepoint.sh +28 -5
- package/src/cli.ts +60 -18
- package/src/continue.ts +7 -1
- package/src/cost.ts +1 -1
- package/src/installer.ts +27 -4
- package/src/integrity.ts +41 -10
- package/src/policy.ts +17 -2
package/scripts/gate-selftest.ts
CHANGED
|
@@ -649,7 +649,7 @@ console.log('\nT3 — lane-aware gates');
|
|
|
649
649
|
return s.length === 12 && s.includes('run-evals') && s.includes('retrieval-eval') && s.includes('conformance');
|
|
650
650
|
});
|
|
651
651
|
assert('budget direct → 0, full → 50000', true, () => budgetForLane('direct') === 0 && budgetForLane('full') === 50000);
|
|
652
|
-
assert('budget spike →
|
|
652
|
+
assert('budget spike → 9000 (direct fixture 9k)', true, () => budgetForLane('spike') === 9000);
|
|
653
653
|
// mutation: break direct step count → should fail (file content shows not 3)
|
|
654
654
|
const broken = originalPolicy.replace(
|
|
655
655
|
"direct: ['build-hooks:check', 'typecheck', 'build']",
|
|
@@ -675,5 +675,160 @@ console.log('\nT3 — lane-aware gates');
|
|
|
675
675
|
});
|
|
676
676
|
}
|
|
677
677
|
|
|
678
|
+
// --- B1: CLI availability — remove section → content validation fails ---
|
|
679
|
+
console.log('\nB1 — CLI availability');
|
|
680
|
+
{
|
|
681
|
+
const wf = join(root, 'content', 'skills', 'mugiwara-workflow', 'SKILL.md');
|
|
682
|
+
const original = readFileSync(wf, 'utf8');
|
|
683
|
+
try {
|
|
684
|
+
const b1Pattern = /## CLI availability[\s\S]*?## Artifact trust/;
|
|
685
|
+
const b1Broken = original.replace(b1Pattern, '## Artifact trust');
|
|
686
|
+
if (!b1Pattern.test(original) || b1Broken === original) {
|
|
687
|
+
console.error('✗ B1: mutation target not found — the gate it guards may be dead.');
|
|
688
|
+
failed++;
|
|
689
|
+
} else {
|
|
690
|
+
writeFileSync(wf, b1Broken);
|
|
691
|
+
assert('missing CLI availability → grep fails', false, () => run('B1-grep', 'grep -q "CLI availability" content/skills/mugiwara-workflow/SKILL.md'));
|
|
692
|
+
}
|
|
693
|
+
} finally {
|
|
694
|
+
writeFileSync(wf, original);
|
|
695
|
+
assert('restored → content validation passes', true, () => run('B1-restore', 'grep -q "CLI availability" content/skills/mugiwara-workflow/SKILL.md'));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// --- B2: team evidence gate — revert to single state.json → integrity gate fails on team ---
|
|
700
|
+
console.log('\nB2 — team evidence gate');
|
|
701
|
+
{
|
|
702
|
+
const integ = join(root, 'src', 'integrity.ts');
|
|
703
|
+
const original = readFileSync(integ, 'utf8');
|
|
704
|
+
try {
|
|
705
|
+
const fixedPattern = /const stateFiles = existsSync\(missionDir\)/;
|
|
706
|
+
const broken = original.replace(fixedPattern, "const evidenceFile = join(missionDir, 'state.json'); // B2 revert");
|
|
707
|
+
const fullPattern = / \/\/ Solo layout writes state\.json; team layout writes <member>\.json per member\./;
|
|
708
|
+
let b2Broken = original;
|
|
709
|
+
if (fullPattern.test(original)) {
|
|
710
|
+
// remove the team-aware block header to make grep for stateFiles fail for the specific definition
|
|
711
|
+
b2Broken = original.replace(fixedPattern, "const evidenceFile = join(missionDir, 'state.json'); // B2 revert");
|
|
712
|
+
// also need to remove remaining stateFiles references to make grep fail - replace all stateFiles with evidenceFile
|
|
713
|
+
b2Broken = b2Broken.replace(/stateFiles/g, 'evidenceFile');
|
|
714
|
+
}
|
|
715
|
+
if (!fixedPattern.test(original) || broken === original) {
|
|
716
|
+
console.error('✗ B2: mutation target not found — the gate it guards may be dead.');
|
|
717
|
+
failed++;
|
|
718
|
+
} else {
|
|
719
|
+
writeFileSync(integ, b2Broken);
|
|
720
|
+
assert('single state.json → team evidence gate dead', false, () => run('B2', 'grep -q "const stateFiles = existsSync" src/integrity.ts'));
|
|
721
|
+
}
|
|
722
|
+
} finally {
|
|
723
|
+
writeFileSync(integ, original);
|
|
724
|
+
assert('restored → team gate present', true, () => run('B2-restore', 'grep -q "const stateFiles = existsSync" src/integrity.ts'));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// --- B3: task counter — restore unanchored grep → savepoint task test fails ---
|
|
729
|
+
console.log('\nB3 — task counter');
|
|
730
|
+
{
|
|
731
|
+
const sp = join(root, 'scripts', 'savepoint.sh');
|
|
732
|
+
const original = readFileSync(sp, 'utf8');
|
|
733
|
+
try {
|
|
734
|
+
const broken = original.replace('TASKS_TOTAL=$(count_boxes "$PLAN_FILE" \'[ xX]\')', 'TASKS_TOTAL=$(grep -cE \'^\\s*-\\s*\\[[ xX]\\]\' "$PLAN_FILE" 2>/dev/null || true)')
|
|
735
|
+
.replace('TASKS_DONE=$(count_boxes "$PLAN_FILE" \'[xX]\')', 'TASKS_DONE=$(grep -c \'\\[x\\]\' "$PLAN_FILE" 2>/dev/null || true)');
|
|
736
|
+
if (broken === original) {
|
|
737
|
+
console.error('✗ B3: mutation target not found — the gate it guards may be dead.');
|
|
738
|
+
failed++;
|
|
739
|
+
} else {
|
|
740
|
+
writeFileSync(sp, broken);
|
|
741
|
+
assert('unanchored grep → savepoint task test fails', false, () => run('B3', 'bun run test -- savepoint -t "B3: task counting"'));
|
|
742
|
+
}
|
|
743
|
+
} finally {
|
|
744
|
+
writeFileSync(sp, original);
|
|
745
|
+
assert('restored → savepoint task test passes', true, () => run('B3-restore', 'bun run test -- savepoint -t "B3: task counting"'));
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// --- B4: repo root — restore [ -d .git ] → lane subdirectory test fails ---
|
|
750
|
+
console.log('\nB4 — repo root');
|
|
751
|
+
{
|
|
752
|
+
const lane = join(root, 'scripts', 'lane.sh');
|
|
753
|
+
const original = readFileSync(lane, 'utf8');
|
|
754
|
+
try {
|
|
755
|
+
const broken = original.replace(
|
|
756
|
+
/# Resolve the repo root: handles subdirectories and git worktrees[\s\S]*?cd "\$REPO_ROOT" \|\| \{ echo "lane: cannot enter repo root" >&2; exit 1; \}/,
|
|
757
|
+
'[ -d .git ] || { echo "lane: not a git repository" >&2; exit 1; }'
|
|
758
|
+
);
|
|
759
|
+
if (broken === original) {
|
|
760
|
+
console.error('✗ B4: mutation target not found — the gate it guards may be dead.');
|
|
761
|
+
failed++;
|
|
762
|
+
} else {
|
|
763
|
+
writeFileSync(lane, broken);
|
|
764
|
+
assert('[ -d .git ] → lane subdirectory gate dead', false, () => run('B4', 'grep -q "git rev-parse --show-toplevel" scripts/lane.sh'));
|
|
765
|
+
}
|
|
766
|
+
} finally {
|
|
767
|
+
writeFileSync(lane, original);
|
|
768
|
+
assert('restored → lane uses git rev-parse', true, () => run('B4-restore', 'grep -q "git rev-parse --show-toplevel" scripts/lane.sh'));
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// --- B5: spike budget — set below base → lane-base fails ---
|
|
773
|
+
console.log('\nB5 — spike budget');
|
|
774
|
+
{
|
|
775
|
+
const baseFile = join(root, 'scripts', 'lib', 'lane-base.sh');
|
|
776
|
+
const original = readFileSync(baseFile, 'utf8');
|
|
777
|
+
try {
|
|
778
|
+
const broken = original.replace('BUDGET_spike=9000', 'BUDGET_spike=3000');
|
|
779
|
+
if (broken === original) {
|
|
780
|
+
console.error('✗ B5: mutation target not found — the gate it guards may be dead.');
|
|
781
|
+
failed++;
|
|
782
|
+
} else {
|
|
783
|
+
writeFileSync(baseFile, broken);
|
|
784
|
+
assert('BUDGET_spike 3000 < LANE_BASE 5411 → lane-base fails', false, () => run('B5', 'bun scripts/lane-base.ts'));
|
|
785
|
+
}
|
|
786
|
+
} finally {
|
|
787
|
+
writeFileSync(baseFile, original);
|
|
788
|
+
assert('restored → lane-base passes', true, () => run('B5-restore', 'bun scripts/lane-base.ts'));
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// --- B6: corrupt state — swallow parse errors again → status test fails ---
|
|
793
|
+
console.log('\nB6 — corrupt state');
|
|
794
|
+
{
|
|
795
|
+
const cont = join(root, 'src', 'continue.ts');
|
|
796
|
+
const original = readFileSync(cont, 'utf8');
|
|
797
|
+
try {
|
|
798
|
+
const broken = original.replace('unreadable.push(join(mission, f));', '// corrupt savepoint — skip, never crash the listing');
|
|
799
|
+
if (broken === original) {
|
|
800
|
+
console.error('✗ B6: mutation target not found — the gate it guards may be dead.');
|
|
801
|
+
failed++;
|
|
802
|
+
} else {
|
|
803
|
+
writeFileSync(cont, broken);
|
|
804
|
+
assert('swallow parse errors → unreadable gate dead', false, () => run('B6', 'grep -q "unreadableStateFiles" src/continue.ts && grep -q "unreadable.push" src/continue.ts'));
|
|
805
|
+
}
|
|
806
|
+
} finally {
|
|
807
|
+
writeFileSync(cont, original);
|
|
808
|
+
assert('restored → corrupt state surfaced', true, () => run('B6-restore', 'grep -q "unreadable.push" src/continue.ts'));
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// --- B7: zero evidence — remove check → integrity team test fails ---
|
|
813
|
+
console.log('\nB7 — zero evidence');
|
|
814
|
+
{
|
|
815
|
+
const integ = join(root, 'src', 'integrity.ts');
|
|
816
|
+
const original = readFileSync(integ, 'utf8');
|
|
817
|
+
try {
|
|
818
|
+
const b7Pattern = /mission declares no evidence/;
|
|
819
|
+
const broken = original.replace(b7Pattern, 'ZERO_EVIDENCE_REMOVED');
|
|
820
|
+
if (!b7Pattern.test(original) || broken === original) {
|
|
821
|
+
console.error('✗ B7: mutation target not found — the gate it guards may be dead.');
|
|
822
|
+
failed++;
|
|
823
|
+
} else {
|
|
824
|
+
writeFileSync(integ, broken);
|
|
825
|
+
assert('no zero-evidence check → integrity gate dead', false, () => run('B7', 'grep -q "mission declares no evidence" src/integrity.ts'));
|
|
826
|
+
}
|
|
827
|
+
} finally {
|
|
828
|
+
writeFileSync(integ, original);
|
|
829
|
+
assert('restored → zero-evidence warned', true, () => run('B7-restore', 'grep -q "mission declares no evidence" src/integrity.ts'));
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
678
833
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
679
834
|
process.exit(failed > 0 ? 1 : 0);
|
package/scripts/lane-base.ts
CHANGED
|
@@ -107,6 +107,22 @@ for (const lane of lanes) {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// A lane whose base exceeds its budget is born in `warn` — the budget is then
|
|
111
|
+
// noise rather than a signal. (B5)
|
|
112
|
+
for (const lane of ['lean', 'standard', 'full', 'spike']) {
|
|
113
|
+
const base = constants[lane]?.base ?? 0;
|
|
114
|
+
const budget = constants[lane]?.budget ?? 0;
|
|
115
|
+
if (base >= budget) {
|
|
116
|
+
console.log(` ✗ LANE_BASE_${lane} (${base}) >= BUDGET_${lane} (${budget}) — every mission starts over budget`);
|
|
117
|
+
failures++;
|
|
118
|
+
}
|
|
119
|
+
const pct = budget ? Math.round((base / budget) * 100) : 100;
|
|
120
|
+
if (pct > 80) {
|
|
121
|
+
console.log(` ✗ LANE_BASE_${lane} is ${pct}% of BUDGET_${lane} — leaves no headroom (target ≤70%)`);
|
|
122
|
+
failures++;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
110
126
|
if (failures > 0) {
|
|
111
127
|
console.log(`\nlane-base: ${failures} constant(s) drifted from content load`);
|
|
112
128
|
process.exit(1);
|
package/scripts/lane.sh
CHANGED
|
@@ -11,7 +11,11 @@ BASE="${1:-main}"
|
|
|
11
11
|
JSON_OUT=0
|
|
12
12
|
[ "${2:-}" = "--json" ] && JSON_OUT=1
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
# Resolve the repo root: handles subdirectories and git worktrees, where .git
|
|
15
|
+
# is a file rather than a directory. (B4)
|
|
16
|
+
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
|
17
|
+
echo "lane: not a git repository" >&2; exit 1; }
|
|
18
|
+
cd "$REPO_ROOT" || { echo "lane: cannot enter repo root" >&2; exit 1; }
|
|
15
19
|
|
|
16
20
|
# resolve base
|
|
17
21
|
if ! git rev-parse "$BASE" >/dev/null 2>&1; then
|
package/scripts/lib/lane-base.sh
CHANGED
package/scripts/savepoint.sh
CHANGED
|
@@ -8,6 +8,18 @@ set -u
|
|
|
8
8
|
|
|
9
9
|
die() { echo "savepoint: $*" >&2; exit 1; }
|
|
10
10
|
|
|
11
|
+
# count_boxes <file> <char-class> — count markdown checkboxes.
|
|
12
|
+
# Anchored so prose mentioning "- [x]" is not counted; skips fenced code blocks
|
|
13
|
+
# so documentation examples are not counted; matches [x] and [X] alike. (B3)
|
|
14
|
+
count_boxes() {
|
|
15
|
+
[ -f "$1" ] || { echo 0; return; }
|
|
16
|
+
awk -v pat="$2" '
|
|
17
|
+
/^[[:space:]]*```/ { inblock = !inblock; next }
|
|
18
|
+
!inblock && $0 ~ ("^[[:space:]]*-[[:space:]]*\\[" pat "\\]") { n++ }
|
|
19
|
+
END { print n+0 }
|
|
20
|
+
' "$1"
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
MUGIWARA_DIR="${MUGIWARA_DIR:-.mugiwara}"
|
|
12
24
|
|
|
13
25
|
# optional provider-reported tokens file (T4): --tokens-file <path> JSON {input_tokens, output_tokens}
|
|
@@ -154,6 +166,11 @@ esac
|
|
|
154
166
|
# (BSD/macOS-safe: no \+ BRE).
|
|
155
167
|
BRANCH_SLUG=$(echo "$BRANCH" | tr '/' '-' | tr -cd 'A-Za-z0-9._-' | sed 's/^\.\{1,\}$//' )
|
|
156
168
|
|
|
169
|
+
# Resolve the repo root: handles subdirectories and git worktrees, where .git
|
|
170
|
+
# is a file rather than a directory. (B4)
|
|
171
|
+
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || die "not a git repository"
|
|
172
|
+
cd "$REPO_ROOT" || die "cannot enter repo root"
|
|
173
|
+
|
|
157
174
|
# state + continue live in the mission dir. Solo (member empty) → state.json
|
|
158
175
|
# + continue.json; team writes <member>.json + continue-<member>.json so
|
|
159
176
|
# parallel members never clobber each other.
|
|
@@ -167,7 +184,6 @@ else
|
|
|
167
184
|
fi
|
|
168
185
|
|
|
169
186
|
[ -z "$MISSION" ] && die "usage: savepoint.sh <mission> [member] [wave] [mode] [lane]"
|
|
170
|
-
[ -d .git ] || die "not a git repository"
|
|
171
187
|
|
|
172
188
|
# --- computed fields ---
|
|
173
189
|
BASE_SHA=$(git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null || git merge-base HEAD "$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||')" 2>/dev/null || git rev-parse HEAD~1 2>/dev/null || echo "unknown")
|
|
@@ -328,16 +344,23 @@ if [ -n "$PLAN_FILE" ] && [ -f "$PLAN_FILE" ]; then
|
|
|
328
344
|
# total counts ALL task lines (checked + unchecked); done counts checked only.
|
|
329
345
|
# A fully-completed plan must read total=N done=N, never total=0 (the old
|
|
330
346
|
# unchecked-only grep degenerated a done plan to tasks.total=0).
|
|
331
|
-
TASKS_TOTAL=$(
|
|
332
|
-
TASKS_DONE=$(
|
|
347
|
+
TASKS_TOTAL=$(count_boxes "$PLAN_FILE" '[ xX]')
|
|
348
|
+
TASKS_DONE=$(count_boxes "$PLAN_FILE" '[xX]')
|
|
333
349
|
fi
|
|
334
350
|
# Fallback for large campaigns (>3 phases, >1500 lines) where master plan.md is an index
|
|
335
351
|
# and tasks live in sub-plan/*.md — only when plan.md has zero checkbox tasks to
|
|
336
352
|
# keep simple missions unchanged.
|
|
337
353
|
if [ "${TASKS_TOTAL:-0}" -eq 0 ] 2>/dev/null && [ -d "$MISSION_DIR/sub-plan" ]; then
|
|
338
|
-
TASKS_TOTAL
|
|
339
|
-
|
|
354
|
+
TASKS_TOTAL=0; TASKS_DONE=0
|
|
355
|
+
for _sp in "$MISSION_DIR"/sub-plan/*.md; do
|
|
356
|
+
[ -f "$_sp" ] || continue
|
|
357
|
+
TASKS_TOTAL=$(( TASKS_TOTAL + $(count_boxes "$_sp" '[ xX]') ))
|
|
358
|
+
TASKS_DONE=$(( TASKS_DONE + $(count_boxes "$_sp" '[xX]') ))
|
|
359
|
+
done
|
|
340
360
|
fi
|
|
361
|
+
# done ≤ total is an invariant of the audit trail — never let a report show
|
|
362
|
+
# progress above 100%, whatever the plan file contains. (B3)
|
|
363
|
+
[ "${TASKS_DONE:-0}" -gt "${TASKS_TOTAL:-0}" ] 2>/dev/null && TASKS_DONE="$TASKS_TOTAL"
|
|
341
364
|
|
|
342
365
|
# blocker count
|
|
343
366
|
BLOCKERS_FILE="$MISSION_DIR/blockers.md"
|
package/src/cli.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { installTo, removeInstalled, VERSION, ensureProjectGitignore, removeProj
|
|
|
12
12
|
import { manifestPath, readManifest, writeManifest, type Scope } from './manifest.ts';
|
|
13
13
|
import { resetMission, archiveMission } from './mission.ts';
|
|
14
14
|
import { runScript, RUNNABLE } from './run.ts';
|
|
15
|
-
import { readContinue, readState, resolveContinue, formatTable, formatResume, gitActor, hasLegacyLayout, CURRENT_SCHEMA_VERSION } from './continue.ts';
|
|
15
|
+
import { readContinue, readState, resolveContinue, formatTable, formatResume, gitActor, hasLegacyLayout, CURRENT_SCHEMA_VERSION, unreadableStateFiles } from './continue.ts';
|
|
16
16
|
import { blamePath } from './provenance.ts';
|
|
17
17
|
import { signReport, verifyReport, ensurePureKey, hasMinisign } from './sign.ts';
|
|
18
18
|
import { ensureConfig } from './config.ts';
|
|
@@ -25,6 +25,17 @@ import { enforceHarnessPolicy } from './policy.ts';
|
|
|
25
25
|
const str = (v: FlagValue): string | undefined => (typeof v === 'string' ? v : undefined);
|
|
26
26
|
const flag = (v: FlagValue): boolean => v === true;
|
|
27
27
|
|
|
28
|
+
// Anchor to the repo root so running from a package subdirectory does not
|
|
29
|
+
// create a shadow .mugiwara/ there. (B4)
|
|
30
|
+
function resolveProjectDir(explicit?: string): string {
|
|
31
|
+
if (explicit) return resolve(explicit);
|
|
32
|
+
try {
|
|
33
|
+
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim();
|
|
34
|
+
if (root) return root;
|
|
35
|
+
} catch { /* not a git repo — fall through */ }
|
|
36
|
+
return process.cwd();
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
export async function run(argv: string[]): Promise<void> {
|
|
29
40
|
const { command, flags, _ } = parseArgs(argv);
|
|
30
41
|
if (flag(flags.help) || command === 'help') return help();
|
|
@@ -35,7 +46,7 @@ export async function run(argv: string[]): Promise<void> {
|
|
|
35
46
|
{
|
|
36
47
|
const bypass = new Set(['install', 'update', 'uninstall', 'list']);
|
|
37
48
|
if (!bypass.has(command)) {
|
|
38
|
-
const projectDirForHarness =
|
|
49
|
+
const projectDirForHarness = resolveProjectDir(str(flags.project));
|
|
39
50
|
enforceHarnessPolicy(projectDirForHarness);
|
|
40
51
|
}
|
|
41
52
|
}
|
|
@@ -45,7 +56,7 @@ export async function run(argv: string[]): Promise<void> {
|
|
|
45
56
|
// Skipped for install/update --dry-run: a dry run must not mutate the project.
|
|
46
57
|
const isDryRunInstall = (command === 'install' || command === 'update') && flag(flags.dryRun);
|
|
47
58
|
if (!isDryRunInstall) {
|
|
48
|
-
const projectDir =
|
|
59
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
49
60
|
if (ensureConfig(projectDir)) {
|
|
50
61
|
console.log(`default .mugiwara/config written at ${join(projectDir, '.mugiwara', 'config')} (edit it to customise)`);
|
|
51
62
|
}
|
|
@@ -75,7 +86,7 @@ export async function run(argv: string[]): Promise<void> {
|
|
|
75
86
|
}
|
|
76
87
|
|
|
77
88
|
function resetCmd(flags: Args['flags']): void {
|
|
78
|
-
const projectDir =
|
|
89
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
79
90
|
const force = flag(flags.force);
|
|
80
91
|
const result = resetMission(projectDir, flag(flags.keepLogs), force);
|
|
81
92
|
if (result.blocked) {
|
|
@@ -88,7 +99,7 @@ function resetCmd(flags: Args['flags']): void {
|
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
function archive(flags: Args['flags'], positionals: string[]): void {
|
|
91
|
-
const projectDir =
|
|
102
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
92
103
|
const mission = positionals[1];
|
|
93
104
|
if (!mission) { console.error('usage: mugiwara archive <mission> [--project <dir>] [--dry-run]'); process.exit(1); }
|
|
94
105
|
const result = archiveMission(projectDir, mission, { dryRun: flag(flags.dryRun) });
|
|
@@ -107,7 +118,7 @@ function archive(flags: Args['flags'], positionals: string[]): void {
|
|
|
107
118
|
* touched before that date.
|
|
108
119
|
*/
|
|
109
120
|
function cleanCmd(flags: Args['flags']): void {
|
|
110
|
-
const projectDir =
|
|
121
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
111
122
|
const dryRun = flag(flags.dryRun);
|
|
112
123
|
const root = join(projectDir, '.mugiwara', 'missions');
|
|
113
124
|
if (!existsSync(root)) { console.log('nothing to clean (.mugiwara/missions/ does not exist).'); return; }
|
|
@@ -173,7 +184,7 @@ async function resolveOptions(flags: Args['flags']): Promise<{ scope: Scope; pro
|
|
|
173
184
|
if (!interactive) { scope = 'project'; }
|
|
174
185
|
else scope = (await choose(rl!, 'Install scope?', ['global (user-wide)', 'project (this repo)'])) === 0 ? 'global' : 'project';
|
|
175
186
|
}
|
|
176
|
-
const projectDir =
|
|
187
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
177
188
|
if (scope === 'project' && !existsSync(projectDir)) throw new Error(`Project dir not found: ${projectDir}`);
|
|
178
189
|
|
|
179
190
|
let targetIds = str(flags.target)?.split(',').map(s => s.trim()) ?? null;
|
|
@@ -231,13 +242,15 @@ async function install(flags: Args['flags']): Promise<void> {
|
|
|
231
242
|
});
|
|
232
243
|
console.log(`\nOK mugiwara ${VERSION} installed (manifest: ${file})`);
|
|
233
244
|
if (allNotes.length) console.log(`${allNotes.length} note(s) above may need attention.`);
|
|
245
|
+
console.log('CLI: run `npm i -g @ionivetech/mugiwara` so the crew can call `mugiwara savepoint/archive/continue`.');
|
|
246
|
+
console.log(' Without it the crew degrades to inline-only — no state, no resume, no closure gate.');
|
|
234
247
|
// A fresh install writes a default .mugiwara/config — point at it directly.
|
|
235
248
|
console.log('\nNext: edit .mugiwara/config to customise (mode, branch, coverage, depths).');
|
|
236
249
|
}
|
|
237
250
|
|
|
238
251
|
async function uninstall(flags: Args['flags']): Promise<void> {
|
|
239
252
|
const scope: Scope = flag(flags.global) ? 'global' : 'project';
|
|
240
|
-
const projectDir =
|
|
253
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
241
254
|
const home = homedir();
|
|
242
255
|
const file = manifestPath({ scope, projectDir, home });
|
|
243
256
|
const manifest = readManifest(file);
|
|
@@ -310,7 +323,7 @@ function schemaWarnings(projectDir: string): void {
|
|
|
310
323
|
|
|
311
324
|
function list(flags: Args['flags']): void {
|
|
312
325
|
const home = homedir();
|
|
313
|
-
const projectDir =
|
|
326
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
314
327
|
legacyWarning(projectDir);
|
|
315
328
|
let found = false;
|
|
316
329
|
for (const [label, file] of [
|
|
@@ -342,12 +355,28 @@ function list(flags: Args['flags']): void {
|
|
|
342
355
|
* the caller must stop and let the user pick.
|
|
343
356
|
*/
|
|
344
357
|
function continueCmd(flags: Args['flags'], positionals: string[]): void {
|
|
345
|
-
const projectDir =
|
|
358
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
346
359
|
legacyWarning(projectDir);
|
|
347
360
|
schemaWarnings(projectDir);
|
|
348
361
|
const [mission, member] = positionals.slice(1);
|
|
349
362
|
let entries = readContinue(projectDir);
|
|
350
363
|
|
|
364
|
+
// If the requested member's state file is unreadable, refuse rather than
|
|
365
|
+
// resuming from continue-<member>.json alone. A resume point without its
|
|
366
|
+
// state is a guess. (B6)
|
|
367
|
+
if (mission) {
|
|
368
|
+
// readState populates unreadableStateFiles for state files; entries already captured
|
|
369
|
+
readState(projectDir);
|
|
370
|
+
const badState = unreadableStateFiles();
|
|
371
|
+
const target = member ? `${mission}/${member}.json` : `${mission}/state.json`;
|
|
372
|
+
if (badState.includes(target)) {
|
|
373
|
+
console.error(`✗ mission "${mission}"${member ? ` member "${member}"` : ''} has unreadable state: ${target}`);
|
|
374
|
+
process.exit(1);
|
|
375
|
+
}
|
|
376
|
+
// re-read continue entries after the state scan cleared unreadable (preserve original entries)
|
|
377
|
+
// entries already holds the correct continue data, no need to re-read
|
|
378
|
+
}
|
|
379
|
+
|
|
351
380
|
// default to this actor's work; --all crosses actors on a shared checkout
|
|
352
381
|
if (!flag(flags.all)) {
|
|
353
382
|
const actor = gitActor(projectDir);
|
|
@@ -386,11 +415,19 @@ function continueCmd(flags: Args['flags'], positionals: string[]): void {
|
|
|
386
415
|
|
|
387
416
|
/** `mugiwara status` — one screen of computed mission state, no model needed. */
|
|
388
417
|
function statusCmd(flags: Args['flags']): void {
|
|
389
|
-
const projectDir =
|
|
418
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
390
419
|
legacyWarning(projectDir);
|
|
391
420
|
schemaWarnings(projectDir);
|
|
392
421
|
const states = readState(projectDir);
|
|
393
|
-
|
|
422
|
+
const bad = unreadableStateFiles();
|
|
423
|
+
if (bad.length) {
|
|
424
|
+
console.error(`⚠ ${bad.length} unreadable state file(s): ${bad.join(', ')}`);
|
|
425
|
+
console.error(' These are not "no mission" — they are corrupt. Inspect or delete them.');
|
|
426
|
+
}
|
|
427
|
+
if (!states.length) {
|
|
428
|
+
console.log(bad.length ? 'No readable mission state on disk.' : 'No mission state on disk.');
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
394
431
|
const actor = flag(flags.all) ? null : gitActor(projectDir);
|
|
395
432
|
const rows = actor ? (states.filter((s) => s.actor === actor).length ? states.filter((s) => s.actor === actor) : states) : states;
|
|
396
433
|
for (const s of rows) {
|
|
@@ -406,7 +443,7 @@ function statusCmd(flags: Args['flags']): void {
|
|
|
406
443
|
|
|
407
444
|
/** `mugiwara cost [--mission <id>] [--json] [--ledger]` — show cost ledger, avoided work, efficiency, trail. */
|
|
408
445
|
function costCmd(flags: Args['flags'], positionals: string[]): void {
|
|
409
|
-
const projectDir =
|
|
446
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
410
447
|
const mission = str(flags.mission) ?? positionals[1] ?? (() => {
|
|
411
448
|
const states = readState(projectDir);
|
|
412
449
|
if (states.length === 1) return states[0].mission;
|
|
@@ -461,7 +498,7 @@ function costCmd(flags: Args['flags'], positionals: string[]): void {
|
|
|
461
498
|
|
|
462
499
|
/** `mugiwara run <script.sh> [args]` — run a bundled harness script here. */
|
|
463
500
|
function runCmd(flags: Args['flags'], positionals: string[]): void {
|
|
464
|
-
const projectDir =
|
|
501
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
465
502
|
const name = positionals[1];
|
|
466
503
|
if (!name) {
|
|
467
504
|
console.error(`usage: mugiwara run <script> [args...]\n scripts: ${RUNNABLE.join(', ')}`);
|
|
@@ -473,7 +510,7 @@ function runCmd(flags: Args['flags'], positionals: string[]): void {
|
|
|
473
510
|
|
|
474
511
|
/** `mugiwara blame <path>` — provenance note on the last commit touching path. */
|
|
475
512
|
function blameCmd(flags: Args['flags'], positionals: string[]): void {
|
|
476
|
-
const projectDir =
|
|
513
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
477
514
|
const path = positionals[1];
|
|
478
515
|
if (!path) { console.error('usage: mugiwara blame <file-path>'); process.exit(1); }
|
|
479
516
|
console.log(blamePath(projectDir, path));
|
|
@@ -506,10 +543,15 @@ export function stalenessLine(projectDir: string, baseSha: string): string | nul
|
|
|
506
543
|
|
|
507
544
|
/** `mugiwara handoff <mission>` — a report the next engineer can act on. */
|
|
508
545
|
function handoffCmd(flags: Args['flags'], positionals: string[]): void {
|
|
509
|
-
const projectDir =
|
|
546
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
510
547
|
const mission = positionals[1];
|
|
511
548
|
if (!mission) { console.error('usage: mugiwara handoff <mission> [--project <dir>]'); process.exit(1); }
|
|
512
549
|
const states = readState(projectDir).filter((s) => s.mission === mission);
|
|
550
|
+
const bad = unreadableStateFiles().filter((p) => p.startsWith(`${mission}/`));
|
|
551
|
+
if (bad.length) {
|
|
552
|
+
console.error(`✗ mission "${mission}" has unreadable state: ${bad.join(', ')}`);
|
|
553
|
+
process.exit(1);
|
|
554
|
+
}
|
|
513
555
|
if (!states.length) { console.error(`no in-flight mission "${mission}"`); process.exit(1); }
|
|
514
556
|
const lines = [
|
|
515
557
|
`# Handoff: ${mission}`,
|
|
@@ -540,7 +582,7 @@ function handoffCmd(flags: Args['flags'], positionals: string[]): void {
|
|
|
540
582
|
}
|
|
541
583
|
|
|
542
584
|
export function migrateCmd(flags: Args['flags']): void {
|
|
543
|
-
const projectDir =
|
|
585
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
544
586
|
const dryRun = flag(flags.dryRun);
|
|
545
587
|
const legacyState = join(projectDir, '.mugiwara', 'state');
|
|
546
588
|
const legacyContinue = join(projectDir, '.mugiwara', 'continue');
|
|
@@ -623,7 +665,7 @@ export function migrateCmd(flags: Args['flags']): void {
|
|
|
623
665
|
|
|
624
666
|
/** `mugiwara sign <mission>` / `--verify` / `--gen-key` — optional attestation. */
|
|
625
667
|
function signCmd(flags: Args['flags'], _: string[]): void {
|
|
626
|
-
const projectDir =
|
|
668
|
+
const projectDir = resolveProjectDir(str(flags.project));
|
|
627
669
|
if (flag(flags.genKey)) {
|
|
628
670
|
const backend = str(flags.backend) ?? 'auto';
|
|
629
671
|
const home = homedir();
|
package/src/continue.ts
CHANGED
|
@@ -111,6 +111,11 @@ export function gitActor(cwd: string): string {
|
|
|
111
111
|
return name || process.env.USER || process.env.USERNAME || '';
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
const unreadable: string[] = [];
|
|
115
|
+
|
|
116
|
+
/** State files that exist but could not be parsed. Cleared by each scan. (B6) */
|
|
117
|
+
export function unreadableStateFiles(): string[] { return [...unreadable]; }
|
|
118
|
+
|
|
114
119
|
/**
|
|
115
120
|
* Read every mission dir under `.mugiwara/missions/<mission>/`, picking the
|
|
116
121
|
* files this reader owns: state readers take `state.json` / `<member>.json`,
|
|
@@ -118,6 +123,7 @@ export function gitActor(cwd: string): string {
|
|
|
118
123
|
* files are skipped.
|
|
119
124
|
*/
|
|
120
125
|
function scan<T>(projectDir: string, kind: 'state' | 'continue', map: (raw: Record<string, unknown>, member: string | null) => T): T[] {
|
|
126
|
+
unreadable.length = 0;
|
|
121
127
|
const base = join(projectDir, '.mugiwara', 'missions');
|
|
122
128
|
if (!existsSync(base)) return [];
|
|
123
129
|
const out: T[] = [];
|
|
@@ -148,7 +154,7 @@ function scan<T>(projectDir: string, kind: 'state' | 'continue', map: (raw: Reco
|
|
|
148
154
|
if (text(raw.mission) !== mission) continue;
|
|
149
155
|
out.push(map(raw, member));
|
|
150
156
|
} catch {
|
|
151
|
-
|
|
157
|
+
unreadable.push(join(mission, f));
|
|
152
158
|
}
|
|
153
159
|
}
|
|
154
160
|
}
|
package/src/cost.ts
CHANGED
package/src/installer.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/installer.ts
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync, rmSync, lstatSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync, rmSync, lstatSync, chmodSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -57,6 +57,7 @@ export interface Target {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export const CONTENT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'content');
|
|
60
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
60
61
|
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')) as { version: string };
|
|
61
62
|
export const VERSION = pkg.version;
|
|
62
63
|
|
|
@@ -99,9 +100,13 @@ export function installTo(target: Target, opts: InstallOptions): InstallResult {
|
|
|
99
100
|
const backupRoot = join(scope === 'global' ? home : projectDir, '.mugiwara');
|
|
100
101
|
const result: InstallResult = { written: [], skipped: [], backedUp: [], notes: [] };
|
|
101
102
|
|
|
102
|
-
const writeOne = (absPath: string, text: string) => {
|
|
103
|
+
const writeOne = (absPath: string, text: string, mode?: number) => {
|
|
103
104
|
if (existsSync(absPath)) {
|
|
104
|
-
if (readFileSync(absPath, 'utf8') === text) {
|
|
105
|
+
if (readFileSync(absPath, 'utf8') === text) {
|
|
106
|
+
// ensure mode even when content unchanged
|
|
107
|
+
if (!dryRun && mode !== undefined) { try { chmodSync(absPath, mode); } catch { /* ignore */ } }
|
|
108
|
+
result.skipped.push(absPath); return;
|
|
109
|
+
}
|
|
105
110
|
if (!force) {
|
|
106
111
|
result.skipped.push(absPath);
|
|
107
112
|
result.notes.push(`conflict (not overwritten; run update to replace with backup): ${absPath}`);
|
|
@@ -114,7 +119,11 @@ export function installTo(target: Target, opts: InstallOptions): InstallResult {
|
|
|
114
119
|
if (!dryRun) { mkdirSync(backupDir, { recursive: true }); copyFileSync(absPath, backupFile); }
|
|
115
120
|
result.backedUp.push(absPath);
|
|
116
121
|
}
|
|
117
|
-
if (!dryRun) {
|
|
122
|
+
if (!dryRun) {
|
|
123
|
+
mkdirSync(dirname(absPath), { recursive: true });
|
|
124
|
+
writeFileSync(absPath, text);
|
|
125
|
+
if (mode !== undefined) { try { chmodSync(absPath, mode); } catch { /* ignore */ } }
|
|
126
|
+
}
|
|
118
127
|
result.written.push(absPath);
|
|
119
128
|
};
|
|
120
129
|
|
|
@@ -156,6 +165,20 @@ export function installTo(target: Target, opts: InstallOptions): InstallResult {
|
|
|
156
165
|
for (const r of sharedRefs) writeOne(join(sharedRoot, r.relPath), r.text);
|
|
157
166
|
}
|
|
158
167
|
|
|
168
|
+
// Shell fallbacks: pure sh, no Node. The critical path (lane sizing + state)
|
|
169
|
+
// must survive on a harness where the CLI cannot run. See plan.md B1.
|
|
170
|
+
{
|
|
171
|
+
const SHELL_FALLBACKS = ['lane.sh', 'savepoint.sh', 'lib/patterns.sh', 'lib/lane-base.sh'];
|
|
172
|
+
const mugiwaraDir = join(scope === 'global' ? home : projectDir, '.mugiwara');
|
|
173
|
+
for (const rel of SHELL_FALLBACKS) {
|
|
174
|
+
const src = join(REPO_ROOT, 'scripts', rel);
|
|
175
|
+
if (!existsSync(src)) continue;
|
|
176
|
+
const text = readFileSync(src, 'utf8');
|
|
177
|
+
const dest = join(mugiwaraDir, 'bin', rel);
|
|
178
|
+
writeOne(dest, text, 0o755);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
159
182
|
if (target.postInstall) {
|
|
160
183
|
const post = target.postInstall({ scope, projectDir, home, dryRun, files: result.written });
|
|
161
184
|
result.written.push(...post.written);
|