@mstar-harness/opencode 1.8.8 → 2.0.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/CHANGELOG.md +30 -2
- package/README.md +10 -0
- package/dist/mstar.js +561 -0
- package/harness-commands/iteration-drive.md +18 -2
- package/harness-commands/iteration-loop.md +16 -0
- package/harness-commands/iteration-start.md +16 -0
- package/harness-skills/mstar-audit/SKILL.md +2 -0
- package/harness-skills/mstar-branch-worktree/SKILL.md +4 -0
- package/harness-skills/mstar-coding-behavior/SKILL.md +5 -0
- package/harness-skills/mstar-compound/SKILL.md +2 -0
- package/harness-skills/mstar-compound-refresh/SKILL.md +2 -0
- package/harness-skills/mstar-design-md/SKILL.md +2 -0
- package/harness-skills/mstar-design-md/templates/DESIGN.dark.md.template +5 -0
- package/harness-skills/mstar-design-md/templates/DESIGN.md.template +5 -0
- package/harness-skills/mstar-dispatch-gates/SKILL.md +6 -0
- package/harness-skills/mstar-host/SKILL.md +4 -0
- package/harness-skills/mstar-iteration/SKILL.md +14 -5
- package/harness-skills/mstar-iteration/references/phase-2-worktree-lease.md +2 -0
- package/harness-skills/mstar-iteration/references/phase-4-5-pr-delivery.md +5 -2
- package/harness-skills/mstar-phase-gates/SKILL.md +2 -0
- package/harness-skills/mstar-plan-artifacts/SKILL.md +13 -2
- package/harness-skills/mstar-plan-artifacts/references/plan-quality-bar.md +1 -1
- package/harness-skills/mstar-plan-artifacts/references/status-and-residuals.md +12 -13
- package/harness-skills/mstar-plan-artifacts/templates/README.md +1 -1
- package/harness-skills/mstar-plan-conventions/SKILL.md +9 -1
- package/harness-skills/mstar-plan-conventions/references/harness-bootstrap-and-agents-layering.md +1 -1
- package/harness-skills/mstar-review-qc/SKILL.md +2 -0
- package/harness-skills/mstar-roles/SKILL.md +2 -0
- package/harness-skills/mstar-roles/references/project-manager/dispatch-and-assignment.md +1 -1
- package/harness-skills/mstar-sdd/SKILL.md +18 -14
- package/harness-skills/mstar-sdd/references/file-handoffs.md +8 -8
- package/harness-skills/mstar-sdd/references/sticky-implementer-session.md +2 -2
- package/harness-skills/mstar-skill-authoring/SKILL.md +4 -0
- package/harness-skills/mstar-strategy/SKILL.md +2 -0
- package/package.json +6 -2
- package/harness-skills/mstar-plan-artifacts/scripts/tech-debt-rollup.sh +0 -115
- package/harness-skills/mstar-sdd/scripts/review-package +0 -43
- package/harness-skills/mstar-sdd/scripts/sdd-workspace +0 -90
- package/harness-skills/mstar-sdd/scripts/task-brief +0 -40
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,34 @@ The monorepo root [CHANGELOG.md](../../CHANGELOG.md) summarizes cross-surface re
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.0.0] - 2026-08-08
|
|
10
|
+
|
|
11
|
+
### Bundled harness skills (`harness-skills/` at publish)
|
|
12
|
+
|
|
13
|
+
- **Bash SDD/rollup scripts removed (engine CLI is the documented path)**: `skills/mstar-sdd/scripts/{sdd-workspace,task-brief,review-package}` and `skills/mstar-plan-artifacts/scripts/tech-debt-rollup.sh` are deleted. Skill text now documents `mstar sdd workspace|task-brief|review-package` and the engine `techDebtRollup` import (`mstar status validate` remains the schema gate); parity tests compare engine output against stored golden fixtures captured from the byte-proven ports (slice 2).
|
|
14
|
+
- Added the **`@mstar-harness/engine`** package scaffold: a version-aligned workspace library (`zod` + `ajv`, `node:*` only, no `bin`) with a typed `ValidationResult` + `readHarnessVersion()` placeholder core, wired into the release surface list (10 → 11), changelog assembly, and root workspaces.
|
|
15
|
+
- **Engine hardening (QC fix wave, slice 1)**: lease location/orphan/dual-write verify (`lease.verify.*`) moved into `@mstar-harness/engine` (CLI `mstar lease verify` is now a thin wrapper); `archiveResiduals` gained a plan-id path-traversal guard, the status write lock, and append dedup; `withStatusWriteLock` gained an ownership guard (never removes another writer's lockdir), a `holder.pid` crash-diagnosis file, and fast-fail reentrancy detection; `readHarnessVersion` reads the module's own manifest first (published installs no longer regress to `0.0.0`); `tech-debt-rollup` parity now mirrors jq `//` exactly (`false`/`0` edges tested against the bash oracle); residual closed-lifecycle completeness (`closed_at` + `closure_note`) and plan-row `Done` ⇒ no-lease invariants added. Release prep now ensures the `@mstar-harness/engine` registry row + package-history link in root changelog heads.
|
|
16
|
+
- **Harness Workflow Engine positioning (iteration v2.0.0)**: unified engine-first descriptions across the 7 plugin manifests and the 4 package manifests; added `workflow-engine` / `workflow-enforcement` / `deterministic-workflow` / `harness-workflow` keywords to the root plugin manifest; re-framed README.md / README_CN.md around deterministic workflow gates enforced by a TS engine (not prompts alone) with judgment staying in `mstar-*` skills, plus a What-ships table (Harness Workflow Engine / mstar CLI / `mstar-*` skills / host adapters).
|
|
17
|
+
|
|
18
|
+
- Version alignment with harness **2.0.0** (no OpenCode package API change).
|
|
19
|
+
|
|
20
|
+
See root [CHANGELOG.md](../../CHANGELOG.md) **2.0.0**.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- `beforeDispatch` Assignment lint is engine-only: local `validateAssignmentPresence` parser removed (qc1 F-002); branch-form parsing via engine `parseAssignmentBranchForms` / `parseBranchPolicyDirectOnBranch` (qc1 F-001); read-only roles (scout/explore) skip the branch-form + default-branch gates (qc3 F-1 / qc2 S-5).
|
|
25
|
+
- `antiRecursionPrecheck` wired into the hook: task dispatch whose role binding (`args.subagent` / `args.subagent_type`) equals the Assignment's `Execute as` warns at critical severity, warn-only (qc1 F-004 / qc2 S-2).
|
|
26
|
+
|
|
27
|
+
## [1.8.9] - 2026-08-07
|
|
28
|
+
|
|
29
|
+
### Harness
|
|
30
|
+
|
|
31
|
+
- **Phase 5 checkout**: merge-ready product fixes edit **directly** on the control / `spec_integration_branch` checkout; **forbid** opening a separate Phase 5 feature/fix worktree or applying Phase 2's "no product edits on control" rule. SSOT stays in `mstar-iteration` (`phase-4-5-pr-delivery` §5.0); **not** in the general `mstar-branch-worktree` skill.
|
|
32
|
+
|
|
33
|
+
- Version alignment with harness **1.8.9** (no OpenCode package API change).
|
|
34
|
+
|
|
35
|
+
See root [CHANGELOG.md](../../CHANGELOG.md) **1.8.9**.
|
|
36
|
+
|
|
9
37
|
## [1.8.8] - 2026-08-06
|
|
10
38
|
|
|
11
39
|
### Bundled harness skills (`harness-skills/` at publish)
|
|
@@ -114,7 +142,7 @@ See root [CHANGELOG.md](../../CHANGELOG.md) **1.5.6**.
|
|
|
114
142
|
|
|
115
143
|
## 1.5.5
|
|
116
144
|
|
|
117
|
-
- Version alignment with harness **1.5.5** (bundled skills: control-path harness under default gitignore +
|
|
145
|
+
- Version alignment with harness **1.5.5** (bundled skills: control-path harness under default gitignore + SDD workspace `MSTAR_CONTROL_ROOT` resolution).
|
|
118
146
|
|
|
119
147
|
See root [CHANGELOG.md](../../CHANGELOG.md) **1.5.5**.
|
|
120
148
|
|
|
@@ -397,7 +425,7 @@ See root [CHANGELOG.md](../../CHANGELOG.md) **0.6.2**.
|
|
|
397
425
|
|
|
398
426
|
## 0.6.1
|
|
399
427
|
|
|
400
|
-
- Bundled skills:
|
|
428
|
+
- Bundled skills: tech-debt rollup script, PM dual fullstack spread defaults, routing-eval v6.
|
|
401
429
|
|
|
402
430
|
See root [CHANGELOG.md](../../CHANGELOG.md) **0.6.1**.
|
|
403
431
|
|
package/README.md
CHANGED
|
@@ -32,6 +32,16 @@ npx @mstar-harness/cli init --target opencode
|
|
|
32
32
|
|
|
33
33
|
The plugin resolves **only paths inside this package** — not `process.cwd()/skills`, so your app repo root does not affect harness loading.
|
|
34
34
|
|
|
35
|
+
## Status write lint (hook coverage)
|
|
36
|
+
|
|
37
|
+
The plugin registers a non-blocking `tool.execute.before` lint for `write`/`edit` tools that target `{HARNESS_DIR}/status.json`: the about-to-be-written (or current on-disk) document is validated against the engine `status.validateStatus` schema and violations are logged as warnings. The hook never blocks and never modifies the write.
|
|
38
|
+
|
|
39
|
+
Hook coverage follows the engine `resolveHarnessDir` probe order (`.mstar/` → `.agents/` → `.plans/`/`plans/` walking up from the target file). Repos whose harness root is not one of those names — e.g. the `.harness/` layout used by this monorepo itself — are **not** auto-discovered: set **`MSTAR_HARNESS_DIR`** in the OpenCode server environment (absolute path to the harness root) to enable the lint for such repos.
|
|
40
|
+
|
|
41
|
+
## Dispatch presence lint (hook coverage)
|
|
42
|
+
|
|
43
|
+
The plugin registers a non-blocking `tool.execute.before` lint for the `task` tool (subagent dispatch): the prompt is parsed for the three core Assignment header fields — `Execute as: <role-id>`, `Delegation: allowed|forbidden`, `Task category: <category>` — and each missing field is logged as a warning (`assignment.presence.missing-*`, `high` for `Execute as`, `medium` otherwise). The hook never blocks and never modifies the prompt; prompts that are not Assignment-shaped (no `## Assignment` heading and none of the three fields) stay silent. Field **presence** only: value-level Assignment validation (Working-branch forms, N→seat mapping, tri identity) is a later-slice extension via `dispatch.validateAssignmentFields`.
|
|
44
|
+
|
|
35
45
|
## Quick start
|
|
36
46
|
|
|
37
47
|
1. Install the plugin (above).
|
package/dist/mstar.js
CHANGED
|
@@ -1,3 +1,446 @@
|
|
|
1
|
+
// ../engine/dist/engine.js
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, statSync } from "node:fs";
|
|
4
|
+
import { basename as basename2, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "node:path";
|
|
5
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "node:fs";
|
|
6
|
+
import { join as join4, resolve as resolve4 } from "node:path";
|
|
7
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
|
+
var SEVERITY_ORDER = ["critical", "high", "medium", "low", "nit"];
|
|
9
|
+
function applyEnforcement(gate, opts) {
|
|
10
|
+
return { ...gate, hardBlocked: opts.hard && gate.violations.length > 0 };
|
|
11
|
+
}
|
|
12
|
+
function readJson(filePath) {
|
|
13
|
+
if (!existsSync(filePath))
|
|
14
|
+
return {};
|
|
15
|
+
const content = readFileSync(filePath, "utf8").trim();
|
|
16
|
+
if (!content)
|
|
17
|
+
return {};
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(content);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
throw new Error(`Invalid JSON in ${filePath}: ${error.message}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function resolveHarnessDir(startDir = process.cwd(), opts = {}) {
|
|
25
|
+
const start = resolve2(startDir);
|
|
26
|
+
const explicit = opts.harnessDir ?? process.env.MSTAR_HARNESS_DIR;
|
|
27
|
+
if (explicit)
|
|
28
|
+
return resolve2(start, explicit);
|
|
29
|
+
let dir = start;
|
|
30
|
+
for (;; ) {
|
|
31
|
+
for (const candidate of [join2(dir, ".mstar"), join2(dir, ".agents"), join2(dir, ".plans"), join2(dir, "plans")]) {
|
|
32
|
+
if (isDirectory(candidate))
|
|
33
|
+
return candidate;
|
|
34
|
+
}
|
|
35
|
+
const parent = dirname2(dir);
|
|
36
|
+
if (parent === dir)
|
|
37
|
+
return null;
|
|
38
|
+
dir = parent;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function resolveIterationDir(harnessDir) {
|
|
42
|
+
return join2(resolve2(harnessDir), "iterations");
|
|
43
|
+
}
|
|
44
|
+
var GITIGNORE_SNIPPET = `# Morning Star harness (.mstar/)
|
|
45
|
+
# Principle: process stays local; results are shared with the team.
|
|
46
|
+
# Ignored (process / coordination):
|
|
47
|
+
.mstar/archived/
|
|
48
|
+
.mstar/iterations/
|
|
49
|
+
.mstar/plans/
|
|
50
|
+
.mstar/sdd/
|
|
51
|
+
.mstar/notes.json
|
|
52
|
+
.mstar/status.json
|
|
53
|
+
# Tracked (results): .mstar/AGENTS.md, .mstar/knowledge/, .mstar/specs/
|
|
54
|
+
`;
|
|
55
|
+
var GITIGNORE_SNIPPET_AGENTS = `# Morning Star harness (.agents/) — legacy
|
|
56
|
+
.agents/archived/
|
|
57
|
+
.agents/iterations/
|
|
58
|
+
.agents/plans/
|
|
59
|
+
.agents/sdd/
|
|
60
|
+
.agents/notes.json
|
|
61
|
+
.agents/status.json
|
|
62
|
+
# Tracked (results): .agents/AGENTS.md, .agents/knowledge/, .agents/specs/
|
|
63
|
+
`;
|
|
64
|
+
var GITIGNORE_PROCESS_ENTRIES = GITIGNORE_SNIPPET.split(`
|
|
65
|
+
`).filter((line) => line.startsWith(".mstar/")).map((line) => line.trim());
|
|
66
|
+
var GITIGNORE_PROCESS_ENTRIES_AGENTS = GITIGNORE_SNIPPET_AGENTS.split(`
|
|
67
|
+
`).filter((line) => line.startsWith(".agents/")).map((line) => line.trim());
|
|
68
|
+
function isDirectory(dir) {
|
|
69
|
+
try {
|
|
70
|
+
return statSync(dir).isDirectory();
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
var DATE_PART = String.raw`\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])`;
|
|
76
|
+
var RFC3339_Z_RE = new RegExp(String.raw`^${DATE_PART}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$`);
|
|
77
|
+
var DATE_ONLY_RE = new RegExp(String.raw`^${DATE_PART}$`);
|
|
78
|
+
var heldLockDirs = new AsyncLocalStorage;
|
|
79
|
+
var BRANCH_FORMS_HINT = '"Working branch: <existing>" | "Working branch: create <new> from <base>" | "Branch policy: direct on <branch> — <reason>"';
|
|
80
|
+
var REQUIRED_FIELDS = [
|
|
81
|
+
{ key: "executeAs", label: "Execute as", code: "execute-as" },
|
|
82
|
+
{ key: "delegation", label: "Delegation", code: "delegation" },
|
|
83
|
+
{ key: "taskCategory", label: "Task category", code: "task-category" }
|
|
84
|
+
];
|
|
85
|
+
function violation2(severity, code, message, fix) {
|
|
86
|
+
return { ok: false, severity, code, message, fix };
|
|
87
|
+
}
|
|
88
|
+
function parseAssignmentFields(assignmentText) {
|
|
89
|
+
const fields = {};
|
|
90
|
+
for (const line of assignmentText.split(/\r?\n/)) {
|
|
91
|
+
const match = line.match(/^[ \t]*(?:[-*][ \t]+)?\*\*\s*([^*:]+?)\s*\*\*\s*:\s*(.*)$/) ?? line.match(/^[ \t]*(?:[-*][ \t]+)?([A-Za-z][A-Za-z -]*?)\s*:\s*(.*)$/);
|
|
92
|
+
if (!match)
|
|
93
|
+
continue;
|
|
94
|
+
const label = match[1].trim();
|
|
95
|
+
const value = match[2].trim();
|
|
96
|
+
const known = REQUIRED_FIELDS.find((f) => f.label === label);
|
|
97
|
+
if (known) {
|
|
98
|
+
fields[known.key] = value;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (label === "Working branch")
|
|
102
|
+
fields.workingBranch = value;
|
|
103
|
+
else if (label === "Branch policy")
|
|
104
|
+
fields.branchPolicy = value;
|
|
105
|
+
}
|
|
106
|
+
return fields;
|
|
107
|
+
}
|
|
108
|
+
var ASSIGNMENT_ENFORCEMENT_BOLD_RE = /^[ \t]*(?:[-*][ \t]+)?\*\*\s*Enforcement\s*\*\*\s*:\s*(.*)$/m;
|
|
109
|
+
var ASSIGNMENT_ENFORCEMENT_PLAIN_RE = /^[ \t]*(?:[-*][ \t]+)?Enforcement\s*:\s*(.*)$/m;
|
|
110
|
+
var COMPASS_ENFORCEMENT_RE = /^enforcement\s*:\s*(.*)$/m;
|
|
111
|
+
function enforcementValue(raw) {
|
|
112
|
+
const value = raw.trim();
|
|
113
|
+
const unquoted = value.replace(/^(['"])(.*)\1$/, "$2");
|
|
114
|
+
return unquoted.trim().toLowerCase();
|
|
115
|
+
}
|
|
116
|
+
var ASSIGNMENT_BODY_START_RE = /^(?:#{1,6}[ \t]+Task\b|-{3,}[ \t]*$|#[ \t])/m;
|
|
117
|
+
function assignmentHeaderRegion(assignmentText) {
|
|
118
|
+
const marker = assignmentText.match(ASSIGNMENT_BODY_START_RE);
|
|
119
|
+
return marker !== null ? assignmentText.slice(0, marker.index) : assignmentText;
|
|
120
|
+
}
|
|
121
|
+
function parseEnforcementFlag(text) {
|
|
122
|
+
const bold = text.match(ASSIGNMENT_ENFORCEMENT_BOLD_RE);
|
|
123
|
+
if (bold !== null)
|
|
124
|
+
return { hard: enforcementValue(bold[1]) === "hard", source: "assignment" };
|
|
125
|
+
const plain = text.match(ASSIGNMENT_ENFORCEMENT_PLAIN_RE);
|
|
126
|
+
if (plain !== null)
|
|
127
|
+
return { hard: enforcementValue(plain[1]) === "hard", source: "assignment" };
|
|
128
|
+
const compass = text.match(COMPASS_ENFORCEMENT_RE);
|
|
129
|
+
if (compass !== null)
|
|
130
|
+
return { hard: enforcementValue(compass[1]) === "hard", source: "compass" };
|
|
131
|
+
return { hard: false, source: "none" };
|
|
132
|
+
}
|
|
133
|
+
function requireField(violations, value, label, code) {
|
|
134
|
+
if (value === undefined) {
|
|
135
|
+
const v = violation2("high", `assignment.field.missing-${code}`, `missing required Assignment field: ${label}`, `add "**${label}**: <value>" to the Assignment`);
|
|
136
|
+
v.aliases = [`assignment.presence.missing-${code}`];
|
|
137
|
+
violations.push(v);
|
|
138
|
+
} else if (value === "") {
|
|
139
|
+
const v = violation2("high", `assignment.field.invalid-${code}`, `${label} must be non-empty`, `fill in "**${label}**: <value>"`);
|
|
140
|
+
v.aliases = [`assignment.presence.missing-${code}`];
|
|
141
|
+
violations.push(v);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function parseWorkingBranchValue(value) {
|
|
145
|
+
if (value === "")
|
|
146
|
+
return {};
|
|
147
|
+
const create = value.match(/^create\s+(\S+)(?:\s+from\s+(\S+))?$/i);
|
|
148
|
+
if (create)
|
|
149
|
+
return { createForm: { name: create[1], base: create[2] } };
|
|
150
|
+
const danglingFrom = value.match(/^create\s+(\S+)\s+from$/i);
|
|
151
|
+
if (danglingFrom)
|
|
152
|
+
return { createForm: { name: danglingFrom[1], base: "" } };
|
|
153
|
+
const missingName = value.match(/^create\s+from\s+(\S+)$/i);
|
|
154
|
+
if (missingName)
|
|
155
|
+
return { createForm: { name: "", base: missingName[1] } };
|
|
156
|
+
return { workingBranch: value.split(/\s+/)[0] };
|
|
157
|
+
}
|
|
158
|
+
function parseAssignmentBranchForms(assignmentText) {
|
|
159
|
+
const fields = parseAssignmentFields(assignmentText);
|
|
160
|
+
const forms = {};
|
|
161
|
+
if (fields.workingBranch !== undefined && fields.workingBranch !== "") {
|
|
162
|
+
const parsed = parseWorkingBranchValue(fields.workingBranch);
|
|
163
|
+
if (parsed.createForm !== undefined)
|
|
164
|
+
forms.createForm = parsed.createForm;
|
|
165
|
+
else
|
|
166
|
+
forms.workingBranch = parsed.workingBranch;
|
|
167
|
+
}
|
|
168
|
+
if (fields.branchPolicy !== undefined && fields.branchPolicy !== "") {
|
|
169
|
+
const direct = fields.branchPolicy.match(/^direct\s+on\s+(\S+)/i);
|
|
170
|
+
if (direct) {
|
|
171
|
+
const strict = fields.branchPolicy.match(/^direct\s+on\s+(\S+)(?:\s*(?:[—–]|--|-)\s*(.+))?$/);
|
|
172
|
+
forms.directOn = { branch: direct[1].trim(), reason: strict ? (strict[2] ?? "").trim() : "" };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return forms;
|
|
176
|
+
}
|
|
177
|
+
function parseBranchPolicyDirectOnBranch(assignmentText) {
|
|
178
|
+
const directOn = parseAssignmentBranchForms(assignmentText).directOn;
|
|
179
|
+
return directOn !== undefined && directOn.reason !== "" ? directOn.branch : undefined;
|
|
180
|
+
}
|
|
181
|
+
function isReadOnlyAssignmentRole(roleId) {
|
|
182
|
+
const role = roleId.trim().toLowerCase();
|
|
183
|
+
return role === "scout" || role === "explore";
|
|
184
|
+
}
|
|
185
|
+
function validateAssignmentFields(assignmentText, opts = {}) {
|
|
186
|
+
const violations = [];
|
|
187
|
+
const fields = parseAssignmentFields(assignmentText);
|
|
188
|
+
const writable = opts.writable !== false;
|
|
189
|
+
for (const { key, label, code } of REQUIRED_FIELDS) {
|
|
190
|
+
requireField(violations, fields[key], label, code);
|
|
191
|
+
}
|
|
192
|
+
if (writable) {
|
|
193
|
+
const workingPresent = fields.workingBranch !== undefined && fields.workingBranch !== "";
|
|
194
|
+
const policyPresent = fields.branchPolicy !== undefined && fields.branchPolicy !== "";
|
|
195
|
+
const formCount = Number(workingPresent) + Number(policyPresent);
|
|
196
|
+
const forms = parseAssignmentBranchForms(assignmentText);
|
|
197
|
+
if (formCount === 0) {
|
|
198
|
+
violations.push(violation2("high", "assignment.field.branch-missing", "writable assignment must contain exactly one branch form", `add exactly one of: ${BRANCH_FORMS_HINT}`));
|
|
199
|
+
} else if (formCount > 1) {
|
|
200
|
+
violations.push(violation2("high", "assignment.field.branch-multiple", `writable assignment contains ${formCount} branch forms (Working branch + Branch policy) — exactly one required`, `keep exactly one of: ${BRANCH_FORMS_HINT}`));
|
|
201
|
+
} else if (workingPresent) {
|
|
202
|
+
const create = forms.createForm;
|
|
203
|
+
if (create !== undefined && (create.base === undefined || create.base.trim() === "" || create.name.trim() === "")) {
|
|
204
|
+
violations.push(violation2("high", "assignment.field.branch-missing-base", `create-form Working branch is incomplete: "${fields.workingBranch}" (expected "create <new-branch> from <base>")`, "write both the new branch name and the ancestor branch after `from` (main / existing feature branch / remote-tracking branch / `current`)"));
|
|
205
|
+
}
|
|
206
|
+
} else if (policyPresent) {
|
|
207
|
+
const direct = forms.directOn;
|
|
208
|
+
if (direct === undefined) {
|
|
209
|
+
violations.push(violation2("high", "assignment.field.branch-policy-missing-branch", `unparseable Branch policy: "${fields.branchPolicy}" (expected "direct on <branch> — <reason>")`, "start the field with `direct on <branch>`"));
|
|
210
|
+
} else if (direct.reason === "") {
|
|
211
|
+
violations.push(violation2("high", "assignment.field.branch-policy-missing-reason", `Branch policy "direct on ${direct.branch}" is missing the reason`, 'append "— <reason>" after the branch name'));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { ok: violations.length === 0, violations };
|
|
216
|
+
}
|
|
217
|
+
function assertDefaultBranchProtected(branch, opts = {}) {
|
|
218
|
+
const defaultBranches = opts.defaultBranches ?? ["main", "master"];
|
|
219
|
+
const violations = [];
|
|
220
|
+
const normalized = branch.trim();
|
|
221
|
+
if (normalized !== "" && defaultBranches.includes(normalized) && opts.directOnException !== true) {
|
|
222
|
+
violations.push(violation2("high", "dispatch.default-branch.protected", `writable work on default protected branch "${normalized}" requires an explicit direct-on exception`, `add "Branch policy: direct on ${normalized} — <reason>" to the Assignment, or use a feature branch`));
|
|
223
|
+
}
|
|
224
|
+
return { ok: violations.length === 0, violations };
|
|
225
|
+
}
|
|
226
|
+
function antiRecursionPrecheck(subagentType, executeAs) {
|
|
227
|
+
const binding = subagentType.trim().toLowerCase();
|
|
228
|
+
const role = executeAs.trim().toLowerCase();
|
|
229
|
+
if (binding !== "" && binding === role) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
violations: [
|
|
233
|
+
violation2("critical", "dispatch.anti-recursion.self-type", `recursive dispatch refused: role binding "${subagentType}" equals Execute as "${executeAs}" (leaf executors must not re-invoke their own role)`, "complete the work in this session, or return Blocked to project-manager")
|
|
234
|
+
]
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return { ok: true, violations: [] };
|
|
238
|
+
}
|
|
239
|
+
var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
240
|
+
var PLAN_STATUSES = ["Todo", "InProgress", "InReview", "Blocked", "Done"];
|
|
241
|
+
var RESIDUAL_DECISIONS = ["defer", "accept", "risk-accepted"];
|
|
242
|
+
var RESIDUAL_LIFECYCLES = ["open", "resolved", "waived", "superseded", "duplicate"];
|
|
243
|
+
function isPlainObject2(value) {
|
|
244
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
245
|
+
}
|
|
246
|
+
function violation3(severity, code, message, fix) {
|
|
247
|
+
return { ok: false, severity, code, message, fix };
|
|
248
|
+
}
|
|
249
|
+
function validateNonEmptyString2(violations, value, field, missingCode, invalidCode) {
|
|
250
|
+
if (value === undefined) {
|
|
251
|
+
violations.push(violation3("high", missingCode, `missing required field: ${field}`));
|
|
252
|
+
} else if (typeof value !== "string" || value.trim() === "") {
|
|
253
|
+
violations.push(violation3("medium", invalidCode, `${field} must be a non-empty string`));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function validatePlanRow(row) {
|
|
257
|
+
const violations = [];
|
|
258
|
+
if (!isPlainObject2(row)) {
|
|
259
|
+
return { ok: false, violations: [violation3("high", "status.plan-row.invalid", "plan row must be an object")] };
|
|
260
|
+
}
|
|
261
|
+
const { id, plan_id: planId, title, file, status, metadata, execution_lease } = row;
|
|
262
|
+
if (id === undefined && planId === undefined) {
|
|
263
|
+
violations.push(violation3("high", "status.plan-row.missing-id", "missing required field: id (or legacy plan_id)"));
|
|
264
|
+
} else {
|
|
265
|
+
if (id !== undefined) {
|
|
266
|
+
validateNonEmptyString2(violations, id, "id", "status.plan-row.missing-id", "status.plan-row.invalid-id");
|
|
267
|
+
}
|
|
268
|
+
if (planId !== undefined) {
|
|
269
|
+
validateNonEmptyString2(violations, planId, "plan_id", "status.plan-row.missing-plan-id", "status.plan-row.invalid-plan-id");
|
|
270
|
+
}
|
|
271
|
+
if (id !== undefined && planId !== undefined && id !== planId) {
|
|
272
|
+
violations.push(violation3("medium", "status.plan-row.dual-id", "row has both id and plan_id with different values — write one canonical key (prefer id)"));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
validateNonEmptyString2(violations, title, "title", "status.plan-row.missing-title", "status.plan-row.invalid-title");
|
|
276
|
+
validateNonEmptyString2(violations, file, "file", "status.plan-row.missing-file", "status.plan-row.invalid-file");
|
|
277
|
+
if (status === undefined) {
|
|
278
|
+
violations.push(violation3("high", "status.plan-row.missing-status", "missing required field: status"));
|
|
279
|
+
} else if (typeof status !== "string" || !PLAN_STATUSES.includes(status)) {
|
|
280
|
+
violations.push(violation3("medium", "status.plan-row.invalid-status", `status must be one of ${PLAN_STATUSES.join(" | ")} — got ${JSON.stringify(status)}`));
|
|
281
|
+
}
|
|
282
|
+
if (metadata !== undefined && !isPlainObject2(metadata)) {
|
|
283
|
+
violations.push(violation3("medium", "status.plan-row.invalid-metadata", "metadata must be an object"));
|
|
284
|
+
}
|
|
285
|
+
if (execution_lease !== undefined && !isPlainObject2(execution_lease)) {
|
|
286
|
+
violations.push(violation3("medium", "status.plan-row.invalid-execution-lease", "execution_lease must be an object"));
|
|
287
|
+
}
|
|
288
|
+
if (status === "Done" && execution_lease !== undefined) {
|
|
289
|
+
violations.push(violation3("medium", "status.plan-row.done-with-lease", 'plan status Done must not carry an execution_lease — the Done authority deletes the lease in the same complete-file update as status: "Done" (status-and-residuals.md § Hold, release, and override)', 'delete plans[].execution_lease in the same update that sets status: "Done"'));
|
|
290
|
+
}
|
|
291
|
+
return { ok: violations.length === 0, violations };
|
|
292
|
+
}
|
|
293
|
+
function validateResidual(entry) {
|
|
294
|
+
const violations = [];
|
|
295
|
+
if (!isPlainObject2(entry)) {
|
|
296
|
+
return { ok: false, violations: [violation3("high", "status.residual.invalid", "residual entry must be an object")] };
|
|
297
|
+
}
|
|
298
|
+
const { id, title, severity, source, scope, decision, owner, target, tracking, detail_doc, lifecycle, closed_at } = entry;
|
|
299
|
+
validateNonEmptyString2(violations, id, "id", "status.residual.missing-id", "status.residual.invalid-id");
|
|
300
|
+
validateNonEmptyString2(violations, title, "title", "status.residual.missing-title", "status.residual.invalid-title");
|
|
301
|
+
validateNonEmptyString2(violations, source, "source", "status.residual.missing-source", "status.residual.invalid-source");
|
|
302
|
+
validateNonEmptyString2(violations, scope, "scope", "status.residual.missing-scope", "status.residual.invalid-scope");
|
|
303
|
+
validateNonEmptyString2(violations, owner, "owner", "status.residual.missing-owner", "status.residual.invalid-owner");
|
|
304
|
+
if (severity === undefined) {
|
|
305
|
+
violations.push(violation3("high", "status.residual.missing-severity", "missing required field: severity"));
|
|
306
|
+
} else if (typeof severity !== "string" || !SEVERITY_ORDER.includes(severity) && severity !== "warning") {
|
|
307
|
+
violations.push(violation3("medium", "status.residual.invalid-severity", `severity must be one of ${SEVERITY_ORDER.join(" | ")} — got ${JSON.stringify(severity)}`));
|
|
308
|
+
} else if (severity === "warning") {
|
|
309
|
+
violations.push(violation3("low", "status.residual.legacy-warning", `severity "warning" is legacy — forbidden on new entries; read paths normalize it to "low"`, `use "low" (normalizeSeverity maps 'warning' → 'low')`));
|
|
310
|
+
}
|
|
311
|
+
if (decision === undefined) {
|
|
312
|
+
violations.push(violation3("high", "status.residual.missing-decision", "missing required field: decision"));
|
|
313
|
+
} else if (typeof decision !== "string" || !RESIDUAL_DECISIONS.includes(decision)) {
|
|
314
|
+
violations.push(violation3("medium", "status.residual.invalid-decision", `decision must be one of ${RESIDUAL_DECISIONS.join(" | ")} — got ${JSON.stringify(decision)}`));
|
|
315
|
+
}
|
|
316
|
+
if (target === undefined) {
|
|
317
|
+
violations.push(violation3("high", "status.residual.missing-target", "missing required field: target"));
|
|
318
|
+
} else if (typeof target !== "string" && target !== null) {
|
|
319
|
+
violations.push(violation3("medium", "status.residual.invalid-target", "target must be a string or null"));
|
|
320
|
+
}
|
|
321
|
+
if (tracking === undefined) {
|
|
322
|
+
violations.push(violation3("high", "status.residual.missing-tracking", "missing required field: tracking"));
|
|
323
|
+
} else if (typeof tracking !== "string" && tracking !== null) {
|
|
324
|
+
violations.push(violation3("medium", "status.residual.invalid-tracking", "tracking must be a string or null"));
|
|
325
|
+
}
|
|
326
|
+
if (detail_doc !== undefined && typeof detail_doc !== "string" && detail_doc !== null) {
|
|
327
|
+
violations.push(violation3("medium", "status.residual.invalid-detail-doc", "detail_doc must be a string or null"));
|
|
328
|
+
}
|
|
329
|
+
if (closed_at !== undefined && (typeof closed_at !== "string" || !DATE_RE.test(closed_at))) {
|
|
330
|
+
violations.push(violation3("medium", "status.residual.invalid-closed-at", "closed_at must be YYYY-MM-DD"));
|
|
331
|
+
}
|
|
332
|
+
if (lifecycle !== undefined) {
|
|
333
|
+
if (typeof lifecycle !== "string" || !RESIDUAL_LIFECYCLES.includes(lifecycle)) {
|
|
334
|
+
violations.push(violation3("medium", "status.residual.invalid-lifecycle", `lifecycle must be one of ${RESIDUAL_LIFECYCLES.join(" | ")} — got ${JSON.stringify(lifecycle)}`));
|
|
335
|
+
} else if (lifecycle !== "open") {
|
|
336
|
+
if (closed_at === undefined) {
|
|
337
|
+
violations.push(violation3("high", "status.residual.closed-missing-closed-at", `lifecycle "${lifecycle}" requires closed_at (YYYY-MM-DD)`, 'set closed_at (e.g. "2026-08-08")'));
|
|
338
|
+
}
|
|
339
|
+
if (entry.closure_note === undefined) {
|
|
340
|
+
violations.push(violation3("medium", "status.residual.closed-missing-closure-note", `lifecycle "${lifecycle}" requires closure_note (what changed; how verified)`, "add closure_note explaining the close"));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return { ok: violations.length === 0, violations };
|
|
345
|
+
}
|
|
346
|
+
function validateStatus(docOrPath) {
|
|
347
|
+
let doc;
|
|
348
|
+
if (typeof docOrPath === "string") {
|
|
349
|
+
try {
|
|
350
|
+
doc = readJson(docOrPath);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
return {
|
|
353
|
+
ok: false,
|
|
354
|
+
violations: [violation3("high", "status.invalid-json", error.message)]
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
doc = docOrPath;
|
|
359
|
+
}
|
|
360
|
+
const violations = [];
|
|
361
|
+
const { version, updated_at, plans, residual_findings, metadata } = doc;
|
|
362
|
+
if (version === undefined) {
|
|
363
|
+
violations.push(violation3("high", "status.missing-version", "missing required field: version"));
|
|
364
|
+
} else if (typeof version !== "number" || !Number.isInteger(version)) {
|
|
365
|
+
violations.push(violation3("high", "status.invalid-version", "version must be an integer"));
|
|
366
|
+
} else if (version !== 1) {
|
|
367
|
+
violations.push(violation3("medium", "status.unsupported-version", `unsupported status.json schema version ${version} — expected 1`));
|
|
368
|
+
}
|
|
369
|
+
if (updated_at === undefined) {
|
|
370
|
+
violations.push(violation3("high", "status.missing-updated-at", "missing required field: updated_at"));
|
|
371
|
+
} else if (typeof updated_at !== "string" || !DATE_RE.test(updated_at)) {
|
|
372
|
+
violations.push(violation3("medium", "status.invalid-updated-at", "updated_at must be YYYY-MM-DD"));
|
|
373
|
+
}
|
|
374
|
+
if (plans === undefined) {
|
|
375
|
+
violations.push(violation3("high", "status.missing-plans", "missing required field: plans"));
|
|
376
|
+
} else if (!Array.isArray(plans)) {
|
|
377
|
+
violations.push(violation3("high", "status.invalid-plans", "plans must be an array"));
|
|
378
|
+
} else {
|
|
379
|
+
for (const row of plans) {
|
|
380
|
+
violations.push(...validatePlanRow(row).violations);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (residual_findings === undefined) {
|
|
384
|
+
violations.push(violation3("high", "status.missing-residual-findings", "missing required field: residual_findings (root-only canonical)"));
|
|
385
|
+
} else if (!isPlainObject2(residual_findings)) {
|
|
386
|
+
violations.push(violation3("high", "status.invalid-residual-findings", "residual_findings must be an object at root"));
|
|
387
|
+
} else {
|
|
388
|
+
for (const [planId, list] of Object.entries(residual_findings)) {
|
|
389
|
+
if (!Array.isArray(list)) {
|
|
390
|
+
violations.push(violation3("high", "status.residual.invalid-list", `residual_findings["${planId}"] must be an array`));
|
|
391
|
+
} else if (list.length === 0) {
|
|
392
|
+
violations.push(violation3("low", "status.residual.empty-key", `residual_findings["${planId}"] is empty — delete the key (no "plan-id": [])`));
|
|
393
|
+
} else {
|
|
394
|
+
for (const entry of list) {
|
|
395
|
+
violations.push(...validateResidual(entry).violations);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (metadata === undefined) {
|
|
401
|
+
violations.push(violation3("high", "status.missing-metadata", "missing required field: metadata"));
|
|
402
|
+
} else if (!isPlainObject2(metadata)) {
|
|
403
|
+
violations.push(violation3("high", "status.invalid-metadata", "metadata must be an object"));
|
|
404
|
+
} else if (Object.prototype.hasOwnProperty.call(metadata, "residual_findings")) {
|
|
405
|
+
violations.push(violation3("medium", "status.dual-write-residuals", "residual_findings must be root-only — metadata.residual_findings is legacy read-only; remove it (no dual-write)", "move entries to root residual_findings and delete metadata.residual_findings"));
|
|
406
|
+
}
|
|
407
|
+
return { ok: violations.length === 0, violations };
|
|
408
|
+
}
|
|
409
|
+
function resolveCompassEnforcement(harnessDir) {
|
|
410
|
+
const iterationsDir = resolveIterationDir(harnessDir);
|
|
411
|
+
if (!existsSync2(iterationsDir))
|
|
412
|
+
return { hard: false, source: "none" };
|
|
413
|
+
let entries;
|
|
414
|
+
try {
|
|
415
|
+
entries = readdirSync2(iterationsDir, { withFileTypes: true });
|
|
416
|
+
} catch {
|
|
417
|
+
return { hard: false, source: "none" };
|
|
418
|
+
}
|
|
419
|
+
for (const entry of entries) {
|
|
420
|
+
if (!entry.isDirectory())
|
|
421
|
+
continue;
|
|
422
|
+
const compassPath = join4(iterationsDir, entry.name, "delivery-compass.md");
|
|
423
|
+
if (!existsSync2(compassPath))
|
|
424
|
+
continue;
|
|
425
|
+
let content;
|
|
426
|
+
try {
|
|
427
|
+
content = readFileSync3(compassPath, "utf8");
|
|
428
|
+
} catch {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
432
|
+
const fm = frontmatter !== null ? frontmatter[1] : "";
|
|
433
|
+
if (!/^status[ \t]*:[ \t]*(?:active|locked)[ \t]*$/m.test(fm))
|
|
434
|
+
continue;
|
|
435
|
+
const flag = parseEnforcementFlag(fm);
|
|
436
|
+
if (flag.hard)
|
|
437
|
+
return flag;
|
|
438
|
+
}
|
|
439
|
+
return { hard: false, source: "none" };
|
|
440
|
+
}
|
|
441
|
+
var GIT_CAPTURE_MAX_BYTES = 64 * 1024 * 1024;
|
|
442
|
+
var WALK_SKIP_DIRS = new Set(["node_modules", ".git", "dist"]);
|
|
443
|
+
|
|
1
444
|
// src/mstar.ts
|
|
2
445
|
import fs from "node:fs";
|
|
3
446
|
import os from "node:os";
|
|
@@ -149,6 +592,89 @@ var loadBundledCommands = () => {
|
|
|
149
592
|
}
|
|
150
593
|
return result;
|
|
151
594
|
};
|
|
595
|
+
var defaultStatusLogger = (level, message) => {
|
|
596
|
+
const line = `[mstar-harness] ${message}`;
|
|
597
|
+
if (level === "warn")
|
|
598
|
+
console.warn(line);
|
|
599
|
+
else if (level === "error")
|
|
600
|
+
console.error(line);
|
|
601
|
+
else
|
|
602
|
+
console.log(line);
|
|
603
|
+
};
|
|
604
|
+
var STATUS_FILE = "status.json";
|
|
605
|
+
function validateStatusWrite(targetPath, opts = {}) {
|
|
606
|
+
const log = opts.log ?? defaultStatusLogger;
|
|
607
|
+
try {
|
|
608
|
+
const resolved = path.resolve(targetPath);
|
|
609
|
+
if (path.basename(resolved) !== STATUS_FILE)
|
|
610
|
+
return null;
|
|
611
|
+
const harnessDir = resolveHarnessDir(path.dirname(resolved));
|
|
612
|
+
if (!harnessDir || path.join(harnessDir, STATUS_FILE) !== resolved)
|
|
613
|
+
return null;
|
|
614
|
+
const result = opts.doc !== undefined ? validateStatus(opts.doc) : fs.existsSync(resolved) ? validateStatus(resolved) : null;
|
|
615
|
+
if (!result)
|
|
616
|
+
return null;
|
|
617
|
+
const enforcement = opts.enforcement ?? resolveCompassEnforcement(harnessDir);
|
|
618
|
+
if (!result.ok) {
|
|
619
|
+
for (const violation of result.violations) {
|
|
620
|
+
const fix = violation.fix ? ` (fix: ${violation.fix})` : "";
|
|
621
|
+
if (enforcement.hard) {
|
|
622
|
+
log("error", `status.json validation (hard gate): [${violation.severity}] ${violation.code}: ${violation.message}${fix} — hardBlocked per Enforcement: hard; refusal requires a host refusal channel (skill: mstar-plan-artifacts/references/status-and-residuals.md)`);
|
|
623
|
+
} else {
|
|
624
|
+
log("warn", `status.json validation: [${violation.severity}] ${violation.code}: ${violation.message}${fix}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return applyEnforcement(result, { hard: enforcement.hard });
|
|
629
|
+
} catch (error) {
|
|
630
|
+
log("error", `status.json validation aborted: ${error.message}`);
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
var ASSIGNMENT_HEADING_RE = /^#{1,6}\s+Assignment\s*$/m;
|
|
635
|
+
var ASSIGNMENT_FIELD_RE = /^[ \t]*(?:[-*][ \t]+)?\*{0,2}(Execute as|Delegation|Task category)\*{0,2}[ \t]*:[ \t]*(\S.*)$/gm;
|
|
636
|
+
function isAssignmentShaped(assignmentText) {
|
|
637
|
+
return ASSIGNMENT_HEADING_RE.test(assignmentText) || assignmentText.match(ASSIGNMENT_FIELD_RE) !== null;
|
|
638
|
+
}
|
|
639
|
+
function validateDispatchAssignment(assignmentText, opts = {}) {
|
|
640
|
+
const log = opts.log ?? defaultStatusLogger;
|
|
641
|
+
try {
|
|
642
|
+
if (!isAssignmentShaped(assignmentText))
|
|
643
|
+
return { ok: true, violations: [] };
|
|
644
|
+
const violations = [];
|
|
645
|
+
const fields = parseAssignmentFields(assignmentText);
|
|
646
|
+
const writable = isReadOnlyAssignmentRole(fields.executeAs ?? "") ? false : undefined;
|
|
647
|
+
violations.push(...validateAssignmentFields(assignmentText, { writable }).violations);
|
|
648
|
+
const binding = opts.subagentType ?? "";
|
|
649
|
+
if (binding.trim() !== "") {
|
|
650
|
+
violations.push(...antiRecursionPrecheck(binding, fields.executeAs ?? "").violations);
|
|
651
|
+
}
|
|
652
|
+
if (writable !== false) {
|
|
653
|
+
const forms = parseAssignmentBranchForms(assignmentText);
|
|
654
|
+
const branch = forms.createForm?.name ?? forms.workingBranch ?? forms.directOn?.branch ?? process.env.MSTAR_WORKING_BRANCH;
|
|
655
|
+
if (branch !== undefined && branch.trim() !== "") {
|
|
656
|
+
const directOnException = parseBranchPolicyDirectOnBranch(assignmentText) === branch.trim();
|
|
657
|
+
violations.push(...assertDefaultBranchProtected(branch.trim(), { directOnException }).violations);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const result = { ok: violations.length === 0, violations };
|
|
661
|
+
const enforcement = parseEnforcementFlag(assignmentHeaderRegion(assignmentText));
|
|
662
|
+
if (!result.ok) {
|
|
663
|
+
for (const violation of result.violations) {
|
|
664
|
+
const fix = violation.fix ? ` (fix: ${violation.fix})` : "";
|
|
665
|
+
if (enforcement.hard) {
|
|
666
|
+
log("error", `assignment validation (hard gate): [${violation.severity}] ${violation.code}: ${violation.message}${fix} — hardBlocked per Enforcement: hard; refusal requires a host refusal channel (skill: mstar-dispatch-gates)`);
|
|
667
|
+
} else {
|
|
668
|
+
log("warn", `assignment validation: [${violation.severity}] ${violation.code}: ${violation.message}${fix}`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return applyEnforcement(result, { hard: enforcement.hard });
|
|
673
|
+
} catch (error) {
|
|
674
|
+
log("error", `assignment validation aborted: ${error.message}`);
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
152
678
|
var MorningStarHarnessPlugin = async () => {
|
|
153
679
|
const homeDir = os.homedir();
|
|
154
680
|
const envConfigDir = normalizePath(process.env.OPENCODE_CONFIG_DIR, homeDir);
|
|
@@ -183,6 +709,39 @@ var MorningStarHarnessPlugin = async () => {
|
|
|
183
709
|
};
|
|
184
710
|
}
|
|
185
711
|
},
|
|
712
|
+
"tool.execute.before": async (input, output) => {
|
|
713
|
+
const args = output?.args ?? {};
|
|
714
|
+
if (input.tool === "task" && typeof args.prompt === "string") {
|
|
715
|
+
const subagentType = typeof args.subagent === "string" ? args.subagent : typeof args.subagent_type === "string" ? args.subagent_type : "";
|
|
716
|
+
const gate = validateDispatchAssignment(args.prompt, { subagentType });
|
|
717
|
+
if (gate?.hardBlocked) {
|
|
718
|
+
defaultStatusLogger("error", "hard-gate blocked (hardBlocked=true) — refusal requires a host refusal channel");
|
|
719
|
+
}
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (typeof args.filePath !== "string")
|
|
723
|
+
return;
|
|
724
|
+
if (input.tool === "write") {
|
|
725
|
+
const rawContent = args.content;
|
|
726
|
+
let doc;
|
|
727
|
+
if (typeof rawContent === "string") {
|
|
728
|
+
try {
|
|
729
|
+
doc = JSON.parse(rawContent);
|
|
730
|
+
} catch {
|
|
731
|
+
doc = undefined;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const gate = validateStatusWrite(args.filePath, { doc });
|
|
735
|
+
if (gate?.hardBlocked) {
|
|
736
|
+
defaultStatusLogger("error", "hard-gate blocked (hardBlocked=true) — refusal requires a host refusal channel");
|
|
737
|
+
}
|
|
738
|
+
} else if (input.tool === "edit") {
|
|
739
|
+
const gate = validateStatusWrite(args.filePath);
|
|
740
|
+
if (gate?.hardBlocked) {
|
|
741
|
+
defaultStatusLogger("error", "hard-gate blocked (hardBlocked=true) — refusal requires a host refusal channel");
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
},
|
|
186
745
|
"experimental.chat.messages.transform": async (_input, output) => {
|
|
187
746
|
const bootstrap = loadBootstrapContent();
|
|
188
747
|
if (!bootstrap || !output.messages.length)
|
|
@@ -203,5 +762,7 @@ var MorningStarHarnessPlugin = async () => {
|
|
|
203
762
|
};
|
|
204
763
|
};
|
|
205
764
|
export {
|
|
765
|
+
validateStatusWrite,
|
|
766
|
+
validateDispatchAssignment,
|
|
206
767
|
MorningStarHarnessPlugin
|
|
207
768
|
};
|