sortie-dogs 0.15.2 → 0.16.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/README.md CHANGED
@@ -19,8 +19,8 @@ evidence-backed result, not more agents for their own sake.
19
19
  [![Node.js](https://img.shields.io/node/v/sortie-dogs)](https://www.npmjs.com/package/sortie-dogs)
20
20
  [![MIT License](https://img.shields.io/npm/l/sortie-dogs)](LICENSE)
21
21
 
22
- **Current release: [v0.15.2](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.15.2)**
23
- · [Release notes](docs/release-v0.15.2.md)
22
+ **Current release: [v0.15.3](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.15.3)**
23
+ · [Release notes](docs/release-v0.15.3.md)
24
24
  · [Execution vision](docs/v0.14.0-execution-vision.md)
25
25
 
26
26
  [When to use](#when-to-use-sortie-dogs) · [Quick start](#quick-start) · [Workflow](#mission-workflow) · [Models](#default-models) ·
@@ -78,7 +78,7 @@ Use an existing Codex CLI signed in with **ChatGPT**. On Windows, PowerShell **7
78
78
  available; POSIX uses bash. Sortie does not install Codex, start login or use a metered API fallback.
79
79
 
80
80
  ```sh
81
- npm install --save-dev sortie-dogs@0.15.2
81
+ npm install --save-dev sortie-dogs@0.15.3
82
82
  npx --no-install sortie-dogs codex init .
83
83
  ```
84
84
 
@@ -107,7 +107,7 @@ bridge. [Codex guide: SDK, permissions, progress, resume and manifest route](doc
107
107
  Use **OpenCode V2**. In the target project:
108
108
 
109
109
  ```sh
110
- npm install --save-dev sortie-dogs@0.15.2
110
+ npm install --save-dev sortie-dogs@0.15.3
111
111
  npx --no-install sortie-dogs init .
112
112
  ```
113
113
 
@@ -127,7 +127,7 @@ v0.10 is installed. [OpenCode setup and configuration](docs/configuration.md) ·
127
127
 
128
128
  ### Project Memory (OpenCode v010, opt-in)
129
129
 
130
- With v0.15.2 installed, run `sortie-dogs memory on .` once and reload
130
+ With v0.15.3 installed, run `sortie-dogs memory on .` once and reload
131
131
  the project plugin. Ordinary public turns then become pending sources; the existing
132
132
  Operator records semantic additions, corrections, withdrawals and decisions without
133
133
  another save request or a dedicated model call per turn. New sessions recall the
@@ -284,7 +284,7 @@ Install the desired package version and rerun **your host's** initializer:
284
284
 
285
285
  An OpenCode config-local bridge may resolve a separate package; npm-global update alone does not
286
286
  update it. [Global/update instructions](docs/configuration.md#global-availability).
287
- The current Mission asset marker is `0.15.2-tactical-return-v1`; the Codex skill marker is
287
+ The current Mission asset marker is `0.15.3-external-review-links-v1`; the Codex skill marker is
288
288
  `0.14.6-codex-skill-v1`. Installed markers alone do not prove a running host reloaded the new version.
289
289
 
290
290
  There is no supported uninstall command. Remove the dependency and only known Sortie-owned paths;
@@ -300,6 +300,7 @@ follow the [manual removal guide](docs/uninstall.md), never delete the entire `.
300
300
  [SWE-bench history](docs/benchmarks/swebench-lite-history.md) · [Historical local case study](docs/benchmark-reference.md).
301
301
  - **Development:** [Testing](docs/testing.md) · [CLI testing](docs/cli-testing.md) ·
302
302
  [Windows tests](docs/windows-tests.md) · [Release routine](docs/release-batch.md).
303
+ - **Current release:** [v0.15.3](docs/release-v0.15.3.md).
303
304
  - **Releases:** [v0.15.2](docs/release-v0.15.2.md) · [v0.15.1](docs/v0.15.1-migration.md) · [v0.15.0](docs/release-v0.15.0.md) · [v0.14.9](docs/release-v0.14.9.md) · [v0.14.8](docs/release-v0.14.8.md) · [v0.14.7](docs/release-v0.14.7.md) · [v0.14.6](docs/release-v0.14.6.md) · [v0.14.5](docs/release-v0.14.5.md) · [v0.14.4](docs/release-v0.14.4.md) · [v0.14.3](docs/release-v0.14.3.md) · [v0.14.2](docs/release-v0.14.2.md) · [v0.14.1](docs/release-v0.14.1.md) · [v0.14.0](docs/release-v0.14.0.md) · [v0.13.22](docs/release-v0.13.22.md) · [v0.13.21](docs/release-v0.13.21.md) · [v0.13.20](docs/release-v0.13.20.md) · [v0.13.19](docs/release-v0.13.19.md) · [v0.13.18](docs/release-v0.13.18.md) · [v0.13.17](docs/release-v0.13.17.md) · [v0.13.16](docs/release-v0.13.16.md) · [v0.13.15](docs/release-v0.13.15.md) · [v0.13.14](docs/release-v0.13.14.md) · [v0.13.13](docs/release-v0.13.13.md) · [v0.13.12](docs/release-v0.13.12.md) · [v0.13.11](docs/release-v0.13.11.md) · [v0.13.10](docs/release-v0.13.10.md) ·
304
305
  [v0.13.9](docs/release-v0.13.9.md) · [All GitHub releases](https://github.com/zufall-upon/Sortie-dogs/releases).
305
306
 
@@ -3,6 +3,6 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export declare const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export declare const V010_RUNTIME_ASSET_VERSION = "0.15.2-tactical-return-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.16.0-purpose-aligned-v2";
7
7
  export declare const CODEX_SKILL_ASSET_VERSION = "0.14.6-codex-skill-v1";
8
8
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION | typeof CODEX_SKILL_ASSET_VERSION;
@@ -3,5 +3,5 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export const V010_RUNTIME_ASSET_VERSION = "0.15.2-tactical-return-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.16.0-purpose-aligned-v2";
7
7
  export const CODEX_SKILL_ASSET_VERSION = "0.14.6-codex-skill-v1";
@@ -136,11 +136,12 @@ function classifyVersionTransition(installedValue, currentValue, v010Restore = f
136
136
  const adjacentPreOneLine = installed.major === 0 && current.major === 0 &&
137
137
  current.minor === installed.minor + 1;
138
138
  // v0.11 used a different runtime. The v010 asset migration restores the 0.10
139
- // preview into the 0.12–0.15 Mission line without selecting a v0.11 execution path.
139
+ // preview into the 0.12–0.16 Mission line without selecting a v0.11 execution path.
140
140
  const restoredPreview = v010Restore && installed.major === 0 && installed.minor === 10 &&
141
- current.major === 0 && [12, 13, 14, 15].includes(current.minor);
141
+ current.major === 0 && [12, 13, 14, 15, 16].includes(current.minor);
142
142
  const retainedMission = v010Restore && installed.major === 0 &&
143
- ((installed.minor === 12 && current.minor === 14) || ([12, 13].includes(installed.minor) && current.minor === 15)) &&
143
+ ((installed.minor === 12 && current.minor === 14) || ([12, 13].includes(installed.minor) && current.minor === 15) ||
144
+ ([12, 13, 14].includes(installed.minor) && current.minor === 16)) &&
144
145
  current.major === 0;
145
146
  return sameLine || adjacentPreOneLine || restoredPreview || retainedMission ? "compatible-update" : "incompatible";
146
147
  }
@@ -0,0 +1,42 @@
1
+ import type { OperatorMission } from "./operator-mission.js";
2
+ export declare const PURPOSE_DECISIONS: readonly ["CONTINUE", "REUSE", "CHANGE_METHOD", "STOP_METHOD", "NEEDS_DECISION"];
3
+ export interface PurposeDecisionInput {
4
+ decision: typeof PURPOSE_DECISIONS[number];
5
+ requirement_ids: string[];
6
+ observed_outcome: string;
7
+ unmet_work: string[];
8
+ reason: string;
9
+ evidence_refs: string[];
10
+ affected_method?: string;
11
+ next_action: string;
12
+ }
13
+ export interface PurposeDecision extends PurposeDecisionInput {
14
+ id: string;
15
+ mission_id: string;
16
+ run_id: string | null;
17
+ requirements_fingerprint: string;
18
+ recorded_at: string;
19
+ }
20
+ export declare function purposeRequirementsFingerprint(mission: OperatorMission): string;
21
+ /** A bounded strategy observation, never an evidence receipt or an execution control. */
22
+ export declare function validatePurposeDecision(mission: OperatorMission, raw: unknown): asserts raw is PurposeDecisionInput;
23
+ export declare function appendPurposeDecision(mission: OperatorMission, raw: unknown): void;
24
+ export declare function purposeDecisionView(mission: OperatorMission): {
25
+ current: PurposeDecision[];
26
+ historical: PurposeDecision[];
27
+ application_status: string;
28
+ interpretation: string;
29
+ };
30
+ export declare const purposeDecisionSchema: {
31
+ type: string;
32
+ "x-sortie-optional": boolean;
33
+ additionalProperties: boolean;
34
+ description: string;
35
+ required: string[];
36
+ properties: {
37
+ decision: {
38
+ type: string;
39
+ enum: ("CONTINUE" | "REUSE" | "CHANGE_METHOD" | "STOP_METHOD" | "NEEDS_DECISION")[];
40
+ };
41
+ };
42
+ };
@@ -0,0 +1,56 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ export const PURPOSE_DECISIONS = ["CONTINUE", "REUSE", "CHANGE_METHOD", "STOP_METHOD", "NEEDS_DECISION"];
3
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
4
+ const line = (value) => typeof value === "string" && !!value.trim() && value.length <= 2000;
5
+ const lines = (value, min = 0) => Array.isArray(value) && value.length >= min && value.length <= 64 && value.every(line);
6
+ const keys = ["decision", "requirement_ids", "observed_outcome", "unmet_work", "reason", "evidence_refs", "affected_method", "next_action"];
7
+ function valid(value) {
8
+ return object(value) && PURPOSE_DECISIONS.includes(value.decision) &&
9
+ lines(value.requirement_ids, 1) && line(value.observed_outcome) && lines(value.unmet_work) && line(value.reason) &&
10
+ lines(value.evidence_refs) && (value.affected_method === undefined || line(value.affected_method)) && line(value.next_action) &&
11
+ (!["CHANGE_METHOD", "STOP_METHOD"].includes(String(value.decision)) || line(value.affected_method));
12
+ }
13
+ export function purposeRequirementsFingerprint(mission) {
14
+ return createHash("sha256").update(JSON.stringify(mission.requirements)).digest("hex");
15
+ }
16
+ /** A bounded strategy observation, never an evidence receipt or an execution control. */
17
+ export function validatePurposeDecision(mission, raw) {
18
+ if (!valid(raw) || Object.keys(raw).some(key => !keys.includes(key)) ||
19
+ raw.requirement_ids.some(id => !mission.requirements.some(requirement => requirement.id === id))) {
20
+ throw new Error("mission-purpose-decision-invalid");
21
+ }
22
+ }
23
+ export function appendPurposeDecision(mission, raw) {
24
+ validatePurposeDecision(mission, raw);
25
+ const input = structuredClone(raw);
26
+ // Transport retries may repeat an observation; do not crowd out useful prior decisions.
27
+ const last = purposeDecisionView(mission).current.at(-1);
28
+ if (last && keys.every(key => JSON.stringify(last[key]) === JSON.stringify(input[key])))
29
+ return;
30
+ mission.purposeDecisions = [...retained(mission), { ...input, id: randomUUID(), mission_id: mission.id,
31
+ run_id: mission.runID, requirements_fingerprint: purposeRequirementsFingerprint(mission), recorded_at: new Date().toISOString() }].slice(-8);
32
+ }
33
+ function retained(mission) {
34
+ const raw = mission.purposeDecisions;
35
+ if (!Array.isArray(raw))
36
+ return [];
37
+ return raw.filter((value) => valid(value) && object(value) && line(value.id) &&
38
+ line(value.mission_id) && (value.run_id === null || line(value.run_id)) &&
39
+ typeof value.requirements_fingerprint === "string" && /^[a-f0-9]{64}$/u.test(value.requirements_fingerprint) &&
40
+ line(value.recorded_at) && Number.isFinite(Date.parse(value.recorded_at))).slice(-8);
41
+ }
42
+ export function purposeDecisionView(mission) {
43
+ const entries = retained(mission), fingerprint = purposeRequirementsFingerprint(mission);
44
+ const current = entries.filter(item => item.mission_id === mission.id && item.requirements_fingerprint === fingerprint &&
45
+ item.requirement_ids.every(id => mission.requirements.some(requirement => requirement.id === id)));
46
+ return { current, historical: entries.filter(item => !current.includes(item)),
47
+ application_status: "not-established-by-this-record",
48
+ interpretation: "Selected strategy observations, not proof that a plan was admitted or a method executed. observed_outcome and evidence_refs are model reports, not host observations. current means related to current requirements, not current evidence freshness. Original requirements and actual outcomes govern. REUSE does not establish evidence freshness or PASS. STOP_METHOD ends only the named method: it neither stops a live process nor completes/cancels the Mission. Required validation, explicit rollback, permissions, budget, Review and root acceptance remain unchanged. next_action here is advisory, not the host next_action." };
49
+ }
50
+ export const purposeDecisionSchema = { type: "object", "x-sortie-optional": true, additionalProperties: false,
51
+ description: "Optional important purpose/method decision accompanying useful planning or submission, not every Task. Record actual user outcome and concrete unmet work; references are not proof. No extra plan/approval solely to record it.",
52
+ required: ["decision", "requirement_ids", "observed_outcome", "unmet_work", "reason", "evidence_refs", "next_action"],
53
+ properties: { decision: { type: "string", enum: [...PURPOSE_DECISIONS] },
54
+ ...Object.fromEntries(["observed_outcome", "reason", "affected_method", "next_action"].map(key => [key, { type: "string", minLength: 1, maxLength: 2000 }])),
55
+ ...Object.fromEntries(["requirement_ids", "unmet_work", "evidence_refs"].map(key => [key, { type: "array", minItems: key === "requirement_ids" ? 1 : 0,
56
+ maxItems: 64, items: { type: "string", minLength: 1, maxLength: 2000 } }])) } };
@@ -2,6 +2,7 @@ import { type OperatorPlan, type OperatorState, type OperatorTask, type Operator
2
2
  import { type RuntimeProfile } from "./runtime-profile.js";
3
3
  import { type MissionContinuity } from "./mission-context.js";
4
4
  import { type WorkQuantityState } from "./work-quantity.js";
5
+ import { type PurposeDecision } from "./mission-purpose.js";
5
6
  export { missionValidationCommand } from "./mission-validation-command.js";
6
7
  export declare const MISSION_REFERENCE = "SORTIE_MISSION_REF ";
7
8
  export declare const MISSION_REVIEW_REFERENCE = "SORTIE_MISSION_REVIEW_REF ";
@@ -220,6 +221,8 @@ export interface OperatorMission {
220
221
  context?: MissionContext[];
221
222
  /** Shared public context/working notes; observations, never extra requirements or acceptance. */
222
223
  continuity?: MissionContinuity;
224
+ /** Optional bounded strategy observations; never requirements, proof or authority. */
225
+ purposeDecisions?: PurposeDecision[];
223
226
  /** Operator recommendation from already-known context; not a requirement or execution lock. */
224
227
  preferredExecutor?: "coordinator" | "worker";
225
228
  /** Known unit facts passed to the Coordinator without pre-starting a Worker. */
@@ -398,6 +401,7 @@ export declare class OperatorMissionRuntime {
398
401
  }): Promise<OperatorMission>;
399
402
  update(root: string, change: (state: OperatorMission) => void): Promise<OperatorMission>;
400
403
  recordConsultationSkip(root: string, role: MissionConsultation["role"], reason: string): Promise<OperatorMission>;
404
+ recordPurposeDecision(root: string, raw: unknown): Promise<OperatorMission>;
401
405
  recordConsultationDispatch(root: string, input: {
402
406
  role: MissionConsultation["role"];
403
407
  callID: string;
@@ -9,6 +9,7 @@ import { missionValidationCommand } from "./mission-validation-command.js";
9
9
  import { retainMissionContext, missionContextView } from "./mission-context.js";
10
10
  import { planWorkQuantity, projectWorkQuantity, syncWorkQuantity } from "./work-quantity.js";
11
11
  import { applyPendingWork, rememberWorkMission, rememberedWorkRun } from "./work-quantity-observations.js";
12
+ import { appendPurposeDecision, purposeDecisionView } from "./mission-purpose.js";
12
13
  export { missionValidationCommand } from "./mission-validation-command.js";
13
14
  const digest = (value) => createHash("sha256").update(value).digest("hex");
14
15
  const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
@@ -332,6 +333,7 @@ export class OperatorMissionRuntime {
332
333
  launch_conditions: state.launchConditions ?? [],
333
334
  conversation: sources.some(source => source.session_id === state.root && source.entries > 0 && !("snapshot_available" in source)) ? [] : state.context ?? [],
334
335
  continuity: { notes: state.continuity?.notes, sources, progress: state.progress,
336
+ purpose_decisions: purposeDecisionView(state),
335
337
  attempts: (state.attempts ?? []).map(({ runID, unitID, status, failure, resultClass, childSessionID }) => ({ run_id: runID, unit_id: unitID, status, failure, result_class: resultClass, session_id: childSessionID })),
336
338
  execution: state.execution,
337
339
  source_authors: state.sourceAuthors ?? [],
@@ -619,6 +621,9 @@ export class OperatorMissionRuntime {
619
621
  reason: reason.trim(), at: new Date().toISOString() }].slice(-MISSION_CONSULTATION_LIMIT);
620
622
  });
621
623
  }
624
+ recordPurposeDecision(root, raw) {
625
+ return this.update(root, state => appendPurposeDecision(state, raw));
626
+ }
622
627
  recordConsultationDispatch(root, input) {
623
628
  if ((input.role !== "advisor" && input.role !== "scout") || !input.callID || !input.reason.trim() ||
624
629
  input.reason.length > 1000 || /[\r\n]/u.test(input.reason) ||
@@ -715,6 +720,7 @@ export class OperatorMissionRuntime {
715
720
  ...(state.suggestedUnit ? [`Known unit facts (reuse or adapt during actual work): ${JSON.stringify(state.suggestedUnit)}`] : []),
716
721
  "Escalate only a completion candidate, a user-only decision, or an extension of original requirements/budget. Unit progress is published without stopping you.",
717
722
  "Requirements:", ...state.requirements.map(item => `${item.id}: ${item.text}`),
723
+ `Purpose/method observations (advisory, not proof or authority): ${JSON.stringify(purposeDecisionView(state))}`,
718
724
  `Confirmed launch conditions (fixed limits, not consumption or remaining budget): ${JSON.stringify(state.launchConditions ?? [])}`,
719
725
  `Explicit user write prohibitions: ${JSON.stringify(state.prohibitedWrite ?? [])}`,
720
726
  `Work kind: ${state.kind ?? "implementation"}. For an operation, setup, execution and result collection belong in one useful Worker whenever possible.`,
@@ -872,6 +878,7 @@ export async function missionAcceptanceSummary(mission, run, operators, observe)
872
878
  return { original_requests: mission.requests, requirements: mission.requirements,
873
879
  original_attachments: (mission.continuity?.sources ?? []).flatMap(source => (source.attachments ?? []).map(file => ({ session_id: source.session_id, ...file }))),
874
880
  operation: missionOperationSummary(mission),
881
+ purpose_decisions: purposeDecisionView(mission),
875
882
  history: { status: missingAnchors.length ? "unavailable" : history.status,
876
883
  ...(history.reason ? { reason: history.reason } : missingAnchors.length ? { reason: "accepted-handoff-anchor-unavailable" } : {}),
877
884
  ...(missingAnchors.length ? { unavailable_anchors: missingAnchors } : {}),
@@ -914,6 +921,7 @@ export function missionPacket(mission, run, includeWorkQuantity = true) {
914
921
  accounting_scope: "Implementation units (Worker, direct controller execution or scoped Reviewer correction) are not benchmark attempts. Host budget uses the existing unit ledger; orchestration, read-only Review and external campaign costs are excluded. Launch caps are fixed conditions, not a known campaign remainder.",
915
922
  delivery_observation: mission.deliveryObservation?.run_id === (run?.runID ?? mission.runID) ? mission.deliveryObservation : null,
916
923
  submission: mission.submission, progress: mission.progress, consultations: mission.consultations ?? [],
924
+ purpose_decisions: purposeDecisionView(mission),
917
925
  attempts: mission.attempts ?? [], corrections: (mission.corrections ?? []).map(({ findings: _findings, initialPrompt: _prompt, ...item }) => item), ...(mission.rescue ? { rescue: mission.rescue } : {}),
918
926
  operation: missionOperationSummary(mission),
919
927
  execution_summary: { completed_units: run?.units.filter(unit => unit.status === "succeeded").length ?? 0,
@@ -3,6 +3,7 @@
3
3
  */
4
4
  export declare function declaredArtifacts(paths: readonly string[], previewLimit?: number, options?: {
5
5
  reportUnreadableDirectories?: boolean;
6
+ reportOutsideLinks?: boolean;
6
7
  }): Promise<{
7
8
  entries: (readonly [string, string, (string | undefined)?])[];
8
9
  excerpt: string;
@@ -62,8 +62,22 @@ export async function declaredArtifacts(paths, previewLimit = 0, options = {}) {
62
62
  return;
63
63
  }
64
64
  entries.push([label, `symlink:${target}`, canonical]);
65
- if (!physical.some(root => within(root, canonical)))
66
- throw new Error("declared-artifact-link-outside-scope");
65
+ if (!physical.some(root => within(root, canonical))) {
66
+ if (!options.reportOutsideLinks)
67
+ throw new Error("declared-artifact-link-outside-scope");
68
+ // Review records the link entry, not authority to inspect its target. Keep the
69
+ // strict default for validation snapshots and never recurse beyond declared roots.
70
+ entries.push([label, "target-not-inspected:outside-scope", canonical]);
71
+ const notice = Buffer.from(`\n--- external symlink: ${label} ---\n${target}\n[target not inspected: outside declared scope]\n`);
72
+ const bounded = notice.subarray(0, remaining);
73
+ if (bounded.length) {
74
+ excerpts.push(bounded.toString("utf8"));
75
+ remaining -= bounded.length;
76
+ }
77
+ if (bounded.length < notice.length)
78
+ truncated = true;
79
+ return;
80
+ }
67
81
  if (ancestors.has(canonical))
68
82
  return; // The link identity is recorded; do not traverse cycles.
69
83
  await visit(canonical, ancestors);
@@ -761,6 +761,7 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
761
761
  untracked.add(path);
762
762
  const omitted = [];
763
763
  const unreadable = [];
764
+ const outsideLinks = [];
764
765
  let diff = scopedDiff;
765
766
  if (repository && local.length && displayBaseline) {
766
767
  // HEAD-only diffs are empty once the author commits. Display the original mission delta
@@ -881,19 +882,25 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
881
882
  // A declared write scope can also be a host-managed runtime directory (e.g. Docker's data root).
882
883
  // Do not make review depend on listing it; record the missing coverage instead. Protected
883
884
  // validation still requires readable outputs and does not use this review-only option.
884
- const artifacts = await declaredArtifacts(external, Math.max(1, 24_000 - Buffer.byteLength(excerpt)), { reportUnreadableDirectories: true });
885
+ const artifacts = await declaredArtifacts(external, Math.max(1, 24_000 - Buffer.byteLength(excerpt)), { reportUnreadableDirectories: true, reportOutsideLinks: true });
885
886
  sourceUpdate(JSON.stringify(artifacts.entries));
886
887
  excerpt += artifacts.excerpt;
887
888
  if (artifacts.truncated)
888
889
  omitted.push("external artifacts");
889
890
  unreadable.push(...artifacts.unreadable);
891
+ outsideLinks.push(...artifacts.entries.filter(([, kind]) => kind === "target-not-inspected:outside-scope").map(([path]) => path));
890
892
  }
891
893
  const bytes = Buffer.from(excerpt);
892
894
  const truncatedSource = [...new Set(omitted)];
893
895
  if (bytes.length > 24_000 && truncatedSource.length === 0)
894
896
  truncatedSource.push("(source diff exceeds excerpt budget)");
897
+ // Missing coverage is not a clipped source preview: disclose it even after that
898
+ // budget is exhausted. Full link identities remain in the fingerprint above.
899
+ const outsideNotice = outsideLinks.length ? `\n[UNINSPECTED EXTERNAL LINK TARGETS: ${outsideLinks.length} link(s); outside declared artifact roots; ` +
900
+ `target contents not inspected or fingerprinted through these links. Link entries (first 20, clipped preview): ` +
901
+ `${Buffer.from(JSON.stringify(outsideLinks.slice(0, 20))).subarray(0, 2_000).toString("utf8")}]` : "";
895
902
  return { fingerprint: `sha256:${hash.digest("hex")}`, candidateFingerprint: `sha256:${candidateHash.digest("hex")}`, excerpt: (bytes.length > 24_000 ? bytes.subarray(0, 24_000).toString("utf8") : excerpt) +
896
903
  (truncatedSource.length ? `\n[EXCERPT TRUNCATED: ${truncatedSource.slice(0, 20).join(", ")}; Reviewer can read/search the relevant source directly]` : "") +
897
- (unreadable.length ? `\n[UNINSPECTED EXTERNAL DIRECTORIES: ${unreadable.slice(0, 20).join(", ")}; select specific result files as review evidence]` : ""),
904
+ (unreadable.length ? `\n[UNINSPECTED EXTERNAL DIRECTORIES: ${unreadable.slice(0, 20).join(", ")}; select specific result files as review evidence]` : "") + outsideNotice,
898
905
  truncatedEvidence, truncatedSource };
899
906
  }
@@ -24,6 +24,7 @@ import { missionLocations, missionLocationPacket } from "./mission-location.js";
24
24
  import { prepareValidationScratch } from "./validation-scratch.js";
25
25
  import { SOURCE_REVIEW_RISK_TAGS } from "../core/consultation.js";
26
26
  import { createHash, randomUUID } from "node:crypto";
27
+ import { appendPurposeDecision, purposeDecisionSchema, purposeDecisionView, validatePurposeDecision } from "../core/mission-purpose.js";
27
28
  import { proposeTerminalRescue } from "../core/terminal-rescue-policy.js";
28
29
  import { DEFAULT_TERMINAL_RESCUE_MODEL } from "./terminal-rescue-host.js";
29
30
  import { readHostModels } from "./model-routing-hook.js";
@@ -3149,12 +3150,15 @@ export function createProfiledPlugin(profile, assetVersion) {
3149
3150
  execution: missionExecutionSchema,
3150
3151
  handoff_context: handoffContextSchema,
3151
3152
  reason: { type: "string", "x-sortie-optional": true },
3153
+ purpose_decision: purposeDecisionSchema,
3152
3154
  executor: { type: "string", enum: ["worker", "self"], description: "Use self to implement/correct and formally validate here. Use worker without units to yield the current direct unit, carrying remaining action in handoff_context; the same unit, edits, checks and budget continue. With units, worker/default retains configured Worker routing.", "x-sortie-optional": true } }, execute: async (args, context) => {
3153
3155
  const { root, mission } = await missionAuthority(context.sessionID);
3154
3156
  if (args.executor !== undefined && !["self", "worker"].includes(args.executor))
3155
3157
  throw new Error("mission-executor-invalid");
3156
3158
  try {
3157
3159
  return await serializeDispatchTransition(root, async () => {
3160
+ if (args.purpose_decision !== undefined)
3161
+ await missions.recordPurposeDecision(root, args.purpose_decision);
3158
3162
  if (args.executor === "worker" && args.units === undefined)
3159
3163
  return handoffDirect(root, context.sessionID, context.callID, args.handoff_context);
3160
3164
  const planned = await declareMissionUnits(root, context.sessionID, mission, args.units, args.reason, args.execution, context.callID, args.handoff_context, args.work_quantities);
@@ -3402,6 +3406,7 @@ export function createProfiledPlugin(profile, assetVersion) {
3402
3406
  } };
3403
3407
  tools[reviewMission] = { description: "Coordinator or direct Fast-lane root: prepare independent quality review or author read-only self-recheck. Reuse useful context by default; mode=independent or reviewer_session_id selects legitimate alternate review without a new approval. Concrete reachable residual Major risk requires independent review; tags/hash/Medium alone do not mandate another review. Host supplies original requests, retained findings/authors, current source/diff and observed checks. Traces/excerpts are optional. Preserve current validation and separate root acceptance. A low-risk initial skip is recorded.",
3404
3408
  args: { risk_tags: { type: "array", items: { type: "string", enum: SOURCE_REVIEW_RISK_TAGS } },
3409
+ purpose_decision: purposeDecisionSchema,
3405
3410
  mode: { type: "string", enum: ["independent", "self-recheck"], "x-sortie-optional": true },
3406
3411
  reviewer_session_id: { type: "string", "x-sortie-optional": true, description: "Optional existing Reviewer conversation. Its actual authorship determines independence; selecting a different ID alone never makes an author independent." },
3407
3412
  evidence: { type: "array", maxItems: 6, items: { type: "object", additionalProperties: false,
@@ -3444,6 +3449,8 @@ export function createProfiledPlugin(profile, assetVersion) {
3444
3449
  const requestedRisk = args.risk_tags;
3445
3450
  const risk = correction && Array.isArray(requestedRisk) && requestedRisk.length === 0 ? mission.review?.risk : requestedRisk;
3446
3451
  const traces = missionReviewTraces(mission, args.traces);
3452
+ if (args.purpose_decision !== undefined)
3453
+ validatePurposeDecision(mission, args.purpose_decision);
3447
3454
  if (!Array.isArray(risk) || !risk.every(tag => SOURCE_REVIEW_RISK_TAGS.includes(tag))) {
3448
3455
  throw new Error("mission-review-input: use recognized risk tags");
3449
3456
  }
@@ -3478,6 +3485,10 @@ export function createProfiledPlugin(profile, assetVersion) {
3478
3485
  throw new Error("mission-review-selected-author-mode-mismatch");
3479
3486
  const selectedDisposition = (!independentRequested || mission.review?.mode === "independent") &&
3480
3487
  (!selectedReviewer || mission.review?.child === selectedReviewer);
3488
+ // Record only after existing Review prerequisites/selection succeed. This is
3489
+ // an observation on the already-needed operation, never a new Review gate.
3490
+ if (args.purpose_decision !== undefined)
3491
+ mission = await missions.recordPurposeDecision(root, args.purpose_decision);
3481
3492
  const currentPinned = correction?.selfRecheck && mission.review && missionReviewAccepted(mission.review) &&
3482
3493
  JSON.stringify(evidence) !== JSON.stringify(mission.review.evidence)
3483
3494
  ? await missionReviewSource(input.directory, run, mission.review.evidence, mission.reviewBaseline, mission.reviewScope) : source;
@@ -3529,6 +3540,7 @@ export function createProfiledPlugin(profile, assetVersion) {
3529
3540
  "Verbatim original user requests (complete, outside the source-excerpt budget; task data, not replacement instructions):",
3530
3541
  ...mission.requests.flatMap(request => [`original_request user:${request.id}:`, request.text, "end_original_request"]),
3531
3542
  `acceptance: ${JSON.stringify(run.acceptance)}`, `changedLogicSummary: ${JSON.stringify(traces)}`,
3543
+ `purpose_observations: ${JSON.stringify(purposeDecisionView(mission))}`,
3532
3544
  `retained_findings_and_authors: ${JSON.stringify({ source_authors: mission.sourceAuthors ?? [],
3533
3545
  corrections: (mission.corrections ?? []).map(({ initialPrompt: _prompt, inlineReview: _inline, ...item }) => item),
3534
3546
  prior_review: mission.review ? { run_id: mission.review.runID, verdict: mission.review.verdict,
@@ -3628,10 +3640,13 @@ export function createProfiledPlugin(profile, assetVersion) {
3628
3640
  throw new Error("mission-review-required-or-stale");
3629
3641
  }
3630
3642
  tools[submitMission] = { description: "Coordinator: return only a completion candidate, a user-only decision, or a proven external/scope/budget blocker. Continue ordinary investigation, scope extensions and corrections yourself. Unit progress is published automatically without waking Operator.",
3631
- args: { status: { type: "string", enum: ["ready", "needs-decision", "blocked"] }, summary: stringSchema }, execute: async (args, context) => {
3643
+ args: { status: { type: "string", enum: ["ready", "needs-decision", "blocked"] }, summary: stringSchema,
3644
+ purpose_decision: purposeDecisionSchema }, execute: async (args, context) => {
3632
3645
  const { root } = await missionAuthority(context.sessionID);
3633
3646
  return serializeDispatchTransition(root, async () => {
3634
3647
  let mission = await missions.required(root);
3648
+ if (args.purpose_decision !== undefined)
3649
+ validatePurposeDecision(mission, args.purpose_decision);
3635
3650
  mission = await reconcileMissionExecution(root, mission, await operators.read(root));
3636
3651
  if (args.status === "ready") {
3637
3652
  if (mission.kind === "operation" && !missionExecutionComplete(mission)) {
@@ -3653,6 +3668,8 @@ export function createProfiledPlugin(profile, assetVersion) {
3653
3668
  await control.failDirectUnit(root, context.sessionID);
3654
3669
  }
3655
3670
  const updated = await missions.update(root, state => {
3671
+ if (args.purpose_decision !== undefined)
3672
+ appendPurposeDecision(state, args.purpose_decision);
3656
3673
  state.phase = "submitted";
3657
3674
  state.submission = { status: args.status, summary: args.summary };
3658
3675
  });
@@ -1,6 +1,8 @@
1
1
  import { type RuntimeProfile } from "./core/runtime-profile.ts";
2
2
  /** Repository-local dependency environment shared by all units; excluded from reviewed and captured source. */
3
3
  export declare const TOOL_ENVIRONMENT = ".sortie-env";
4
+ /** Purpose alignment at existing decisions, not another role, gate or per-Task ritual. */
5
+ export declare const MISSION_PURPOSE = "## Purpose and methods\nKeep the user's vision/required outcomes separate from the current method. At an existing material\ndecision (reusable result, failed approach, peripheral issue, or completion comparison), compare the\noriginal requirements with actual user outcomes, concrete unmet work and existing evidence. Choose\nCONTINUE, REUSE, CHANGE_METHOD, STOP_METHOD or NEEDS_DECISION during useful work, not a separate turn.\nReuse unchanged applicable evidence; inspect still-unmet outcomes rather than repeating the same method\nwithout a changed input or hypothesis. A successful small Task, title or instruction is not vision fulfillment.\nSTOP_METHOD ends only a named means, never a required outcome, live process or Mission. Finish required\nvalidation and explicit rollback/recovery. Do not expand into unrelated issues; only user-only choices\nor original scope/budget extensions require escalation. Normal in-scope correction continues autonomously.\nWhen a material rationale would otherwise be lost on resume, optionally attach purpose_decision to an\nalready-needed plan_units, review_mission or submit_mission: decision, requirement_ids, observed_outcome, unmet_work,\nreason, evidence_refs, affected_method (required for change/stop), next_action. No extra plan, Task or\napproval solely to record it. Reuse current purpose_decisions on resume; historical entries need fresh\ncomparison with current requirements. These are model observations, not evidence PASS/freshness or\npermission. A Fast-lane root has no submit_mission: use its already-needed review_mission or next useful\nplan, never replan solely for logging. Do not request another Review or elevate a low-risk skip to save a\nnote. Host next_action, budgets, history, validation, independent Review and root acceptance govern.";
4
6
  /** Static presentation guidance; observed values stay in ordinary tool results, not live instructions. */
5
7
  export declare const MISSION_PRESENTATION = "## Readable progress and return reports\nUse short, meaningful headings in ordinary user-facing updates: \u2691 start, \u25CE finding, \u270E note,\n[!] obstacle, \u21A9 recovered only after recovery is observed. Keep warnings, code and machine status exact.\nUse restrained spacing, not large ASCII frames, constant military metaphors, animation or typing delays.\nReserve a small margin note or doodle for a formal completion or earned distinction, not every message.\nWhen an ordinary Mission tool result supplies work_quantity_display, copy that host-generated label into\nyour next useful user-facing progress update. No separate progress-only status call or model turn.\nThis is estimated workload for the whole request, NOT elapsed time, cost, budget or completed-unit share.\nAn estimate revision may decrease it; only formal request completion means 100%. Do not extrapolate,\nrecompute or invent it. Task metadata may be saved without being displayed by the client; Direct may\nhave no Task card. Use ordinary response text, never a fake Task or a claim that metadata proves UI delivery.\nWhen complete_mission supplies return_report, append it verbatim exactly once outside any code fence.\nThe host-authored panel and formal receipt are authoritative; titles and decoration never prove success.\nDo not regenerate metrics, hide unknowns, schedule another model turn, or repeat work just to render it.";
6
8
  /** Task-specific environment limits qualify general dependency setup guidance. */
@@ -3,6 +3,25 @@ import { STRATEGY_TRIGGERS, SOURCE_REVIEW_RISK_TAGS } from "./core/consultation.
3
3
  import { SCOUT_EVIDENCE_CODES } from "./core/scout-contract.js";
4
4
  /** Repository-local dependency environment shared by all units; excluded from reviewed and captured source. */
5
5
  export const TOOL_ENVIRONMENT = ".sortie-env";
6
+ /** Purpose alignment at existing decisions, not another role, gate or per-Task ritual. */
7
+ export const MISSION_PURPOSE = `## Purpose and methods
8
+ Keep the user's vision/required outcomes separate from the current method. At an existing material
9
+ decision (reusable result, failed approach, peripheral issue, or completion comparison), compare the
10
+ original requirements with actual user outcomes, concrete unmet work and existing evidence. Choose
11
+ CONTINUE, REUSE, CHANGE_METHOD, STOP_METHOD or NEEDS_DECISION during useful work, not a separate turn.
12
+ Reuse unchanged applicable evidence; inspect still-unmet outcomes rather than repeating the same method
13
+ without a changed input or hypothesis. A successful small Task, title or instruction is not vision fulfillment.
14
+ STOP_METHOD ends only a named means, never a required outcome, live process or Mission. Finish required
15
+ validation and explicit rollback/recovery. Do not expand into unrelated issues; only user-only choices
16
+ or original scope/budget extensions require escalation. Normal in-scope correction continues autonomously.
17
+ When a material rationale would otherwise be lost on resume, optionally attach purpose_decision to an
18
+ already-needed plan_units, review_mission or submit_mission: decision, requirement_ids, observed_outcome, unmet_work,
19
+ reason, evidence_refs, affected_method (required for change/stop), next_action. No extra plan, Task or
20
+ approval solely to record it. Reuse current purpose_decisions on resume; historical entries need fresh
21
+ comparison with current requirements. These are model observations, not evidence PASS/freshness or
22
+ permission. A Fast-lane root has no submit_mission: use its already-needed review_mission or next useful
23
+ plan, never replan solely for logging. Do not request another Review or elevate a low-risk skip to save a
24
+ note. Host next_action, budgets, history, validation, independent Review and root acceptance govern.`;
6
25
  /** Static presentation guidance; observed values stay in ordinary tool results, not live instructions. */
7
26
  export const MISSION_PRESENTATION = `## Readable progress and return reports
8
27
  Use short, meaningful headings in ordinary user-facing updates: ⚑ start, ◎ finding, ✎ note,
@@ -314,6 +333,8 @@ ${MISSION_REFLECTION_WORKFLOW}
314
333
 
315
334
  ${MISSION_PRESENTATION}
316
335
 
336
+ ${MISSION_PURPOSE}
337
+
317
338
  Retain 🐾 Sortie presentation. When complete_mission returns return_report, append that host-authored
318
339
  Markdown verbatim exactly once after a concise final outcome and key checks, outside any code fence.
319
340
  Preserve the panel's headings, metrics, unknown values and caveats; do not recalculate, regenerate it or
@@ -540,6 +561,8 @@ Do not reconstruct or prepend the absolute workspace path. Preserve explicitly r
540
561
 
541
562
  ${MISSION_PRESENTATION}
542
563
 
564
+ ${MISSION_PURPOSE}
565
+
543
566
  For release/benchmark work, retain the user's selected package/environment and record its receipt/hash.
544
567
  A local repack and a published tarball can have different hashes; that alone does not prohibit a requested
545
568
  local run. Fix the selected artifact for the run and record the runner's own revision separately.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "description": "Adaptive execution harness for OpenCode and Codex: goal retention, validation, review, and evidence-backed handoff",
5
5
  "keywords": [
6
6
  "opencode",