sortie-dogs 0.1.1
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/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +317 -0
- package/dist/core/diagnostics.d.ts +2 -0
- package/dist/core/diagnostics.js +65 -0
- package/dist/core/initialize.d.ts +14 -0
- package/dist/core/initialize.js +371 -0
- package/dist/core/path.d.ts +10 -0
- package/dist/core/path.js +27 -0
- package/dist/core/types.d.ts +114 -0
- package/dist/core/types.js +1 -0
- package/dist/core/validate-manifest.d.ts +3 -0
- package/dist/core/validate-manifest.js +139 -0
- package/dist/core/validate-schema.d.ts +5 -0
- package/dist/core/validate-schema.js +215 -0
- package/dist/core/validate-semantics.d.ts +15 -0
- package/dist/core/validate-semantics.js +295 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/plugin/config.d.ts +29 -0
- package/dist/plugin/config.js +173 -0
- package/dist/plugin/gate.d.ts +38 -0
- package/dist/plugin/gate.js +320 -0
- package/dist/plugin/index.d.ts +36 -0
- package/dist/plugin/index.js +271 -0
- package/dist/plugin/model-routing-hook.d.ts +40 -0
- package/dist/plugin/model-routing-hook.js +55 -0
- package/dist/plugin/model-routing.d.ts +62 -0
- package/dist/plugin/model-routing.js +128 -0
- package/dist/runtime-assets.d.ts +37 -0
- package/dist/runtime-assets.js +333 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zufall-upon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Sortie-dogs
|
|
2
|
+
|
|
3
|
+
**Give OpenCode a task; get a bounded, validated implementation loop instead of an open-ended agent run.**
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
Sortie-dogs is an opt-in OpenCode orchestration plugin. It turns a task into a
|
|
8
|
+
scoped plan, parallel investigation, dedicated implementation, canonical
|
|
9
|
+
validation, and evidence-backed completion—while preserving standard OpenCode
|
|
10
|
+
agents and settings.
|
|
11
|
+
|
|
12
|
+
Requirements: Node.js 22.6 or newer, npm, and OpenCode.
|
|
13
|
+
|
|
14
|
+
Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md)
|
|
15
|
+
|
|
16
|
+
## Why Sortie-dogs
|
|
17
|
+
|
|
18
|
+
- **Focused when invited, invisible otherwise.** Activate it with `/sortie` or
|
|
19
|
+
select `dog-coordinator`; ordinary OpenCode sessions remain unchanged.
|
|
20
|
+
- **Parallel context without uncontrolled fan-out.** Every worker handoff uses
|
|
21
|
+
exactly three bounded scouts before implementation begins.
|
|
22
|
+
- **Writes stay inside the assignment.** Exact source or operation manifests
|
|
23
|
+
gate edits and handoffs.
|
|
24
|
+
- **One accountable implementation path.** A dedicated Sol worker handles
|
|
25
|
+
implementation, remediation, and blocker resolution.
|
|
26
|
+
- **Evidence before completion.** Canonical validation, risk-based review, and
|
|
27
|
+
terminal evidence gate coordinator-owned completion and commits.
|
|
28
|
+
- **Long work can recover.** Restart recovery and bounded compaction continue
|
|
29
|
+
from retained handoff context rather than silently starting over.
|
|
30
|
+
|
|
31
|
+
## The workflow
|
|
32
|
+
|
|
33
|
+
1. **Brief and plan** — `dog-coordinator` turns the request into acceptance
|
|
34
|
+
criteria, a write manifest, and validation requirements.
|
|
35
|
+
2. **Exactly three scouts** — bounded, read-only investigation collects
|
|
36
|
+
complementary evidence without expanding the write scope.
|
|
37
|
+
3. **Dedicated worker** — the Sol worker implements only the approved manifest
|
|
38
|
+
and also owns scoped remediation or blocker resolution.
|
|
39
|
+
4. **Canonical validation** — the declared test or build command must produce
|
|
40
|
+
acceptable evidence.
|
|
41
|
+
5. **Risk-based review** — high-risk candidates receive independent review;
|
|
42
|
+
low-risk candidates can skip that extra pass after validation.
|
|
43
|
+
6. **Coordinator completion** — only the coordinator closes the loop and owns
|
|
44
|
+
any commit after manifest, validation, review, and evidence gates pass.
|
|
45
|
+
7. **Bounded continuation** — restart recovery and compaction handoffs preserve
|
|
46
|
+
progress; repeated batches remain bounded rather than becoming endless
|
|
47
|
+
delegation.
|
|
48
|
+
|
|
49
|
+
## A visual walkthrough
|
|
50
|
+
|
|
51
|
+
### Control complexity
|
|
52
|
+
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+
The coordinator keeps investigation, implementation, validation, and review in
|
|
56
|
+
separate roles. Manifest gates keep their writes bounded even as the project
|
|
57
|
+
gets more complex.
|
|
58
|
+
|
|
59
|
+
### Finish with evidence
|
|
60
|
+
|
|
61
|
+

