sortie-dogs 0.15.1 → 0.15.3

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
- **Release candidate: v0.15.0** · [Release notes](docs/release-v0.15.0.md)
23
- · [Current published release: v0.14.9](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.14.9)
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.14.9
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.14.9
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 the v0.15.0 candidate 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 candidate Mission asset marker is `0.15.0-project-memory-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,7 +300,8 @@ 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
- - **Releases:** [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) ·
303
+ - **Current release:** [v0.15.3](docs/release-v0.15.3.md).
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
 
306
307
  ## Support
@@ -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.1-dangling-review-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.15.3-external-review-links-v1";
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.1-dangling-review-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.15.3-external-review-links-v1";
7
7
  export const CODEX_SKILL_ASSET_VERSION = "0.14.6-codex-skill-v1";
@@ -1,3 +1,4 @@
1
+ import { type RareAwards } from "./rare-awards.ts";
1
2
  /** Optional, bounded telemetry retained with the existing terminal goal ledger, never an authority input. */
2
3
  export interface GoalReport {
3
4
  readonly definition: "pre-terminal-host-tokens/v1";
@@ -14,5 +15,6 @@ export interface GoalReport {
14
15
  readonly worker_ms: number;
15
16
  readonly wall_ms: number;
16
17
  };
18
+ readonly rare?: RareAwards;
17
19
  }
18
20
  export declare function validGoalReport(value: unknown): value is GoalReport;
@@ -1,3 +1,4 @@
1
+ import { validRareAwards } from "./rare-awards.js";
1
2
  export function validGoalReport(value) {
2
3
  if (value === null || typeof value !== "object")
3
4
  return false;
@@ -5,11 +6,12 @@ export function validGoalReport(value) {
5
6
  const amount = (n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
6
7
  const duration = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
7
8
  const overlap = item.overlap;
8
- return Object.keys(item).every((key) => ["definition", "terminal_key", "tokens", "models", "first_pass_eligible", "traits", "overlap"].includes(key)) &&
9
+ return Object.keys(item).every((key) => ["definition", "terminal_key", "tokens", "models", "first_pass_eligible", "traits", "overlap", "rare"].includes(key)) &&
9
10
  item.definition === "pre-terminal-host-tokens/v1" && /^sha256:[a-f0-9]{64}$/u.test(item.terminal_key) &&
10
11
  (item.tokens === null || amount(item.tokens)) && typeof item.first_pass_eligible === "boolean" &&
11
12
  Array.isArray(item.traits) && item.traits.length <= 3 && new Set(item.traits).size === item.traits.length &&
12
13
  item.traits.every((trait) => ["pack-tactics", "recovery", "clean-sweep"].includes(trait)) &&
14
+ (item.rare === undefined || validRareAwards(item.rare)) &&
13
15
  (overlap === undefined || overlap !== null && typeof overlap === "object" &&
14
16
  Object.keys(overlap).every((key) => ["definition", "worker_ms", "wall_ms"].includes(key)) && overlap.definition === "worker-span-union/v1" &&
15
17
  duration(overlap.worker_ms) && duration(overlap.wall_ms) && overlap.worker_ms >= overlap.wall_ms) &&
@@ -0,0 +1,26 @@
1
+ /** Presentation telemetry only. Never an execution, review, or acceptance input. */
2
+ export type ConditionState = "true" | "false" | "unknown";
3
+ export type RareStatus = "earned" | "not-earned" | "unknown";
4
+ export interface RareEvaluation {
5
+ readonly id: string;
6
+ readonly rule_version: number;
7
+ readonly status: RareStatus;
8
+ readonly evidence: readonly string[];
9
+ }
10
+ export interface RareAwards {
11
+ readonly definition: string;
12
+ readonly evaluations: readonly RareEvaluation[];
13
+ }
14
+ export declare const RARE_RULES: readonly [{
15
+ readonly id: "rare.clean-sweep";
16
+ readonly rule_version: 1;
17
+ }, {
18
+ readonly id: "rare.recovery-coordination";
19
+ readonly rule_version: 1;
20
+ }];
21
+ /** False dominates unknown for an AND; absence of a positive trait is not a negative proof. */
22
+ export declare function evaluateRareAwards(succeeded: boolean, clean: ConditionState, recovery: ConditionState, coordination: ConditionState): RareAwards;
23
+ /** Bounded forward-compatible envelope: unknown definitions/rules remain telemetry, never awards. */
24
+ export declare function validRareAwards(value: unknown): value is RareAwards;
25
+ /** Recognize only this version and its host evidence contract, not a future rule's success string. */
26
+ export declare function rareEvaluation(rare: RareAwards | undefined, id: string): RareEvaluation | undefined;
@@ -0,0 +1,53 @@
1
+ export const RARE_RULES = [
2
+ { id: "rare.clean-sweep", rule_version: 1 },
3
+ { id: "rare.recovery-coordination", rule_version: 1 },
4
+ ];
5
+ /** False dominates unknown for an AND; absence of a positive trait is not a negative proof. */
6
+ export function evaluateRareAwards(succeeded, clean, recovery, coordination) {
7
+ const status = (conditions) => !succeeded || conditions.includes("false")
8
+ ? "not-earned" : conditions.includes("unknown") ? "unknown" : "earned";
9
+ return { definition: "rare-awards/v1", evaluations: [
10
+ { id: RARE_RULES[0].id, rule_version: 1, status: status([clean]),
11
+ evidence: [succeeded ? "receipt.succeeded" : "receipt.not-succeeded", `clean-sweep.${clean}`] },
12
+ { id: RARE_RULES[1].id, rule_version: 1, status: status([recovery, coordination]),
13
+ evidence: [succeeded ? "receipt.succeeded" : "receipt.not-succeeded", `recovery.${recovery}`, `coordination.${coordination}`] },
14
+ ] };
15
+ }
16
+ /** Bounded forward-compatible envelope: unknown definitions/rules remain telemetry, never awards. */
17
+ export function validRareAwards(value) {
18
+ if (value === null || typeof value !== "object")
19
+ return false;
20
+ const rare = value;
21
+ const code = (v) => typeof v === "string" && /^[a-zA-Z0-9][a-zA-Z0-9./_-]{0,95}$/u.test(v);
22
+ return Object.keys(rare).every(key => ["definition", "evaluations"].includes(key)) && code(rare.definition) &&
23
+ Array.isArray(rare.evaluations) && rare.evaluations.length <= 16 &&
24
+ new Set(rare.evaluations.map(e => e && `${e.id}/${e.rule_version}`)).size === rare.evaluations.length &&
25
+ rare.evaluations.every(e => e !== null && typeof e === "object" &&
26
+ Object.keys(e).every(key => ["id", "rule_version", "status", "evidence"].includes(key)) && code(e.id) &&
27
+ Number.isSafeInteger(e.rule_version) && e.rule_version > 0 && ["earned", "not-earned", "unknown"].includes(e.status) &&
28
+ Array.isArray(e.evidence) && e.evidence.length <= 8 && new Set(e.evidence).size === e.evidence.length && e.evidence.every(code));
29
+ }
30
+ /** Recognize only this version and its host evidence contract, not a future rule's success string. */
31
+ export function rareEvaluation(rare, id) {
32
+ if (rare?.definition !== "rare-awards/v1")
33
+ return undefined;
34
+ const rule = RARE_RULES.find(rule => rule.id === id);
35
+ const evaluation = rare.evaluations.find(e => e.id === id && e.rule_version === rule?.rule_version);
36
+ if (evaluation === undefined)
37
+ return undefined;
38
+ const codes = evaluation.evidence;
39
+ const state = (prefix) => {
40
+ const values = ["true", "false", "unknown"].filter(s => codes.includes(`${prefix}.${s}`));
41
+ return values.length === 1 ? values[0] : undefined;
42
+ };
43
+ const succeeded = codes.includes("receipt.succeeded");
44
+ if (succeeded === codes.includes("receipt.not-succeeded"))
45
+ return undefined;
46
+ const clean = state("clean-sweep"), recovery = state("recovery"), coordination = state("coordination");
47
+ if (id === RARE_RULES[0].id ? clean === undefined : recovery === undefined || coordination === undefined)
48
+ return undefined;
49
+ const expected = evaluateRareAwards(succeeded, clean ?? "unknown", recovery ?? "unknown", coordination ?? "unknown")
50
+ .evaluations.find(e => e.id === id);
51
+ return expected.status === evaluation.status && expected.evidence.length === codes.length &&
52
+ expected.evidence.every(code => codes.includes(code)) ? evaluation : undefined;
53
+ }
@@ -495,5 +495,5 @@ export function projectWorkQuantity(state, completed = false, cancelled = false)
495
495
  remaining_kinds: accepted ? [] : state.remainingKinds };
496
496
  }
497
497
  export function formatWorkQuantity(progress) {
498
- return `全体作業量 ${progress.n}/100(概算)${progress.estimate_revision > 1 ? " · 見積更新" : ""}`;
498
+ return `全体作業量 ${progress.n}%(概算)${progress.estimate_revision > 1 ? " · 見積更新" : ""}`;
499
499
  }
@@ -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);
@@ -48,7 +48,7 @@ import { TerminalRescueRuntime } from "../core/terminal-rescue-runtime.js";
48
48
  import { OpenCodeTerminalRescueHost } from "./terminal-rescue-host.js";
49
49
  import { AdaptiveRemediationRuntime, AdaptiveRunFlightLineage } from "../core/adaptive-remediation-runtime.js";
50
50
  import { DEFAULT_ADAPTIVE_REMEDIATION_MODEL, GitAdaptiveRemediationHost, OpenCodeAdaptiveRemediationProvider } from "./adaptive-remediation-host.js";
51
- import { collectRunMetrics, createSortieResult, createGoalReport, insertRunMetrics, insertSortieResult, replaceDoneTerminalStatus, replaceTerminalStatus, sanitizeTerminalReport, terminalRunOutcome } from "./run-metrics.js";
51
+ import { collectRunMetrics, createSortieResult, createGoalReport, withSavedGoalReport, insertRunMetrics, insertSortieResult, replaceDoneTerminalStatus, replaceTerminalStatus, sanitizeTerminalReport, terminalRunOutcome } from "./run-metrics.js";
52
52
  import { STABLE_RUNTIME_PROFILE } from "../core/runtime-profile.js";
53
53
  import { reviewerCorrectionValidationFresh } from "./mission-review.js";
54
54
  import { evidenceFromObservedExecution } from "../core/observed-goal-evidence.js";
@@ -6474,8 +6474,10 @@ export const SortieDogsPlugin = async (input, options) => {
6474
6474
  let records = terminal.records;
6475
6475
  if (!records.some(({ event }) => event.kind === "goal.reported" && event.report?.terminal_key === report.terminal_key)) {
6476
6476
  await ledger.appendGoal({ kind: "goal.reported", at: new Date().toISOString(), goal_id: terminal.receipt.goal_id, report });
6477
- records = (await ledger.readGoal()).records;
6478
6477
  }
6478
+ // appendGoal serializes competing deliveries and retains the first report for this receipt.
6479
+ records = (await ledger.readGoal()).records;
6480
+ sortieResult = withSavedGoalReport(sortieResult, terminal.receipt, records);
6479
6481
  const currentPath = goalLedgerFiles.get(goalRoot(textInput.sessionID));
6480
6482
  if (currentPath !== undefined) {
6481
6483
  const { collectCareer } = await import("./sortie-career.js");
@@ -7956,8 +7958,9 @@ export const SortieDogsPlugin = async (input, options) => {
7956
7958
  const report = createGoalReport(result, receipt);
7957
7959
  if (!records.some(({ event }) => event.kind === "goal.reported" && event.report.terminal_key === report.terminal_key)) {
7958
7960
  await ledger.appendGoal({ kind: "goal.reported", at: new Date().toISOString(), goal_id: receipt.goal_id, report });
7959
- records = (await ledger.readGoal()).records;
7960
7961
  }
7962
+ records = (await ledger.readGoal()).records;
7963
+ result = withSavedGoalReport(result, receipt, records);
7961
7964
  const currentPath = goalLedgerFiles.get(goalRoot(root));
7962
7965
  if (currentPath) {
7963
7966
  const { collectCareer } = await import("./sortie-career.js");
@@ -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
  }
@@ -10,6 +10,7 @@ import { readFile, realpath } from "node:fs/promises";
10
10
  import { BUILT_IN_MODEL_CATALOG } from "./model-routing.js";
11
11
  import { goalFingerprint } from "../core/goal-bound.js";
12
12
  import { decoratePreviewHeadings, returnReportPanel } from "./receipt-presentation.js";
13
+ import { reportText, resolveReportLocale } from "./report-locale.js";
13
14
  import { sanitizeTerminalReport, terminalRunOutcome } from "./run-metrics.js";
14
15
  import { normalizeCommand, canonicalObservedValidationMembers, extractWritePaths, isShellTool } from "./gate.js";
15
16
  import { normalizeExecutionScope, normalizeManifestScope, normalizeRelativePath } from "../core/path.js";
@@ -1912,16 +1913,17 @@ export function createProfiledPlugin(profile, assetVersion) {
1912
1913
  }
1913
1914
  let panel;
1914
1915
  if (input.returnReportTransport === "tool-result" && result.receipt?.status === "succeeded") {
1916
+ const t = reportText(resolveReportLocale());
1915
1917
  const reviewGaps = missionReportReview(mission, state.runID) === "evidence-gaps"
1916
- ? mission.review.result?.trim() || "独立Reviewの未解決証拠あり" : undefined;
1918
+ ? mission.review.result?.trim() || t("独立Reviewの未解決証拠あり", "Independent Review has unconfirmed evidence") : undefined;
1917
1919
  const gapSummary = reviewGaps?.replace(/^EVIDENCE_GAPS\s*/u, "").replace(/\s+/gu, " ").slice(0, 500);
1918
1920
  const text = withMissionOperationOutcome(`✅ **DONE** \`${state.runID}\` — declared checks passed; Operator accepted the result.`, mission) + `\n\n` +