|
|
62
|
+
|
|
63
|
+
Validation and risk-based review happen before coordinator-owned completion, so
|
|
64
|
+
the result returns with a concise record of what changed and how it was checked.
|
|
65
|
+
|
|
66
|
+
## Install from npm
|
|
67
|
+
|
|
68
|
+
Install the public package in the target project, then generate the
|
|
69
|
+
project-local runtime files:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
npm install --save-dev sortie-dogs
|
|
73
|
+
npx sortie-dogs init .
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Create `.opencode/plugins/sortie-dogs.ts` as the OpenCode plugin bridge:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
export { SortieDogsPlugin } from "sortie-dogs/plugin";
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
OpenCode discovers the bridge automatically; no `plugin` entry in
|
|
83
|
+
`opencode.json` is required. Restart OpenCode, then start a task:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
/sortie <task>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Selecting `dog-coordinator` directly also activates the workflow.
|
|
90
|
+
|
|
91
|
+
## Scope and session guarantees
|
|
92
|
+
|
|
93
|
+
The plugin is passive by default. It activates a session only when a message
|
|
94
|
+
uses `/sortie` or the selected agent is `dog-coordinator`. It validates exact
|
|
95
|
+
write scope through source or operation manifests and rejects invalid worker
|
|
96
|
+
handoffs. Standard OpenCode agents, roles, settings, and unrelated sessions are
|
|
97
|
+
preserved.
|
|
98
|
+
|
|
99
|
+
On `session.idle`, the final handoff is checked and the session is released. A
|
|
100
|
+
`session.deleted` event also releases it. A later request must activate the
|
|
101
|
+
workflow again.
|
|
102
|
+
|
|
103
|
+
## Model routing
|
|
104
|
+
|
|
105
|
+
`dog-coordinator` and `dog-scout` default to `openai/gpt-5.6-luna` with the
|
|
106
|
+
`xhigh` variant. This is the recommended balance: bounded prompts, concise
|
|
107
|
+
scout evidence, and fewer unnecessary context or tool turns can reduce token
|
|
108
|
+
use while preserving quality. Project-local routing can override either
|
|
109
|
+
default.
|
|
110
|
+
|
|
111
|
+
The `implementation`, `remediation`, and `blocker-resolution` roles always use
|
|
112
|
+
the dedicated Sol worker; user configuration cannot replace those routes. For
|
|
113
|
+
other explicitly routed roles, resolution is deterministic: Sortie-dogs tries
|
|
114
|
+
the preferred target, then ordered fallbacks. Roles without either a built-in
|
|
115
|
+
default or an explicit route keep OpenCode's already selected model.
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"modelRouting": {
|
|
120
|
+
"dog-coordinator": {
|
|
121
|
+
"preferred": { "model": "openai/gpt-5.6-luna", "variant": "xhigh" }
|
|
122
|
+
},
|
|
123
|
+
"dog-scout": {
|
|
124
|
+
"preferred": { "model": "openai/gpt-5.6-luna", "variant": "xhigh" }
|
|
125
|
+
},
|
|
126
|
+
"dog-advisor": {
|
|
127
|
+
"preferred": { "model": "fable/opus", "variant": "thinking" },
|
|
128
|
+
"fallback": [{ "model": "provider/general" }]
|
|
129
|
+
},
|
|
130
|
+
"dog-reviewer": {
|
|
131
|
+
"preferred": { "model": "fable/opus", "variant": "thinking" },
|
|
132
|
+
"fallback": [{ "model": "provider/general" }]
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"modelCatalog": {
|
|
136
|
+
"project": [
|
|
137
|
+
{ "model": "openai/gpt-5.6-luna", "variants": ["xhigh"] },
|
|
138
|
+
{ "model": "fable/opus", "variants": ["thinking"] },
|
|
139
|
+
{ "model": "provider/general" }
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Save project configuration as `.opencode/sortie-dogs.json`. `modelCatalog`
|
|
146
|
+
declares provider models and named variants that are actually available;
|
|
147
|
+
Sortie-dogs does not invent, probe, or translate variants. Resolution tries the
|
|
148
|
+
preferred target and then its fallbacks, rejecting an explicitly routed role
|
|
149
|
+
when no candidate appears in the catalog. The advisor and reviewer routes above
|
|
150
|
+
are optional secondary examples; omit them when they are not needed.
|
|
151
|
+
|
|
152
|
+
`dog-advisor` accepts bounded Strategy or SourceReview consultation from the
|
|
153
|
+
coordinator. `dog-reviewer` independently checks high-risk candidates after
|
|
154
|
+
canonical validation. Neither role implements, stages, commits, or acts as a
|
|
155
|
+
user-facing worker.
|
|
156
|
+
|
|
157
|
+
## Updates and migration
|
|
158
|
+
|
|
159
|
+
After replacing the dependency with a newer release asset, run:
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
npx sortie-dogs init .
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`init` is idempotent. It updates files owned by Sortie-dogs, migrates recognized
|
|
166
|
+
older runtime files, and records the installed version in
|
|
167
|
+
`.opencode/sortie-dogs.version`. Conflicting or unrecognized files remain
|
|
168
|
+
untouched and initialization stops safely. User-owned configuration—including
|
|
169
|
+
`.opencode/sortie-dogs.json`—and standard OpenCode files are preserved.
|
|
170
|
+
|
|
171
|
+
## Safe manual removal
|
|
172
|
+
|
|
173
|
+
There is no supported Sortie-dogs uninstall command. Removing the npm dependency
|
|
174
|
+
is a separate package-manager operation: run `npm uninstall sortie-dogs` only in
|
|
175
|
+
the directory whose `package.json` declares it. Never delete `package.json` or
|
|
176
|
+
`package-lock.json` to remove the package.
|
|
177
|
+
|
|
178
|
+
To remove generated runtime files manually, delete only these exact
|
|
179
|
+
Sortie-dogs-owned paths:
|
|
180
|
+
|
|
181
|
+
```text
|
|
182
|
+
.opencode/agent/dog-coordinator.md
|
|
183
|
+
.opencode/agent/dog-worker.md
|
|
184
|
+
.opencode/agent/dog-scout.md
|
|
185
|
+
.opencode/agent/dog-reviewer.md
|
|
186
|
+
.opencode/agent/dog-advisor.md
|
|
187
|
+
.opencode/command/sortie.md
|
|
188
|
+
.opencode/sortie-dogs.version
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Never delete the `.opencode`, `.opencode/agent`, or `.opencode/command`
|
|
192
|
+
directories, and never use a wildcard such as `*.md`. Preserve the standard
|
|
193
|
+
`plan`, `build`, and `builder` agents and every other user-owned file. Do not
|
|
194
|
+
remove `.opencode/sortie-dogs.json`, the plugin bridge, other agents, or OpenCode
|
|
195
|
+
settings as part of runtime-file removal.
|
|
196
|
+
|
|
197
|
+
The legacy files `.opencode/agent/coordinator-mk2a2.md` and
|
|
198
|
+
`.opencode/agent/sol-worker-mk2a2.md` may be removed only after an old
|
|
199
|
+
Sortie-dogs marker or the content confirms Sortie-dogs ownership. If ownership
|
|
200
|
+
is unclear or a filename is unexpected, stop and inspect instead of deleting.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function run(argv: readonly string[]): Promise<number>;
|
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
const core = await import(`../core/validate-schema.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`);
|
|
6
|
+
const diagnostics = await import(`../core/diagnostics.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`);
|
|
7
|
+
const manifestValidator = await import(`../core/validate-manifest.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`);
|
|
8
|
+
const pathUtils = await import(`../core/path.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`);
|
|
9
|
+
const initializer = await import(`../core/initialize.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`);
|
|
10
|
+
const LIMITS = {
|
|
11
|
+
handoffBytes: 2 * 1024 * 1024,
|
|
12
|
+
manifestBytes: 512 * 1024,
|
|
13
|
+
changedPathsBytes: 1024 * 1024,
|
|
14
|
+
changedPaths: 10_000,
|
|
15
|
+
arrayItems: 10_000,
|
|
16
|
+
pathCharacters: 512,
|
|
17
|
+
jsonDepth: 32,
|
|
18
|
+
};
|
|
19
|
+
const USAGE = `Usage: sortie-dogs lint <handoff.json> [<handoff.json> ...]
|
|
20
|
+
[--manifest <operation-manifest.json>]
|
|
21
|
+
[--changed-paths-from <file|->]
|
|
22
|
+
[--changed-path <path> ...]
|
|
23
|
+
[--format text|json] [--quiet] [--strict]`;
|
|
24
|
+
const INIT_USAGE = "Usage: sortie-dogs init [project-root]";
|
|
25
|
+
class InputFailure extends Error {
|
|
26
|
+
safeMessage;
|
|
27
|
+
constructor(safeMessage) {
|
|
28
|
+
super(safeMessage);
|
|
29
|
+
this.name = "InputFailure";
|
|
30
|
+
this.safeMessage = safeMessage;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function parseArguments(argv) {
|
|
34
|
+
if (argv[0] === "--help") {
|
|
35
|
+
return argv.length === 1 ? { kind: "help" } : { kind: "usage" };
|
|
36
|
+
}
|
|
37
|
+
if (argv[0] !== "lint")
|
|
38
|
+
return { kind: "usage" };
|
|
39
|
+
const parsed = {
|
|
40
|
+
handoffs: [],
|
|
41
|
+
changedPaths: [],
|
|
42
|
+
changedPathsProvided: false,
|
|
43
|
+
format: "text",
|
|
44
|
+
quiet: false,
|
|
45
|
+
strict: false,
|
|
46
|
+
};
|
|
47
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
48
|
+
const argument = argv[index];
|
|
49
|
+
if (argument === "--help")
|
|
50
|
+
return { kind: "help" };
|
|
51
|
+
if (argument === "--quiet" || argument === "--strict") {
|
|
52
|
+
parsed[argument === "--quiet" ? "quiet" : "strict"] = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (argument === "--manifest" || argument === "--changed-paths-from" ||
|
|
56
|
+
argument === "--changed-path" || argument === "--format") {
|
|
57
|
+
const value = argv[index + 1];
|
|
58
|
+
if (value === undefined || value.startsWith("--"))
|
|
59
|
+
return { kind: "usage" };
|
|
60
|
+
index += 1;
|
|
61
|
+
if (argument === "--manifest") {
|
|
62
|
+
if (parsed.manifest !== undefined)
|
|
63
|
+
return { kind: "usage" };
|
|
64
|
+
parsed.manifest = value;
|
|
65
|
+
}
|
|
66
|
+
else if (argument === "--changed-paths-from") {
|
|
67
|
+
if (parsed.changedPathsFrom !== undefined)
|
|
68
|
+
return { kind: "usage" };
|
|
69
|
+
parsed.changedPathsFrom = value;
|
|
70
|
+
parsed.changedPathsProvided = true;
|
|
71
|
+
}
|
|
72
|
+
else if (argument === "--changed-path") {
|
|
73
|
+
parsed.changedPaths.push(value);
|
|
74
|
+
parsed.changedPathsProvided = true;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
if (value !== "text" && value !== "json")
|
|
78
|
+
return { kind: "usage" };
|
|
79
|
+
parsed.format = value;
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (argument.startsWith("-"))
|
|
84
|
+
return { kind: "usage" };
|
|
85
|
+
parsed.handoffs.push(argument);
|
|
86
|
+
}
|
|
87
|
+
return parsed.handoffs.length === 0 ? { kind: "usage" } : { kind: "run", value: parsed };
|
|
88
|
+
}
|
|
89
|
+
async function readBoundedFile(file, limit, label) {
|
|
90
|
+
let handle;
|
|
91
|
+
try {
|
|
92
|
+
handle = await open(file, "r");
|
|
93
|
+
const buffer = Buffer.allocUnsafe(limit + 1);
|
|
94
|
+
let length = 0;
|
|
95
|
+
while (length <= limit) {
|
|
96
|
+
const result = await handle.read(buffer, length, buffer.length - length, null);
|
|
97
|
+
if (result.bytesRead === 0)
|
|
98
|
+
return buffer.subarray(0, length);
|
|
99
|
+
length += result.bytesRead;
|
|
100
|
+
}
|
|
101
|
+
throw new InputFailure(`${label} exceeds the size limit.`);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof InputFailure)
|
|
105
|
+
throw error;
|
|
106
|
+
throw new InputFailure(`${label} could not be read.`);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await handle?.close().catch(() => undefined);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function readBoundedStdin(limit) {
|
|
113
|
+
const chunks = [];
|
|
114
|
+
let length = 0;
|
|
115
|
+
try {
|
|
116
|
+
for await (const chunk of process.stdin) {
|
|
117
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
118
|
+
length += buffer.length;
|
|
119
|
+
if (length > limit)
|
|
120
|
+
throw new InputFailure("Changed paths input exceeds the size limit.");
|
|
121
|
+
chunks.push(buffer);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (error instanceof InputFailure)
|
|
126
|
+
throw error;
|
|
127
|
+
throw new InputFailure("Changed paths input could not be read.");
|
|
128
|
+
}
|
|
129
|
+
return Buffer.concat(chunks, length);
|
|
130
|
+
}
|
|
131
|
+
function checkJsonResources(value) {
|
|
132
|
+
const pending = [{ value, depth: 1 }];
|
|
133
|
+
while (pending.length > 0) {
|
|
134
|
+
const current = pending.pop();
|
|
135
|
+
const isContainer = Array.isArray(current.value) ||
|
|
136
|
+
(current.value !== null && typeof current.value === "object");
|
|
137
|
+
if (isContainer && current.depth > LIMITS.jsonDepth) {
|
|
138
|
+
throw new InputFailure("JSON input exceeds the nesting depth limit.");
|
|
139
|
+
}
|
|
140
|
+
if (Array.isArray(current.value)) {
|
|
141
|
+
if (current.value.length > LIMITS.arrayItems) {
|
|
142
|
+
throw new InputFailure("JSON input exceeds the array item limit.");
|
|
143
|
+
}
|
|
144
|
+
for (const item of current.value)
|
|
145
|
+
pending.push({ value: item, depth: current.depth + 1 });
|
|
146
|
+
}
|
|
147
|
+
else if (current.value !== null && typeof current.value === "object") {
|
|
148
|
+
for (const item of Object.values(current.value)) {
|
|
149
|
+
pending.push({ value: item, depth: current.depth + 1 });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function parseJson(buffer, label) {
|
|
155
|
+
let value;
|
|
156
|
+
try {
|
|
157
|
+
value = JSON.parse(buffer.toString("utf8"));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
throw new InputFailure(`${label} is not valid JSON.`);
|
|
161
|
+
}
|
|
162
|
+
checkJsonResources(value);
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
function normalizeChangedPaths(paths) {
|
|
166
|
+
const normalized = new Set();
|
|
167
|
+
for (const path of paths) {
|
|
168
|
+
if (path.length > LIMITS.pathCharacters) {
|
|
169
|
+
throw new InputFailure("Changed paths input is invalid.");
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
normalized.add(pathUtils.normalizeRelativePath(path));
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (!(error instanceof pathUtils.RelativePathError))
|
|
176
|
+
throw error;
|
|
177
|
+
throw new InputFailure("Changed paths input is invalid.");
|
|
178
|
+
}
|
|
179
|
+
if (normalized.size > LIMITS.changedPaths) {
|
|
180
|
+
throw new InputFailure("Changed paths input exceeds the count limit.");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return [...normalized];
|
|
184
|
+
}
|
|
185
|
+
function changedPathLines(buffer) {
|
|
186
|
+
return buffer.toString("utf8").split(/\r?\n/u).filter((line) => line.length > 0);
|
|
187
|
+
}
|
|
188
|
+
function safeSchemaPointer(diagnostic) {
|
|
189
|
+
if (diagnostic.code !== "schema_additionalProperties")
|
|
190
|
+
return diagnostic.pointer;
|
|
191
|
+
const slash = diagnostic.pointer.lastIndexOf("/");
|
|
192
|
+
return `${diagnostic.pointer.slice(0, Math.max(0, slash))}/@unknown`;
|
|
193
|
+
}
|
|
194
|
+
function associate(file, input) {
|
|
195
|
+
return input.map((diagnostic) => ({
|
|
196
|
+
file,
|
|
197
|
+
code: diagnostic.code,
|
|
198
|
+
severity: diagnostic.severity,
|
|
199
|
+
pointer: diagnostic.code.startsWith("schema_")
|
|
200
|
+
? safeSchemaPointer(diagnostic)
|
|
201
|
+
: diagnostic.pointer,
|
|
202
|
+
message: diagnostic.message,
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
function neutralizeTextControlCharacters(value) {
|
|
206
|
+
return [...value].map((character) => {
|
|
207
|
+
const codePoint = character.codePointAt(0);
|
|
208
|
+
if (codePoint === 0x0a)
|
|
209
|
+
return "\\n";
|
|
210
|
+
if (codePoint === 0x0d)
|
|
211
|
+
return "\\r";
|
|
212
|
+
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
|
|
213
|
+
return `\\u${codePoint.toString(16).padStart(4, "0")}`;
|
|
214
|
+
}
|
|
215
|
+
return character;
|
|
216
|
+
}).join("");
|
|
217
|
+
}
|
|
218
|
+
function render(output, format) {
|
|
219
|
+
if (format === "json")
|
|
220
|
+
return `${JSON.stringify(output)}\n`;
|
|
221
|
+
return output.map(({ file, pointer, code, severity, message }) => `${file} ${neutralizeTextControlCharacters(pointer || "/")} ${code} ${severity} ${neutralizeTextControlCharacters(message)}\n`).join("");
|
|
222
|
+
}
|
|
223
|
+
export async function run(argv) {
|
|
224
|
+
if (argv[0] === "init") {
|
|
225
|
+
if (argv[1] === "--help" && argv.length === 2) {
|
|
226
|
+
process.stdout.write(`${INIT_USAGE}\n`);
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
if (argv.length > 2 || argv[1]?.startsWith("-") === true) {
|
|
230
|
+
process.stderr.write(`${INIT_USAGE}\n`);
|
|
231
|
+
return 2;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const initialized = await initializer.initializeProject(argv[1]);
|
|
235
|
+
process.stdout.write(initialized.status === "installed"
|
|
236
|
+
? `Initialized Sortie-dogs ${initialized.version}.\n`
|
|
237
|
+
: `Sortie-dogs ${initialized.version} is already initialized.\n`);
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
process.stderr.write(error instanceof initializer.ProjectInitializationError
|
|
242
|
+
? `${error.message}\n`
|
|
243
|
+
: "Initialization failed.\n");
|
|
244
|
+
return 2;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const result = parseArguments(argv);
|
|
248
|
+
if (result.kind === "help") {
|
|
249
|
+
process.stdout.write(`${USAGE}\n`);
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
if (result.kind === "usage") {
|
|
253
|
+
process.stderr.write(`${USAGE}\n`);
|
|
254
|
+
return 2;
|
|
255
|
+
}
|
|
256
|
+
const options = result.value;
|
|
257
|
+
const output = [];
|
|
258
|
+
const failures = [];
|
|
259
|
+
let manifest;
|
|
260
|
+
let changedPaths;
|
|
261
|
+
let changedPathsResolved = true;
|
|
262
|
+
if (options.manifest !== undefined) {
|
|
263
|
+
try {
|
|
264
|
+
const value = parseJson(await readBoundedFile(options.manifest, LIMITS.manifestBytes, "Manifest input"), "Manifest input");
|
|
265
|
+
const validation = core.validateOperationManifestSchema(value);
|
|
266
|
+
if (validation.ok)
|
|
267
|
+
manifest = validation.value;
|
|
268
|
+
else
|
|
269
|
+
output.push(...associate("manifest", validation.diagnostics));
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
failures.push(error instanceof InputFailure ? error.safeMessage : "Manifest input could not be processed.");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const fromFile = options.changedPathsFrom === undefined
|
|
277
|
+
? []
|
|
278
|
+
: changedPathLines(options.changedPathsFrom === "-"
|
|
279
|
+
? await readBoundedStdin(LIMITS.changedPathsBytes)
|
|
280
|
+
: await readBoundedFile(options.changedPathsFrom, LIMITS.changedPathsBytes, "Changed paths input"));
|
|
281
|
+
changedPaths = normalizeChangedPaths([...fromFile, ...options.changedPaths]);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
changedPathsResolved = false;
|
|
285
|
+
failures.push(error instanceof InputFailure ? error.safeMessage : "Changed paths input could not be processed.");
|
|
286
|
+
}
|
|
287
|
+
for (let index = 0; index < options.handoffs.length; index += 1) {
|
|
288
|
+
const file = `handoff[${index}]`;
|
|
289
|
+
try {
|
|
290
|
+
const value = parseJson(await readBoundedFile(options.handoffs[index], LIMITS.handoffBytes, "Handoff input"), "Handoff input");
|
|
291
|
+
const validation = core.validateHandoffSchema(value);
|
|
292
|
+
if (!validation.ok) {
|
|
293
|
+
output.push(...associate(file, validation.diagnostics));
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
output.push(...associate(file, diagnostics.lint(validation.value).diagnostics));
|
|
297
|
+
if (manifest !== undefined && changedPathsResolved) {
|
|
298
|
+
output.push(...associate(file, manifestValidator.validateManifest(validation.value, manifest, changedPaths, options.changedPathsProvided)));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
failures.push(error instanceof InputFailure ? error.safeMessage : "Handoff input could not be processed.");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (!options.quiet)
|
|
306
|
+
process.stdout.write(render(output, options.format));
|
|
307
|
+
for (const failure of failures)
|
|
308
|
+
process.stderr.write(`${failure}\n`);
|
|
309
|
+
if (failures.length > 0)
|
|
310
|
+
return 2;
|
|
311
|
+
const hasFailure = output.some(({ severity }) => severity === "error" ||
|
|
312
|
+
(options.strict && severity === "warning"));
|
|
313
|
+
return hasFailure ? 1 : 0;
|
|
314
|
+
}
|
|
315
|
+
if (process.argv[1] !== undefined && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
|
316
|
+
process.exitCode = await run(process.argv.slice(2));
|
|
317
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const validateSemantics = await import(`./validate-semantics.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`);
|
|
2
|
+
const DEFAULT_SEVERITIES = {
|
|
3
|
+
H001: "error",
|
|
4
|
+
H002: "error",
|
|
5
|
+
H003: "error",
|
|
6
|
+
H004: "error",
|
|
7
|
+
H005: "error",
|
|
8
|
+
H006: "error",
|
|
9
|
+
H007: "error",
|
|
10
|
+
H008: "error",
|
|
11
|
+
H009: "warning",
|
|
12
|
+
H010: "error",
|
|
13
|
+
};
|
|
14
|
+
function compareText(left, right) {
|
|
15
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
16
|
+
}
|
|
17
|
+
function comparePointer(left, right) {
|
|
18
|
+
const leftSegments = left.split("/");
|
|
19
|
+
const rightSegments = right.split("/");
|
|
20
|
+
const length = Math.min(leftSegments.length, rightSegments.length);
|
|
21
|
+
for (let index = 0; index < length; index += 1) {
|
|
22
|
+
const leftSegment = leftSegments[index];
|
|
23
|
+
const rightSegment = rightSegments[index];
|
|
24
|
+
if (leftSegment === rightSegment)
|
|
25
|
+
continue;
|
|
26
|
+
if (/^\d+$/.test(leftSegment) && /^\d+$/.test(rightSegment)) {
|
|
27
|
+
const numericOrder = BigInt(leftSegment) < BigInt(rightSegment) ? -1 : BigInt(leftSegment) > BigInt(rightSegment) ? 1 : 0;
|
|
28
|
+
if (numericOrder !== 0)
|
|
29
|
+
return numericOrder;
|
|
30
|
+
}
|
|
31
|
+
return compareText(leftSegment, rightSegment);
|
|
32
|
+
}
|
|
33
|
+
return leftSegments.length - rightSegments.length;
|
|
34
|
+
}
|
|
35
|
+
function compareDiagnostics(left, right) {
|
|
36
|
+
return (comparePointer(left.pointer, right.pointer) ||
|
|
37
|
+
compareText(left.code, right.code));
|
|
38
|
+
}
|
|
39
|
+
function createLintResult(issues, profile, options = {}) {
|
|
40
|
+
const enabledCodes = options.codes === undefined ? undefined : new Set(options.codes);
|
|
41
|
+
const severityOverrides = options.severity;
|
|
42
|
+
const diagnostics = issues
|
|
43
|
+
.filter((issue) => enabledCodes === undefined || enabledCodes.has(issue.code))
|
|
44
|
+
.map((issue) => ({
|
|
45
|
+
code: issue.code,
|
|
46
|
+
severity: severityOverrides?.[issue.code] ??
|
|
47
|
+
(issue.code === "H006" && profile === "minimal"
|
|
48
|
+
? "warning"
|
|
49
|
+
: DEFAULT_SEVERITIES[issue.code]),
|
|
50
|
+
pointer: issue.path,
|
|
51
|
+
message: issue.message,
|
|
52
|
+
}))
|
|
53
|
+
.sort(compareDiagnostics);
|
|
54
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
55
|
+
for (const diagnostic of diagnostics)
|
|
56
|
+
counts[diagnostic.severity] += 1;
|
|
57
|
+
return {
|
|
58
|
+
diagnostics,
|
|
59
|
+
counts,
|
|
60
|
+
ok: counts.error === 0,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function lint(handoff, options) {
|
|
64
|
+
return createLintResult(validateSemantics.lintHandoff(handoff), handoff.profile, options);
|
|
65
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type InitializationStatus = "installed" | "unchanged";
|
|
2
|
+
export interface InitializeProjectResult {
|
|
3
|
+
readonly status: InitializationStatus;
|
|
4
|
+
readonly version: string;
|
|
5
|
+
readonly installedPaths: readonly string[];
|
|
6
|
+
readonly preservedLegacyPaths: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
export type ProjectInitializationErrorCode = "conflict" | "incompatible-version" | "invalid-project" | "unsafe-path" | "write-failed";
|
|
9
|
+
export declare class ProjectInitializationError extends Error {
|
|
10
|
+
readonly code: ProjectInitializationErrorCode;
|
|
11
|
+
constructor(code: ProjectInitializationErrorCode, message: string, options?: ErrorOptions);
|
|
12
|
+
}
|
|
13
|
+
/** Installs the packaged runtime into one existing project without changing user settings. */
|
|
14
|
+
export declare function initializeProject(projectRoot?: string): Promise<InitializeProjectResult>;
|