1919
- `**変更点:** ${state.units.map(unit => unit.unit.title).join("; ")}\n\n` +
1920
- `**確認結果:** 宣言検証合格 — ${[...new Set(state.units.flatMap(unit => unit.unit.validation))].join("; ")}` +
1921
- (mission?.review ? `\nレビュー: ${mission.review.verdict === "self-rechecked" ? "修正著者の自己再確認(独立PASSではない)" : mission.review.verdict === "evidence-gaps" ? "証拠不足を残して受入れ(レビューPASSではない)" : mission.review.verdict}.` : "") +
1921
+ `**${t("変更点", "Changes")}:** ${state.units.map(unit => unit.unit.title).join("; ")}\n\n` +
1922
+ `**${t("確認結果", "Validation")}:** ${t("宣言検証合格", "Declared checks passed")} — ${[...new Set(state.units.flatMap(unit => unit.unit.validation))].join("; ")}` +
1923
+ (mission?.review ? `\n${t("レビュー", "Review")}: ${mission.review.verdict === "self-rechecked" ? t("修正著者の自己再確認(独立PASSではない)", "Author self-rechecked (not independent PASS)") : mission.review.verdict === "evidence-gaps" ? t("証拠不足を残して受入れ(レビューPASSではない)", "Accepted with evidence gaps (not Review PASS)") : mission.review.verdict}.` : "") +
1922
1924
  (mission?.deliveryObservation?.head ? `\nCommit: ${mission.deliveryObservation.head} (${mission.deliveryObservation.branch ?? "detached"}; clean=${mission.deliveryObservation.clean})` : "") +
1923
- (reviewGaps ? `\n\n**レビュー補足:** ${gapSummary}\n\n**次:** なし(補足だけを理由に再レビュー不要)`
1924
- : "\n\n**次:** なし");
1925
+ (reviewGaps ? `\n\n**${t("レビュー補足", "Review note")}:** ${gapSummary}\n\n**${t("次", "Next")}:** ${t("なし(補足だけを理由に再レビュー不要)", "None (no repeat review solely for the advisory)")}`
1926
+ : `\n\n**${t("次", "Next")}:** ${t("なし", "None")}`);
1925
1927
  const rendered = await control.renderReturnReport(context.sessionID, text, goalFingerprint(result.receipt), missionReportReview(mission, state.runID), missionReportReviewGaps(mission, state.runID)).catch(() => undefined);
1926
1928
  if (rendered)
1927
1929
  panel = returnReportPanel(rendered);
@@ -4914,7 +4916,7 @@ export function createProfiledPlugin(profile, assetVersion) {
4914
4916
  const mission = run ? await missions.read(request.sessionID) : undefined;
4915
4917
  output.text = await control.renderReturnReport(request.sessionID, output.text, goalFingerprint(receipt), run ? missionReportReview(mission, run.runID) : undefined, run ? missionReportReviewGaps(mission, run.runID) : undefined) ?? output.text;
4916
4918
  }
4917
- if (output.text.includes("<summary><strong>🐾 SORTIE DOGS — 帰還報告"))
4919
+ if (returnReportPanel(output.text) !== undefined)
4918
4920
  rememberRendered(`${request.sessionID}:${request.messageID}:${request.partID}`, output.text);
4919
4921
  }
4920
4922
  }
@@ -1,8 +1,8 @@
1
1
  import { terminalRunOutcome } from "./run-metrics.js";
2
2
  /** Extract only the host card, excluding the terminal prose that follows it. */
3
3
  export function returnReportPanel(rendered) {
4
- const summary = rendered.indexOf("<summary><strong>🐾 SORTIE DOGS — 帰還報告");
5
- if (summary < 0)
4
+ const summary = /<summary><strong>🐾 SORTIE DOGS — (?:帰還報告|Return Report)(?=||<\/strong>)/u.exec(rendered)?.index;
5
+ if (summary === undefined)
6
6
  return undefined;
7
7
  const start = rendered.lastIndexOf("<details", summary);
8
8
  const end = rendered.indexOf("\n</details>", summary);
@@ -0,0 +1,7 @@
1
+ /** Product display locale, independent of the language used to instruct the model.
2
+ * Resolve on each delivery, not at plugin creation. Omitted settings preserve the
3
+ * existing Japanese UI; unsupported explicit locales fall back to English.
4
+ */
5
+ export type ReportLocale = "ja" | "en";
6
+ export declare function resolveReportLocale(value?: string | undefined): ReportLocale;
7
+ export declare const reportText: (locale: ReportLocale) => (ja: string, en: string) => string;
@@ -0,0 +1,6 @@
1
+ export function resolveReportLocale(value = process.env.SORTIE_REPORT_LOCALE) {
2
+ if (value === undefined || value.trim() === "")
3
+ return "ja";
4
+ return /^ja(?:[-_]|$)/iu.test(value.trim()) ? "ja" : "en";
5
+ }
6
+ export const reportText = (locale) => (ja, en) => locale === "ja" ? ja : en;
@@ -1,6 +1,6 @@
1
1
  import type { GoalFlightState, GoalTerminalReceipt, GoalFlightEventRecord } from "../core/goal-bound.js";
2
2
  import { type Debrief, type DebriefObservation } from "./sortie-debrief.ts";
3
- import type { GoalReport } from "../core/goal-report.ts";
3
+ import { type GoalReport } from "../core/goal-report.ts";
4
4
  import { type SortieCareer } from "./sortie-career.ts";
5
5
  export interface RunMetricsClient {
6
6
  readonly session?: {
@@ -113,6 +113,7 @@ export declare function createSortieResult(receipt: GoalTerminalReceipt, goal: S
113
113
  export type RunTerminalOutcome = "DONE" | "INTERRUPTED" | "BLOCKED" | "NEED_DECISION";
114
114
  export declare function collectRunMetrics(client: RunMetricsClient | undefined, rootSessionID: string, directory?: string, now?: number, window?: RunMetricsWindow): Promise<RunMetrics | undefined>;
115
115
  export interface SortieResultPresentation {
116
+ readonly locale?: string;
116
117
  readonly implementation?: string;
117
118
  readonly pending?: string;
118
119
  readonly next?: string;
@@ -131,6 +132,8 @@ export declare function replaceTerminalStatus(text: string, replacement: string)
131
132
  export declare function replaceDoneTerminalStatus(text: string, replacement: string): string;
132
133
  export declare function sanitizeTerminalReport(text: string): string;
133
134
  export declare function insertRunMetrics(text: string, metrics: RunMetrics): string;
134
- export declare function insertSortieResult(text: string, result: SortieResult, missionReview?: SortieResultPresentation["missionReview"], reviewEvidenceGaps?: string): string;
135
+ export declare function insertSortieResult(text: string, result: SortieResult, missionReview?: SortieResultPresentation["missionReview"], reviewEvidenceGaps?: string, localeSetting?: string): string;
135
136
  export declare function createGoalReport(result: SortieResult, receipt: GoalTerminalReceipt): GoalReport;
137
+ /** Both terminal delivery callers use the first persisted snapshot, not a later remeasurement. */
138
+ export declare function withSavedGoalReport(result: SortieResult, receipt: GoalTerminalReceipt, records: readonly GoalFlightEventRecord[]): SortieResult;
136
139
  export {};
@@ -1,7 +1,9 @@
1
- import { buildDebrief, renderDebrief, renderDebriefProof, observeDebriefSession } from "./sortie-debrief.js";
1
+ import { buildDebrief, renderDebrief, renderDebriefProof, renderDebriefTraits, renderDebriefTraitEvidence, observeDebriefSession } from "./sortie-debrief.js";
2
2
  import { goalFingerprint } from "../core/goal-bound.js";
3
+ import { validGoalReport } from "../core/goal-report.js";
3
4
  import { renderCareer } from "./sortie-career.js";
4
5
  import { estimateModelUsageCost } from "./model-cost.js";
6
+ import { reportText, resolveReportLocale } from "./report-locale.js";
5
7
  const unavailable = (reason) => ({ availability: "unavailable", value: null, reason });
6
8
  const available = (value, provenance) => ({ availability: "available", value, provenance });
7
9
  function elapsedBetween(start, end) {
@@ -405,77 +407,97 @@ function duration(milliseconds) {
405
407
  return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m ${String(seconds % 60).padStart(2, "0")}s`;
406
408
  return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
407
409
  }
408
- function metricText(metric, render) {
409
- return metric.availability === "available" ? render(metric.value) : "計測不可";
410
+ function metricText(metric, render, unavailable = "計測不可") {
411
+ return metric.availability === "available" ? render(metric.value) : unavailable;
410
412
  }
411
- const displayText = (value) => value?.replace(/[\r\n\t]+/gu, " ").trim() || "未取得";
412
- const reportFence = (body) => {
413
- const longest = Math.max(0, ...[...body.matchAll(/^~+/gmu)].map((match) => match[0].length));
414
- const fence = "~".repeat(Math.max(3, longest + 1));
415
- return `${fence}text\n${body}\n${fence}`;
413
+ /** Literal display text, not Markdown supplied by the model/user. Entities are
414
+ * decoded by the renderer after Markdown tokenization. wbr supplies layout-only
415
+ * breaks for long IDs without adding characters to their text/copy value.
416
+ */
417
+ const reportLiteral = (text) => {
418
+ const escape = (value) => value.replace(/[&<>\\`*_~\[\]#!|]/gu, character => `&#${character.codePointAt(0)};`);
419
+ return text.replace(/[^\s]+/gu, token => {
420
+ const chunks = /^[\x21-\x7e]{33,}$/u.test(token) ? token.match(/.{1,24}/gu) : [token];
421
+ return chunks.map(escape).join("<wbr>");
422
+ }).replace(/^([ ]*)([-+])/u, (_match, space, bullet) => `${space}&#${bullet.codePointAt(0)};`)
423
+ .replace(/^(\d+)([.)])(?=\s)/u, (_match, count, delimiter) => `${count}&#${delimiter.codePointAt(0)};`);
416
424
  };
417
425
  export function formatSortieResult(result, presentation = {}) {
418
- const criteria = metricText(result.proof.criteria, (entries) => {
426
+ const locale = resolveReportLocale(presentation.locale);
427
+ const t = reportText(locale);
428
+ const metric = (value, render) => metricText(value, render, t("計測不可", "Unavailable"));
429
+ const display = (value) => value?.replace(/[\r\n\t]+/gu, " ").trim() || t("未取得", "Unavailable");
430
+ const criteria = metric(result.proof.criteria, (entries) => {
419
431
  const passing = entries.filter(({ status }) => status === "PASS").length;
420
432
  return `${passing}/${entries.length}`;
421
433
  });
422
434
  const achievement = result.mission.status === "COMPLETED" ? "COMPLETED"
423
435
  : result.mission.status === "INTERRUPTED" ? "INTERRUPTED"
424
436
  : result.mission.status === "EXTERNAL_BLOCKER" ? "EXTERNAL_BLOCKER" : "USER_DECISION";
425
- const summaryLabel = result.mission.status === "COMPLETED" ? "完了"
426
- : result.mission.status === "INTERRUPTED" ? "中断(未完了)"
427
- : result.mission.status === "EXTERNAL_BLOCKER" ? "外部要因で未完了" : "ユーザー判断待ち(未完了)";
437
+ const summaryLabel = result.mission.status === "COMPLETED" ? t("完了", "Complete")
438
+ : result.mission.status === "INTERRUPTED" ? t("中断(未完了)", "Interrupted (unfinished)")
439
+ : result.mission.status === "EXTERNAL_BLOCKER" ? t("外部要因で未完了", "External blocker (unfinished)") : t("ユーザー判断待ち(未完了)", "Awaiting user decision (unfinished)");
428
440
  const color = result.mission.status === "COMPLETED" ? "🟢" : result.mission.status === "EXTERNAL_BLOCKER" ? "🔴" : "🟡";
429
- const proof = renderDebriefProof(result.debrief);
430
- const review = presentation.missionReview === "PASS" ? "🟢 PASS(独立Reviewer)"
431
- : presentation.missionReview === "self-rechecked" ? "🟢 自己再確認済み(修正著者・独立PASSではない)"
432
- : presentation.missionReview === "evidence-gaps" ? "🟡 補足あり(非ブロッキング・PASSではない)"
433
- : presentation.missionReview === "skipped-low-risk" ? "免除(低リスク)" : proof.review;
441
+ const proof = renderDebriefProof(result.debrief, locale);
442
+ const review = presentation.missionReview === "PASS" ? t("🟢 PASS(独立Reviewer)", "🟢 PASS (independent Reviewer)")
443
+ : presentation.missionReview === "self-rechecked" ? t("🟢 自己再確認済み(修正著者・独立PASSではない)", "🟢 Author self-rechecked (not independent PASS)")
444
+ : presentation.missionReview === "evidence-gaps" ? t("🟡 補足あり(非ブロッキング・PASSではない)", "🟡 Evidence gaps (non-blocking; not PASS)")
445
+ : presentation.missionReview === "skipped-low-risk" ? t("免除(低リスク)", "Waived (low risk)") : proof.review;
434
446
  const estimate = result.debrief?.estimatedCost;
435
- const estimatedCost = estimate === undefined ? "計測不可"
436
- : estimate.pricedRequests === 0 ? "未換算"
437
- : `$${estimate.usd.toFixed(4)}${estimate.unpricedRequests > 0 ? "(一部未換算)" : ""}`;
447
+ const estimatedCost = estimate === undefined ? t("計測不可", "Unavailable")
448
+ : estimate.pricedRequests === 0 ? t("未換算", "Unpriced")
449
+ : `$${estimate.usd.toFixed(4)}${estimate.unpricedRequests > 0 ? t("(一部未換算)", " (partly unpriced)") : ""}`;
438
450
  const measuredTokens = result.debrief?.mixCoverage?.observedTokens;
439
451
  const tokenText = result.cost.total_tokens.availability === "unavailable" && measuredTokens !== undefined && measuredTokens > 0
440
- ? `${measuredTokens.toLocaleString("ja-JP")} tokens(観測分)`
441
- : metricText(result.cost.total_tokens, (value) => `${value.toLocaleString("ja-JP")} tokens`);
452
+ ? `${measuredTokens.toLocaleString(locale)} ${locale === "en" && measuredTokens === 1 ? "token" : "tokens"}${t("(観測分)", " (observed)")}`
453
+ : metric(result.cost.total_tokens, (value) => `${value.toLocaleString(locale)} ${locale === "en" && value === 1 ? "token" : "tokens"}`);
442
454
  const body = [
443
- "🐾 SORTIE DOGS — 帰還報告",
455
+ t("🐾 SORTIE DOGS — 帰還報告", "🐾 SORTIE DOGS — Return Report"),
456
+ t("TACTICAL DESK / 作戦卓", "TACTICAL DESK / Operations"),
457
+ "────────────────────",
444
458
  result.result_id[0],
445
459
  "",
446
- `${color} ${achievement} — ${displayText(presentation.statusSummary)}`,
460
+ `${color} ${achievement} — ${display(presentation.statusSummary)}`,
447
461
  "",
448
- "⚔️ MISSION",
449
- `経過 ⏱ ${metricText(result.speed.goal_wall_ms, duration)} ※待機含む`,
450
- `最終達成条件 ◔ ${criteria}`,
451
- `対象検証 ${proof.validation}`,
452
- `SourceReview ${review}`,
453
- ...(presentation.reviewNote ? [`レビュー補足 ${displayText(presentation.reviewNote)}`] : []),
454
- `Commit ${displayText(presentation.commit)}`,
462
+ ...renderDebriefTraits(result.debrief, result.mission.status === "COMPLETED", locale),
455
463
  "",
456
- "🔧 実装",
457
- displayText(presentation.implementation),
464
+ "⚑ MISSION",
465
+ `${t("経過", "Elapsed")} ⏱ ${metric(result.speed.goal_wall_ms, duration)} ${t("※待機含む", "※Includes waiting")}`,
466
+ `${t("最終達成条件", "Final criteria")} ◔ ${criteria}`,
467
+ `${t("対象検証", "Target validation")} ${proof.validation}`,
468
+ `SourceReview ${review}`,
469
+ ...(presentation.reviewNote ? [`${t("レビュー補足", "Review note")} ${display(presentation.reviewNote)}`] : []),
470
+ `Commit ${display(presentation.commit)}`,
458
471
  "",
459
- "⏳ 未実施",
460
- displayText(presentation.pending),
472
+ t("🔧 実装", "🔧 Implementation"),
473
+ display(presentation.implementation),
461
474
  "",
462
- "➡️ NEXT",
463
- displayText(presentation.next),
475
+ t("⏳ 未実施", "⏳ Pending"),
476
+ presentation.pending?.trim() ? display(presentation.pending) : t("未確認(未実施なしを確認した意味ではありません)", "Unconfirmed (does not mean nothing remains)"),
464
477
  "",
465
478
  "🪙 COST / PACK",
466
- `使用量 ${tokenText}`,
467
- `予測費用 ${estimatedCost} ※予測概算`,
479
+ `${t("使用量", "Usage")} ${tokenText}`,
480
+ `${t("予測費用", "Estimated cost")} ${estimatedCost} ${t("※予測概算", "※Estimate only")}`,
481
+ `${t("host計上費用", "Host-recorded cost")} ${result.cost.cost_usd.availability === "available" ? `$${result.cost.cost_usd.value.toFixed(4)}` : t("unknown(全体の計上額未取得)", "unknown (total host cost unavailable)")}`,
482
+ t("※換算済み予測費用・host計上費用は請求総額ではありません。未換算・未取得を$0扱いしません", "※Priced estimates and host-recorded costs are not the total bill. Unpriced or missing amounts are not $0."),
483
+ "",
484
+ ...renderDebrief(result.debrief, locale),
468
485
  "",
469
- ...renderDebrief(result.debrief),
486
+ ...renderCareer(result.career, locale),
470
487
  "",
471
- ...renderCareer(result.career),
488
+ ...renderDebriefTraitEvidence(result.debrief, result.mission.status === "COMPLETED", locale),
472
489
  "",
473
490
  "🛑 STOP REASON",
474
- displayText(presentation.stopReason ?? result.mission.stop_reason),
491
+ display(presentation.stopReason ?? result.mission.stop_reason),
475
492
  "",
476
- "※使用量は最終応答生成前の計測",
477
- ].join("\n");
478
- return `<details>\n<summary><strong>🐾 SORTIE DOGS — 帰還報告|${color} ${summaryLabel}</strong></summary>\n\n${reportFence(body)}\n\n</details>`;
493
+ "➡️ NEXT",
494
+ display(presentation.next),
495
+ ...(result.mission.status === "COMPLETED" ? ["", t("└─ 記録を閉じる。次の作戦へ。", "└─ Record closed. On to the next operation.")] : []),
496
+ "",
497
+ t("※使用量は最終応答生成前の計測", "※Usage measured before final response generation"),
498
+ ].filter(line => line !== "").map((line, index) => index === 1
499
+ ? `\`${line}\`` : reportLiteral(line.trim())).join("\n\n");
500
+ return `<details>\n<summary><strong>${t("🐾 SORTIE DOGS — 帰還報告", "🐾 SORTIE DOGS — Return Report")}|${color} ${summaryLabel}</strong></summary>\n\n${body}\n\n</details>`;
479
501
  }
480
502
  export function formatRunMetrics(metrics) {
481
503
  const elapsed = metrics.durationMilliseconds === undefined ? "duration unavailable" : `${duration(metrics.durationMilliseconds)} wall-clock`;
@@ -567,7 +589,7 @@ export function sanitizeTerminalReport(text) {
567
589
  const keep = (block) => `\0SORTIE_PROSE_${preserved.push(block) - 1}\0`;
568
590
  return text.replace(/<details\b[^>]*>[\s\S]*?<\/details>/giu, block => {
569
591
  const summary = /<summary\b[^>]*>([\s\S]*?)<\/summary>/iu.exec(block)?.[1]?.replace(/<[^>]*>/gu, "").trim() ?? "";
570
- return /^(?:Evidence\b|🐾\s*SORTIE DOGS\s*[—-]\s*帰還報告)/iu.test(summary) ? "" : keep(block);
592
+ return /^(?:Evidence\b|🐾\s*SORTIE DOGS\s*[—-]\s*(?:帰還報告|Return Report))/iu.test(summary) ? "" : keep(block);
571
593
  })
572
594
  .replace(/^[ \t]*(`{3,}|~{3,})[^\r\n]*\r?\n[\s\S]*?^[ \t]*\1[ \t]*$/gimu, (block, _fence, offset, source) => {
573
595
  const preceding = source.slice(0, offset).trimEnd().split(/\r?\n\s*\r?\n/u).at(-1) ?? "";
@@ -591,14 +613,16 @@ export function insertRunMetrics(text, metrics) {
591
613
  lines.splice(checkpoint.index + 1, 0, "", formatRunMetrics(metrics));
592
614
  return lines.join(newline);
593
615
  }
594
- export function insertSortieResult(text, result, missionReview, reviewEvidenceGaps) {
616
+ export function insertSortieResult(text, result, missionReview, reviewEvidenceGaps, localeSetting) {
617
+ const locale = resolveReportLocale(localeSetting);
618
+ const t = reportText(locale);
595
619
  const visible = sanitizeTerminalReport(text);
596
620
  const gapSummary = missionReview === "evidence-gaps"
597
- ? reviewEvidenceGaps?.replace(/^EVIDENCE_GAPS\s*/u, "").replace(/\s+/gu, " ").trim().slice(0, 500) || "独立Reviewに未確認事項あり"
621
+ ? reviewEvidenceGaps?.replace(/^EVIDENCE_GAPS\s*/u, "").replace(/\s+/gu, " ").trim().slice(0, 500) || t("独立Reviewに未確認事項あり", "Independent Review has unconfirmed items")
598
622
  : undefined;
599
623
  const reported = extractSortiePresentation(text);
600
624
  // Preserve uncertainty visibly without manufacturing unfinished work or a required next action.
601
- const presentation = { ...reported, missionReview, ...(gapSummary ? { reviewNote: gapSummary } : {}) };
625
+ const presentation = { ...reported, locale, missionReview, ...(gapSummary ? { reviewNote: gapSummary } : {}) };
602
626
  const checkpoint = terminalCheckpoint(visible);
603
627
  if (checkpoint === undefined)
604
628
  return visible;
@@ -631,13 +655,16 @@ export function insertSortieResult(text, result, missionReview, reviewEvidenceGa
631
655
  card = formatSortieResult(result, presentation);
632
656
  }
633
657
  catch {
634
- card = "<details>\n<summary><strong>🐾 SORTIE DOGS — 帰還報告</strong></summary>\n\n**確認:** 表示集計を取得できません。任務結果は先頭の状態を参照。\n\n</details>";
658
+ card = t("<details>\n<summary><strong>🐾 SORTIE DOGS — 帰還報告</strong></summary>\n\n**確認:** 表示集計を取得できません。任務結果は先頭の状態を参照。\n\n</details>", "<details>\n<summary><strong>🐾 SORTIE DOGS — Return Report</strong></summary>\n\n**Note:** Display totals unavailable. See the leading status for the mission result.\n\n</details>");
635
659
  }
636
660
  cleaned.splice(checkpoint.index + 1, 0, "", card, "");
637
661
  return cleaned.join(newline).trimEnd();
638
662
  }
639
663
  function extractSortiePresentation(text) {
640
- const first = topLevelLines(text).find(({ line }) => line.trim().length > 0)?.line ?? "";
664
+ // Old host cards and quoted examples are not caller prose. In particular, a
665
+ // previous locale's generated unknown values must not become retained text.
666
+ text = topLevelLines(text).map(({ line }) => line).join("\n");
667
+ const first = text.split("\n").find(line => line.trim().length > 0) ?? "";
641
668
  const statusSummary = first.split(/\s+[—-]\s+/u).slice(1).join(" — ").trim() || undefined;
642
669
  const section = (names) => {
643
670
  const expression = new RegExp(`^[ \\t]*(?:#{1,6}[ \\t]*)?(?:\\p{Extended_Pictographic}\\uFE0F?[ \\t]+)?(?:\\*\\*)?(?:${names})(?:\\*\\*)?[ \\t]*:?[ \\t]*(?:\\*\\*)?[ \\t]*(.*)$`, "imu");
@@ -650,7 +677,7 @@ function extractSortiePresentation(text) {
650
677
  return tail.find((line) => line.trim().length > 0 && !/^[ \\t]*(?:#{1,6}[ \\t]*)?(?:\\*\\*)?(?:変更点|実装|未実施|次|NEXT|Commit|コミット)/iu.test(line))?.trim();
651
678
  };
652
679
  const explicitStop = /^(?:TRUE_INTERRUPTION|TRUE_BLOCKER)[ \\t]*:[ \\t]*(.+)$/imu.exec(text)?.[1]?.trim();
653
- return { statusSummary, implementation: section("変更点|実装"), pending: section("未実施"), next: section("次|NEXT"),
680
+ return { statusSummary, implementation: section("変更点|実装|Implementation|Changes"), pending: section("未実施|Pending"), next: section("次|NEXT"),
654
681
  commit: section("Commit|コミット"), stopReason: explicitStop };
655
682
  }
656
683
  export function createGoalReport(result, receipt) {
@@ -668,6 +695,19 @@ export function createGoalReport(result, receipt) {
668
695
  models: mix != null && mix.length <= 128 && mix.every((entry) => entry.model.length <= 512) &&
669
696
  mix.reduce((sum, entry) => sum + entry.tokens, 0) === tokens ? mix.map(({ model, tokens }) => ({ model, tokens })) : null,
670
697
  first_pass_eligible: result.debrief?.firstPassEligible === true, traits,
698
+ ...(result.debrief?.rare === undefined ? {} : { rare: result.debrief.rare }),
671
699
  ...(result.debrief?.overlap === undefined ? {} : { overlap: { definition: "worker-span-union/v1",
672
700
  worker_ms: result.debrief.overlap.workerMilliseconds, wall_ms: result.debrief.overlap.wallMilliseconds } }) };
673
701
  }
702
+ /** Both terminal delivery callers use the first persisted snapshot, not a later remeasurement. */
703
+ export function withSavedGoalReport(result, receipt, records) {
704
+ const report = records.map(({ event }) => event).find(event => event.kind === "goal.reported" &&
705
+ event.goal_id === receipt.goal_id && validGoalReport(event.report) && event.report.terminal_key === goalFingerprint(receipt));
706
+ if (result.debrief === undefined)
707
+ return result;
708
+ const saved = report?.kind === "goal.reported" ? report.report : undefined;
709
+ return { ...result, debrief: { ...result.debrief, rare: saved?.rare,
710
+ ...(saved === undefined ? {} : { traits: saved.traits.map(trait => trait === "pack-tactics" ? "連携作戦"
711
+ : trait === "recovery" ? "修正から復帰" : "一発完遂"), firstPassEligible: saved.first_pass_eligible }),
712
+ rareRecord: saved === undefined ? "unavailable" : saved.rare === undefined ? "legacy" : "saved" } };
713
+ }
@@ -45,6 +45,13 @@ export interface SortieCareer {
45
45
  readonly ratio: number | null;
46
46
  };
47
47
  readonly titles: readonly string[];
48
+ readonly rare: readonly {
49
+ readonly id: string;
50
+ readonly earned: number;
51
+ readonly notEarned: number;
52
+ readonly unknown: number;
53
+ readonly legacy: number;
54
+ }[];
48
55
  }
49
56
  /** Rebuild from retained source records; no cache survives deletion, retention, or reset. */
50
57
  export declare function summarizeCareer(histories: readonly (readonly GoalFlightEventRecord[])[], coverage: CareerCoverage): SortieCareer;
@@ -52,4 +59,4 @@ export declare function summarizeCareer(histories: readonly (readonly GoalFlight
52
59
  export declare function collectCareer(directories: readonly string[], currentPath: string, current: readonly GoalFlightEventRecord[], read: (path: string) => Promise<{
53
60
  readonly records: readonly GoalFlightEventRecord[];
54
61
  }>, maxFiles?: number): Promise<SortieCareer>;
55
- export declare function renderCareer(career: SortieCareer | undefined): string[];
62
+ export declare function renderCareer(career: SortieCareer | undefined, locale?: import("./report-locale.ts").ReportLocale): string[];
@@ -2,6 +2,9 @@ import { readdir } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
  import { goalFingerprint } from "../core/goal-bound.js";
4
4
  import { validGoalReport } from "../core/goal-report.js";
5
+ import { RARE_RULES, rareEvaluation } from "../core/rare-awards.js";
6
+ import { rareDisplay } from "./sortie-debrief.js";
7
+ import { reportText, resolveReportLocale } from "./report-locale.js";
5
8
  /** Rebuild from retained source records; no cache survives deletion, retention, or reset. */
6
9
  export function summarizeCareer(histories, coverage) {
7
10
  const unique = new Map();
@@ -23,7 +26,7 @@ export function summarizeCareer(histories, coverage) {
23
26
  goal.receipt = null;
24
27
  if (event.kind === "unit.settled")
25
28
  goal.units.set(event.reservation_id, event.elapsed_ms);
26
- if (event.kind === "goal.reported" && validGoalReport(event.report))
29
+ if (event.kind === "goal.reported" && validGoalReport(event.report) && !goal.reports.has(event.report.terminal_key))
27
30
  goal.reports.set(event.report.terminal_key, event.report);
28
31
  }
29
32
  let completed = 0, interrupted = 0, external = 0, decision = 0, active = 0, telemetryCovered = 0, recoveries = 0;
@@ -31,6 +34,7 @@ export function summarizeCareer(histories, coverage) {
31
34
  const models = new Map();
32
35
  const overlap = { worker: 0, wall: 0, covered: 0, ratio: null };
33
36
  let modelCovered = 0, packTactics = false;
37
+ const rare = RARE_RULES.map(rule => ({ id: rule.id, earned: 0, notEarned: 0, unknown: 0, legacy: 0 }));
34
38
  for (const goal of goals.values()) {
35
39
  const receipt = goal.receipt;
36
40
  if (receipt === null) {
@@ -55,6 +59,22 @@ export function summarizeCareer(histories, coverage) {
55
59
  workerTime.covered++;
56
60
  }
57
61
  const report = goal.reports.get(goalFingerprint(receipt));
62
+ if (receipt.status === "succeeded")
63
+ for (const item of rare) {
64
+ if (report === undefined)
65
+ item.unknown++;
66
+ else if (report.rare === undefined)
67
+ item.legacy++;
68
+ else {
69
+ const evaluation = rareEvaluation(report.rare, item.id);
70
+ if (evaluation?.status === "earned")
71
+ item.earned++;
72
+ else if (evaluation?.status === "not-earned")
73
+ item.notEarned++;
74
+ else
75
+ item.unknown++;
76
+ }
77
+ }
58
78
  if (report === undefined)
59
79
  continue;
60
80
  telemetryCovered++;
@@ -88,7 +108,7 @@ export function summarizeCareer(histories, coverage) {
88
108
  return { scope: "retained-project-goals", since: dates[0] ?? null, coverage, goals: goals.size,
89
109
  completed, interrupted, external, decision, active, telemetryCovered, firstPass, recoveries, tokens,
90
110
  models: [...models].sort(([a], [b]) => a.localeCompare(b)).map(([model, tokens]) => ({ model, tokens })), modelCovered,
91
- workerTime, goalWall, overlap, titles: [
111
+ workerTime, goalWall, overlap, rare, titles: [
92
112
  ...(completed > 0 ? ["任務完遂の隊"] : []), ...(packTactics ? ["連携の隊"] : []),
93
113
  ...(recoveries > 0 ? ["復帰の隊"] : []), ...(firstPass.count > 0 ? ["一発完遂の隊"] : []),
94
114
  ] };
@@ -122,27 +142,48 @@ export async function collectCareer(directories, currentPath, current, read, max
122
142
  }
123
143
  return summarizeCareer(histories, { files: paths.size + 1, included: histories.length, unavailable, truncated: selected.length < paths.size });
124
144
  }
125
- export function renderCareer(career) {
145
+ export function renderCareer(career, locale = resolveReportLocale()) {
146
+ const t = reportText(locale);
126
147
  if (career === undefined)
127
- return ["📜 PACK RECORD", "保存履歴を取得できません"];
148
+ return ["📜 PACK RECORD", t("保存履歴を取得できません", "Saved history unavailable")];
128
149
  const terminal = career.goals - career.active;
129
- const minutes = (metric) => metric.covered === 0 ? "計測不可" : `${(metric.sum / 60000).toFixed(1)}分 ※${metric.covered}/${terminal}任務`;
150
+ const missions = (count) => count === 1 ? " mission" : " missions";
151
+ const minutes = (metric) => metric.covered === 0 ? t("計測不可", "Unavailable") : `${(metric.sum / 60000).toFixed(1)}${t("分", " min")} ※${metric.covered}/${terminal}${t("任務", missions(terminal))}`;
130
152
  return [
131
153
  "📜 PACK RECORD",
132
- `🏁 完了 ${career.completed}`,
133
- `🟡 中断 ${career.interrupted}`,
134
- `⏳ 外部待機 ${career.external}`,
135
- `❓ 指示待ち ${career.decision}`,
136
- `🔄 進行中 ${career.active}`,
137
- `↩️ 復帰 ${career.recoveries}`,
154
+ `🏁 ${t("完了", "Completed")} ${career.completed}`,
155
+ `🟡 ${t("中断", "Interrupted")} ${career.interrupted}`,
156
+ `⏳ ${t("外部待機", "External wait")} ${career.external}`,
157
+ `❓ ${t("指示待ち", "Awaiting decision")} ${career.decision}`,
158
+ `🔄 ${t("進行中", "Active")} ${career.active}`,
159
+ `↩️ ${t("復帰", "Recoveries")} ${career.recoveries}`,
160
+ t("※今回ではなく保存履歴の戦績。進行中は終端記録なし(現在の稼働確認ではありません)", "※Saved history, not this mission. Active means no terminal record, not confirmation of current activity."),
161
+ t(`※復帰は成功任務の同一子セッション内の検証失敗→変更→同じ検証成功のみ。報告取得 ${career.telemetryCovered}/${terminal}任務`, `※Recovery requires a successful mission and same-child check failure → edit → same check success. Reports ${career.telemetryCovered}/${terminal}${missions(terminal)}`),
162
+ `🎯 ${t("一発完遂", "Clean Sweep")} ${career.firstPass.count}/${career.firstPass.eligible}${t("判定対象任務", ` eligible${missions(career.firstPass.eligible)}`)}`,
163
+ "",
164
+ t("🎖 PACK獲得称号(保存履歴)", "🎖 PACK titles (saved history)"),
165
+ ...[
166
+ ["任務完遂の隊", t("任務完遂の隊", "Mission Accomplished Pack"), t("保存された正式receiptに任務完了あり", "Formal saved receipt confirms mission completion")],
167
+ ["連携の隊", t("連携の隊", "Formation Pack"), t("報告に異なる子セッションの稼働区間重複あり(成功称号ではありません)", "Report records overlapping child sessions (not a success title)")],
168
+ ["復帰の隊", t("復帰の隊", "Recovery Pack"), t("成功任務に上記の限定された復帰記録あり", "Successful mission with the narrowly defined recovery above")],
169
+ ["一発完遂の隊", t("一発完遂の隊", "Clean Sweep Pack"), t("判定対象の成功任務に再試行・再計画・検証失敗なし", "Eligible successful mission without retries, replans or failed checks")],
170
+ ].filter(([id]) => career.titles.includes(id)).map(([, title, reason]) => `${title} — ${reason}`),
171
+ ...(career.titles.length === 0 ? [t("判定保留 — 保存範囲で確認できた称号なし", "Pending — no titles confirmed in retained scope")] : []),
172
+ "",
173
+ t("特別記章(保存履歴・記章別/合算しません)", "Special insignia (saved history; separate, never summed)"),
174
+ ...(career.rare.filter(item => item.earned > 0).map(item => `${rareDisplay(item.id, locale).title} — ${t(`${item.earned}任務に獲得記録あり`, `Award recorded in ${item.earned}${missions(item.earned)}`)}`)),
175
+ ...(career.rare.every(item => item.earned === 0) ? [t("確認できた獲得記録なし(未獲得の断定ではありません)", "No confirmed award records (not a claim of never earned)")] : []),
176
+ ...career.rare.map(item => `${rareDisplay(item.id, locale).title}${t(`の判定範囲 — 成功${career.completed}任務中 非該当${item.notEarned}・判定不能${item.unknown}・旧未判定${item.legacy}`, ` coverage — ${career.completed} successful${missions(career.completed)}: not applicable ${item.notEarned} · unknown ${item.unknown} · legacy unevaluated ${item.legacy}`)}`),
177
+ t("※同じ任務・現在の正式receiptにつき各記章1件。再開中の旧獲得は除外。通常称号の別名を追加実績にしません", "※One record per insignia per mission's current formal receipt. Old awards excluded while reopened. Title aliases are not extra achievements."),
138
178
  "",
139
- `🪙 累積使用量 ${career.tokens.covered === 0 ? "計測不可" : `${career.tokens.sum.toLocaleString("ja-JP")} tokens`} ※${career.tokens.covered}/${terminal}任務`,
140
- `⏱ 累積worker ${minutes(career.workerTime)}`,
141
- `🕰 累積goal ${minutes(career.goalWall)} ※待機・重複含む`,
142
- `⚡ 累積重複率 ${career.overlap.ratio === null ? "計測不可" : `${career.overlap.ratio.toFixed(2)}×`} ※${career.overlap.covered}/${terminal}任務・速度倍率ではありません`,
179
+ `🪙 ${t("累積使用量", "Cumulative usage")} ${career.tokens.covered === 0 ? t("計測不可", "Unavailable") : `${career.tokens.sum.toLocaleString(locale)} ${locale === "en" && career.tokens.sum === 1 ? "token" : "tokens"}`} ※${career.tokens.covered}/${terminal}${t("任務", missions(terminal))}`,
180
+ `⏱ ${t("累積worker", "Cumulative worker")} ${minutes(career.workerTime)}`,
181
+ `🕰 ${t("累積goal", "Cumulative goal")} ${minutes(career.goalWall)} ${t("※待機・重複含む", "※Includes waiting and overlap")}`,
182
+ `⚡ ${t("累積重複率", "Cumulative overlap")} ${career.overlap.ratio === null ? t("計測不可", "Unavailable") : `${career.overlap.ratio.toFixed(2)}×`} ※${career.overlap.covered}/${terminal}${t("任務・速度倍率ではありません", `${missions(terminal)}; not a speed multiplier`)}`,
143
183
  "",
144
- "📦 保存範囲",
145
- `${career.since?.slice(0, 10) ?? "開始日不明"}以降 / ${career.coverage.included} of ${career.coverage.files} files${career.coverage.unavailable || career.coverage.truncated ? " ※部分集計" : ""}`,
146
- "※生涯戦績ではありません",
184
+ t("📦 保存範囲", "📦 Retained scope"),
185
+ `${career.since?.slice(0, 10) ?? t("開始日不明", "Unknown start")}${t("以降", " onward")} / ${career.coverage.included} of ${career.coverage.files}${career.coverage.files === 1 ? " file" : " files"}${career.coverage.unavailable || career.coverage.truncated ? t(" ※部分集計", " ※Partial aggregation") : ""}`,
186
+ t("※生涯戦績ではありません", "※Not a lifetime record"),
187
+ t("※称号も上記保存範囲のみ。未取得・削除済み履歴は判定しません", "※Titles cover only the retained scope above. Missing or deleted history is not evaluated."),
147
188
  ];
148
189
  }
@@ -1,4 +1,10 @@
1
1
  import type { GoalAcceptanceContract, GoalTerminalReceipt, GoalFlightEventRecord } from "../core/goal-bound.js";
2
+ import { type RareAwards } from "../core/rare-awards.ts";
3
+ import { type ReportLocale } from "./report-locale.ts";
4
+ export declare function rareDisplay(id: string, locale: ReportLocale): {
5
+ title: string;
6
+ reason: string;
7
+ };
2
8
  type Span = {
3
9
  start: number;
4
10
  end: number;
@@ -80,6 +86,9 @@ export interface Debrief {
80
86
  readonly notes?: readonly string[];
81
87
  readonly traits: readonly ("連携作戦" | "修正から復帰" | "一発完遂")[];
82
88
  readonly firstPassEligible?: boolean;
89
+ readonly rare?: RareAwards;
90
+ /** Only persisted, receipt-matched evaluations are displayed as awarded records. */
91
+ readonly rareRecord?: "saved" | "legacy" | "unavailable";
83
92
  readonly overlap?: {
84
93
  readonly workerMilliseconds: number;
85
94
  readonly wallMilliseconds: number;
@@ -96,8 +105,13 @@ export interface Debrief {
96
105
  /** Extract only bounded typed metadata during the existing host history traversal. No conversation text survives. */
97
106
  export declare function observeDebriefSession(id: string, root: boolean, messages: readonly Record<string, unknown>[], window?: Span): DebriefSession;
98
107
  export declare function buildDebrief(receipt: GoalTerminalReceipt, contract: GoalAcceptanceContract | null, observation: DebriefObservation | undefined, records?: readonly GoalFlightEventRecord[]): Debrief;
99
- export declare function renderDebrief(debrief: Debrief | undefined): string[];
100
- export declare function renderDebriefProof(debrief: Debrief | undefined): {
108
+ /** Display existing observations only; titles never select an execution policy. */
109
+ export declare function debriefOperationTitle(debrief: Debrief | undefined, completed: boolean): string | undefined;
110
+ export declare function renderDebriefTraits(debrief: Debrief | undefined, completed: boolean, locale?: ReportLocale): string[];
111
+ /** Evidence belongs after the main result, without awarding the same fact twice. */
112
+ export declare function renderDebriefTraitEvidence(debrief: Debrief | undefined, completed: boolean, locale?: ReportLocale): string[];
113
+ export declare function renderDebrief(debrief: Debrief | undefined, locale?: ReportLocale): string[];
114
+ export declare function renderDebriefProof(debrief: Debrief | undefined, locale?: ReportLocale): {
101
115
  validation: string;
102
116
  review: string;
103
117
  };
@@ -2,6 +2,18 @@ import { createHash } from "node:crypto";
2
2
  import { validationDurationGuidance } from "../core/validation-budget.js";
3
3
  import { DEDICATED_WORKER_ROLES, LUNA_FABRIC_WORKER_ROLE } from "./model-routing.js";
4
4
  import { normalizeCommand } from "./gate.js";
5
+ import { evaluateRareAwards, rareEvaluation, RARE_RULES } from "../core/rare-awards.js";
6
+ import { reportText, resolveReportLocale } from "./report-locale.js";
7
+ export function rareDisplay(id, locale) {
8
+ const t = reportText(locale);
9
+ return id === "rare.clean-sweep" ? {
10
+ title: t("静かな一撃", "Quiet Strike"),
11
+ reason: t("厳密な一発完遂(既存実績の特別区分・別加算なし)", "Strict Clean Sweep (reclassified existing achievement; no extra count)"),
12
+ } : {
13
+ title: t("不屈の連携", "Unbroken Formation"),
14
+ reason: t("修正復帰 × 異なる子の並行稼働(因果的協働成果の証明ではありません)", "Recovery × overlapping child activity (not proof of causally successful collaboration)"),
15
+ };
16
+ }
5
17
  const object = (value) => value !== null && typeof value === "object" ? value : undefined;
6
18
  const timeValue = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
7
19
  const fingerprint = (value) => createHash("sha256").update(normalizeCommand(value)).digest("hex");
@@ -287,6 +299,17 @@ export function buildDebrief(receipt, contract, observation, records) {
287
299
  (event.kind === "validation.admission" && event.decision !== "ALLOW") || (event.kind === "validation.settled" && event.outcome !== "passed")) &&
288
300
  sessions.every((session) => !session.failed && new Set(session.checks.map((check) => check.command)).size === session.checks.length))
289
301
  traits.push("一発完遂");
302
+ const cleanDisqualified = validation === "FAIL" || recovered || sessions.some(session => session.failed ||
303
+ new Set(session.checks.map(check => check.command)).size !== session.checks.length) ||
304
+ new Set(tasks).size !== tasks.length || settled.some(event => event.disposition !== "succeeded" || event.result_class !== "acceptance") ||
305
+ events?.some(event => event.kind === "goal.replanned" || event.kind === "goal.user-continued" || event.kind === "goal.revised" ||
306
+ event.kind === "validation.admission" && event.decision !== "ALLOW" ||
307
+ event.kind === "validation.settled" && event.outcome !== "passed");
308
+ const clean = traits.includes("一発完遂") ? "true" : cleanDisqualified ? "false" : "unknown";
309
+ const recovery = traits.includes("修正から復帰") ? "true"
310
+ : complete && commands.size > 0 && validation === "PASS" ? "false" : "unknown";
311
+ const coordination = traits.includes("連携作戦") ? "true" : timingComplete ? "false" : "unknown";
312
+ const rare = evaluateRareAwards(receipt.status === "succeeded", clean, recovery, coordination);
290
313
  const review = reviews.filter((entry) => !sessions.some((session) => (session.lastPossibleMutation ?? 0) > entry.at ||
291
314
  session.mutations.some((edit) => edit.end > entry.at))).at(-1);
292
315
  const packComplete = observation.complete && sessions.every(session => session.root || session.timingComplete || Object.keys(session.models).length > 0);
@@ -306,6 +329,7 @@ export function buildDebrief(receipt, contract, observation, records) {
306
329
  usd: pricing.usd, pricedRequests: pricing.pricedRequests, unpricedRequests: pricing.unpricedRequests
307
330
  } } : {}),
308
331
  validation, review: review?.status ?? "未確認", reviewSource: review?.source ?? "controller", traits, firstPassEligible,
332
+ rare, rareRecord: "unavailable",
309
333
  validationEfficiency,
310
334
  notes: [...(!observation.complete ? ["host履歴の取得が一部不足"] : []),
311
335
  ...(!usageComplete ? ["モデルIDまたはusageの記録が不足"] : []),
@@ -313,8 +337,8 @@ export function buildDebrief(receipt, contract, observation, records) {
313
337
  ...(timingComplete ? { overlap: { workerMilliseconds: children.reduce((sum, child) => sum + unionDuration(child.spans), 0),
314
338
  wallMilliseconds: unionDuration(spans) } } : {}) };
315
339
  }
316
- const label = (text) => text.replace(/[\r\n\t]/gu, " ").replace(/[\\`*_{}\[\]()<>!|]/gu, "").slice(0, 120);
317
- const milliseconds = (value) => value === null ? "未記録"
340
+ const label = (text) => text.replace(/[\r\n\t]/gu, " ");
341
+ const milliseconds = (value, locale = "ja") => value === null ? reportText(locale)("未記録", "Unrecorded")
318
342
  : value < 1000 ? `${value}ms` : `${(value / 1000).toFixed(2)}s`;
319
343
  const dollars = (usd) => `$${usd.toFixed(usd > 0 && usd < 0.00005 ? 6 : 4)}`;
320
344
  const gauge = (percent) => {
@@ -323,7 +347,63 @@ const gauge = (percent) => {
323
347
  const partial = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"][remainder];
324
348
  return `${"█".repeat(whole)}${partial}${" ".repeat(10 - whole - (remainder === 0 ? 0 : 1))}`;
325
349
  };
326
- export function renderDebrief(debrief) {
350
+ /** Display existing observations only; titles never select an execution policy. */
351
+ export function debriefOperationTitle(debrief, completed) {
352
+ if (!completed)
353
+ return undefined;
354
+ const traits = debrief?.traits ?? [];
355
+ if (traits.includes("修正から復帰"))
356
+ return "立て直し屋";
357
+ if (traits.includes("一発完遂"))
358
+ return undefined; // Reclassified, never a second achievement under an alias.
359
+ if (traits.includes("連携作戦"))
360
+ return "連携上手";
361
+ return undefined;
362
+ }
363
+ export function renderDebriefTraits(debrief, completed, locale = resolveReportLocale()) {
364
+ const t = reportText(locale);
365
+ const traits = debrief?.traits ?? [];
366
+ const title = debriefOperationTitle(debrief, completed);
367
+ const earned = completed && debrief?.rareRecord === "saved" ? RARE_RULES.filter(rule => rareEvaluation(debrief.rare, rule.id)?.status === "earned") : [];
368
+ return [
369
+ `${t("作戦称号", "Operation title")} ${title === "立て直し屋" ? t(title, "Recovery Specialist") : title === "連携上手" ? t(title, "Formation Specialist") : earned.some(rule => rule.id === "rare.clean-sweep") ? t("特別記章へ集約", "Listed under special insignia") : completed ? t("判定保留(根拠未確認)", "Pending (evidence unconfirmed)") : t("未授与(未完了)", "Not awarded (unfinished)")}`,
370
+ ...earned.map(rule => `${t("特別記章", "Special insignia")} ${rareDisplay(rule.id, locale).title} — ${t("獲得記録あり", "Award recorded")}`),
371
+ ];
372
+ }
373
+ /** Evidence belongs after the main result, without awarding the same fact twice. */
374
+ export function renderDebriefTraitEvidence(debrief, completed, locale = resolveReportLocale()) {
375
+ const t = reportText(locale);
376
+ const traits = debrief?.traits ?? [];
377
+ return [
378
+ t("✎ 称号・観測の根拠", "✎ Title and observation evidence"),
379
+ ...(!completed ? [t("特別記章 — 未授与(正式成功なし)", "Special insignia — not awarded (no formal success)")] : debrief?.rareRecord !== "saved"
380
+ ? [debrief?.rareRecord === "legacy" ? t("特別記章 — 旧記録のため未判定", "Special insignia — legacy record, not evaluated") : t("特別記章 — 保存判定未取得(授与未確認)", "Special insignia — saved evaluation unavailable (award unconfirmed)")]
381
+ : RARE_RULES.map(rule => {
382
+ const evaluation = rareEvaluation(debrief.rare, rule.id);
383
+ const display = rareDisplay(rule.id, locale);
384
+ const evidence = evaluation?.evidence.map(code => {
385
+ if (code === "receipt.succeeded")
386
+ return t("正式成功", "Formal success");
387
+ if (code === "receipt.not-succeeded")
388
+ return t("正式成功なし", "No formal success");
389
+ const [condition, state] = code.split(".");
390
+ const name = condition === "clean-sweep" ? t("一発完遂", "Clean Sweep") : condition === "recovery" ? t("修正復帰", "Recovery") : t("並行稼働", "Overlap");
391
+ return `${name}: ${state === "true" ? t("確認", "confirmed") : state === "false" ? t("不成立確認", "confirmed false") : t("根拠不足", "insufficient evidence")}`;
392
+ }).join(" · ");
393
+ return `${display.title}${t("の条件", " conditions")} — ${display.reason} / ${evaluation?.status === "earned" ? t("成立", "Confirmed") : evaluation?.status === "not-earned" ? t("非該当", "Not applicable") : t("判定不能(根拠不足・未対応ルール含む)", "Unknown (insufficient evidence or unsupported rule)")}${evidence ? ` / ${evidence}` : ""}`;
394
+ })),
395
+ ...(traits.includes("連携作戦") ? [t("🐾 連携作戦 — 異なる子セッションの稼働区間が重複(成果成功の判定ではありません)", "🐾 Pack Tactics — activity spans of different child sessions overlap (not a success judgment)")] : []),
396
+ ...(completed && traits.includes("修正から復帰") ? [t("↩️ 修正から復帰 — 同じ子セッションで対象検証失敗→変更→同じ検証成功、任務完了", "↩️ Recovery — same child session: target check failed → edit → same check passed; mission completed")] : []),
397
+ ...(completed && traits.includes("一発完遂") ? [t("一発完遂の条件 — 完全な対象履歴で再試行・再計画・検証失敗なし、対象検証PASS・任務完了", "Clean Sweep conditions — complete target history, no retries, replans or failed checks; target checks PASS and mission completed")] : []),
398
+ ...(!traits.includes("連携作戦") && !(completed && traits.some(trait => trait === "修正から復帰" || trait === "一発完遂"))
399
+ ? [t("特性の根拠未確認(未観測と該当なしは区別できません)", "Trait evidence unconfirmed (unobserved cannot be distinguished from absent)")] : []),
400
+ t("※今回の観測のみ。別名・組合せの二重加算なし。称号は実行方針・検証・Review・Acceptanceを変更しません", "※This observation only. No duplicate counts for aliases or combinations. Titles do not change execution policy, validation, Review or Acceptance."),
401
+ t("※レアは厳密条件・組合せの特別認定。出現率は未計測。再表示は再授与ではありません", "※Special recognition for strict conditions or combinations; frequency unmeasured. Redisplay is not a new award."),
402
+ t("※記章目的の失敗作り・追加派遣・検証省略・再実行・支出は行いません", "※Do not manufacture failures, dispatch extra tasks, skip checks, rerun work or spend money for insignia."),
403
+ ];
404
+ }
405
+ export function renderDebrief(debrief, locale = resolveReportLocale()) {
406
+ const t = reportText(locale);
327
407
  const validationEfficiency = debrief?.validationEfficiency ?? {
328
408
  completed: 0, skipped: 0, rejected: 0, redundantMilliseconds: 0, medianMilliseconds: null, p90Milliseconds: null,
329
409
  };
@@ -353,42 +433,45 @@ export function renderDebrief(debrief) {
353
433
  } } : {}),
354
434
  ...(tail.some((entry) => entry.hostCostZero) ? { hostCostZero: true } : {}) });
355
435
  }
356
- const status = (value) => value === "PASS" ? "🟢 PASS" : value === "FAIL" ? "🔴 FAIL"
357
- : value === "WAIVED" ? "免除" : "未記録";
358
436
  const counts = new Map(packVisible.map((entry) => [entry.model, entry.count]));
359
437
  return [
360
- `モデル内訳 ${mix === null ? "usage未取得" : !hasPriced
361
- ? "予測費用未換算(モデル別token数)" : pricedUsd === 0
362
- ? "予測費用比率(合計$0・算出不可)" : partiallyPriced
363
- ? "換算済み予測費用の比率(一部未換算)" : "予測費用比率"}`,
438
+ `${t("モデル内訳", "Model breakdown")} ${mix === null ? t("usage未取得", "usage unavailable") : !hasPriced
439
+ ? t("予測費用未換算(モデル別token数)", "Estimate unpriced (tokens by model)") : pricedUsd === 0
440
+ ? t("予測費用比率(合計$0・算出不可)", "Estimated cost shares (total $0; unavailable)") : partiallyPriced
441
+ ? t("換算済み予測費用の比率(一部未換算)", "Priced estimate shares (partly unpriced)") : t("予測費用比率", "Estimated cost shares")}`,
364
442
  ...visible.map((entry) => {
365
443
  const count = counts.get(entry.model);
366
444
  const input = entry.inputCache === undefined ? 0 : entry.inputCache.uncachedInputTokens +
367
445
  entry.inputCache.cacheReadTokens + entry.inputCache.cacheWriteTokens;
368
- const cache = entry.inputCache === undefined || input === 0 ? "未取得" : `${(entry.inputCache.cacheReadTokens / input * 100).toFixed(0)}%`;
446
+ const cache = entry.inputCache === undefined || input === 0 ? t("未取得", "unavailable") : `${(entry.inputCache.cacheReadTokens / input * 100).toFixed(0)}%`;
369
447
  const cost = entry.estimatedCost;
370
448
  const share = cost?.pricedRequests
371
449
  ? pricedUsd > 0
372
- ? `${gauge(cost.usd / pricedUsd * 100)} ${(cost.usd / pricedUsd * 100).toFixed(1)}% · ${dollars(cost.usd)}${cost.complete ? "" : "(一部未換算)"}`
373
- : `比率算出不可 · ${dollars(cost.usd)}`
374
- : "費用未換算";
375
- return `🐕 ${label(entry.model)} ${share} · ${entry.tokens.toLocaleString("ja-JP")} tokens${count === undefined ? "" : ` ×${count}`} ↺${cache}${entry.hostCostZero ? "†" : ""}`;
450
+ ? `${gauge(cost.usd / pricedUsd * 100)} ${(cost.usd / pricedUsd * 100).toFixed(1)}% · ${dollars(cost.usd)}${cost.complete ? "" : t("(一部未換算)", " (partly unpriced)")}`
451
+ : `${t("比率算出不可", "share unavailable")} · ${dollars(cost.usd)}`
452
+ : t("費用未換算", "unpriced");
453
+ return `🐕 ${entry.model === "その他" ? t("その他", "Other") : entry.model === "未分類" ? t("未分類", "Unclassified") : label(entry.model)} ${share} · ${entry.tokens.toLocaleString(locale)} ${locale === "en" && entry.tokens === 1 ? "token" : "tokens"}${count === undefined ? "" : ` ×${count}`} ↺${cache}${entry.hostCostZero ? "†" : ""}`;
376
454
  }),
377
- ...(visible.some((entry) => entry.hostCostZero) ? [" †host費用0計上あり(無料・全体価格の評価ではありません)"] : []),
378
- `⚡ 実行重複率 ${debrief?.overlap !== undefined && debrief.overlap.wallMilliseconds > 0
455
+ ...(visible.some((entry) => entry.hostCostZero) ? [t(" †host費用0計上あり(無料・全体価格の評価ではありません)", " †Some host costs recorded as zero (not a claim of free use or total price)")] : []),
456
+ `⚡ ${t("実行重複率", "Execution overlap")} ${debrief?.overlap !== undefined && debrief.overlap.wallMilliseconds > 0
379
457
  ? `${(debrief.overlap.workerMilliseconds / debrief.overlap.wallMilliseconds).toFixed(2)}×`
380
- : pack?.length === 0 ? "対象なし(出撃なし)" : "稼働区間の記録不足"}`,
381
- " ※worker区間・速度倍率ではありません",
382
- `検証効率 完了${validationEfficiency.completed} · skip${validationEfficiency.skipped} · reject${validationEfficiency.rejected} · 重複回避${milliseconds(validationEfficiency.redundantMilliseconds)}`,
383
- `検証時間 median ${milliseconds(validationEfficiency.medianMilliseconds)} · p90 ${milliseconds(validationEfficiency.p90Milliseconds)}`,
384
- ...(debrief?.notes?.length ? [`計測範囲 ${debrief.notes.join(" · ")}`] : []),
458
+ : pack?.length === 0 ? t("対象なし(出撃なし)", "Not applicable (no dispatches)") : t("稼働区間の記録不足", "Activity spans incomplete")}`,
459
+ t(" ※worker区間・速度倍率ではありません", " ※Worker spans, not a speed multiplier"),
460
+ `${t("検証効率", "Validation efficiency")} ${t("完了", "completed ")}${validationEfficiency.completed} · skip${validationEfficiency.skipped} · reject${validationEfficiency.rejected} · ${t("重複回避", "redundancy avoided ")}${milliseconds(validationEfficiency.redundantMilliseconds, locale)}`,
461
+ `${t("検証時間", "Validation time")} median ${milliseconds(validationEfficiency.medianMilliseconds, locale)} · p90 ${milliseconds(validationEfficiency.p90Milliseconds, locale)}`,
462
+ ...(debrief?.notes?.length ? [`${t("計測範囲", "Measurement scope")} ${debrief.notes.map(note => locale === "ja" ? note : ({
463
+ "host履歴の取得が一部不足": "Partial host history",
464
+ "モデルIDまたはusageの記録が不足": "Model ID or usage records incomplete",
465
+ "稼働区間の時刻が不足(token集計とは独立)": "Activity timestamps incomplete (independent of token aggregation)",
466
+ }[note] ?? "Additional measurement limitation")).join(" · ")}`] : []),
385
467
  ];
386
468
  }
387
- export function renderDebriefProof(debrief) {
469
+ export function renderDebriefProof(debrief, locale = resolveReportLocale()) {
470
+ const t = reportText(locale);
388
471
  const status = (value) => value === "PASS" ? "🟢 PASS" : value === "FAIL" ? "🔴 FAIL"
389
- : value === "WAIVED" ? "免除" : "未記録";
472
+ : value === "WAIVED" ? t("免除", "Waived") : t("未記録", "Unrecorded");
390
473
  const efficiency = debrief?.validationEfficiency;
391
474
  return { validation: `${status(debrief?.validation ?? "未確認")}${efficiency === undefined || efficiency.skipped === 0
392
- ? "" : `(再実行skip ${efficiency.skipped} / ${milliseconds(efficiency.redundantMilliseconds)})`}`,
393
- review: `${status(debrief?.review ?? "未確認")}${debrief?.reviewSource === "reviewer" ? "(reviewer報告)" : ""}` };
475
+ ? "" : t(`(再実行skip ${efficiency.skipped} / ${milliseconds(efficiency.redundantMilliseconds)})`, ` (rerun skipped ${efficiency.skipped} / ${milliseconds(efficiency.redundantMilliseconds)})`)}`,
476
+ review: `${status(debrief?.review ?? "未確認")}${debrief?.reviewSource === "reviewer" ? t("(reviewer報告)", " (reviewer report)") : ""}` };
394
477
  }
@@ -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
+ /** Static presentation guidance; observed values stay in ordinary tool results, not live instructions. */
5
+ 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.";
4
6
  /** Task-specific environment limits qualify general dependency setup guidance. */
5
7
  export declare const MISSION_DEPENDENCY_WORKFLOW = "Honor original dependency limits in setup and unit objectives. If the user permits installs only for\nproven-missing packages, an installed but incompatible package is not missing: do not replace it or\ndescribe it as missing compatible tooling. General registry/compatible-version guidance does not widen\nthat task-specific limit. Reuse a supplied environment; create one only when the original request permits.\nOtherwise carry out authorized dependency repair normally. If required behavior remains unverified under\nthose limits, retain in-scope work and report the limitation, not successful verification.";
6
8
  /** Ordinary requested Git delivery uses source scope, not repository control-storage scope. */
@@ -3,6 +3,21 @@ 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
+ /** Static presentation guidance; observed values stay in ordinary tool results, not live instructions. */
7
+ export const MISSION_PRESENTATION = `## Readable progress and return reports
8
+ Use short, meaningful headings in ordinary user-facing updates: ⚑ start, ◎ finding, ✎ note,
9
+ [!] obstacle, ↩ recovered only after recovery is observed. Keep warnings, code and machine status exact.
10
+ Use restrained spacing, not large ASCII frames, constant military metaphors, animation or typing delays.
11
+ Reserve a small margin note or doodle for a formal completion or earned distinction, not every message.
12
+ When an ordinary Mission tool result supplies work_quantity_display, copy that host-generated label into
13
+ your next useful user-facing progress update. No separate progress-only status call or model turn.
14
+ This is estimated workload for the whole request, NOT elapsed time, cost, budget or completed-unit share.
15
+ An estimate revision may decrease it; only formal request completion means 100%. Do not extrapolate,
16
+ recompute or invent it. Task metadata may be saved without being displayed by the client; Direct may
17
+ have no Task card. Use ordinary response text, never a fake Task or a claim that metadata proves UI delivery.
18
+ When complete_mission supplies return_report, append it verbatim exactly once outside any code fence.
19
+ The host-authored panel and formal receipt are authoritative; titles and decoration never prove success.
20
+ Do not regenerate metrics, hide unknowns, schedule another model turn, or repeat work just to render it.`;
6
21
  /** Task-specific environment limits qualify general dependency setup guidance. */
7
22
  export const MISSION_DEPENDENCY_WORKFLOW = `Honor original dependency limits in setup and unit objectives. If the user permits installs only for
8
23
  proven-missing packages, an installed but incompatible package is not missing: do not replace it or
@@ -297,6 +312,8 @@ ${MISSION_RESULT_HOLD_POLICY}
297
312
 
298
313
  ${MISSION_REFLECTION_WORKFLOW}
299
314
 
315
+ ${MISSION_PRESENTATION}
316
+
300
317
  Retain 🐾 Sortie presentation. When complete_mission returns return_report, append that host-authored
301
318
  Markdown verbatim exactly once after a concise final outcome and key checks, outside any code fence.
302
319
  Preserve the panel's headings, metrics, unknown values and caveats; do not recalculate, regenerate it or
@@ -521,6 +538,8 @@ to change its author. Dispatch the returned Task once; Operator recommendation d
521
538
  Use project-relative paths for repository read/search/shell; the native working directory is project_root.
522
539
  Do not reconstruct or prepend the absolute workspace path. Preserve explicitly requested external paths.
523
540
 
541
+ ${MISSION_PRESENTATION}
542
+
524
543
  For release/benchmark work, retain the user's selected package/environment and record its receipt/hash.
525
544
  A local repack and a published tarball can have different hashes; that alone does not prohibit a requested
526
545
  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.1",
3
+ "version": "0.15.3",
4
4
  "description": "Adaptive execution harness for OpenCode and Codex: goal retention, validation, review, and evidence-backed handoff",
5
5
  "keywords": [
6
6
  "opencode",