throughline 0.10.11 → 0.10.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -1
- package/README.ja.md +7 -1
- package/README.md +10 -1
- package/docs/04_public_release_plan.md +1 -1
- package/docs/adr/0021-grok-host-capture.md +5 -0
- package/docs/adr/0023-handoff-disclosure-policy.md +35 -0
- package/package.json +1 -1
- package/src/cli/handoff-context.mjs +7 -4
- package/src/cli/handoff-context.test.mjs +32 -0
- package/src/docs-contract.test.mjs +7 -4
- package/src/product-ci-plan.test.mjs +150 -0
- package/src/resume-context.mjs +64 -16
- package/src/resume-context.test.mjs +58 -3
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,22 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.10.13] — 2026-09-02
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- 通常の引き継ぎ案内を最初の応答だけに限定し、project束縛済み補足から
|
|
18
|
+
`handoffDisclosure: "silent"`を指定したlauncherは案内を非表示にできるようにした。
|
|
19
|
+
- Throughline旧版がassistant本文へ付けた固定宣言行だけを次回引き継ぎ時に除外し、
|
|
20
|
+
ユーザーの引用と宣言後の会話本文は保持する。
|
|
21
|
+
|
|
22
|
+
## [0.10.12] — 2026-09-01
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- 変更した実装の依存関係から必要なテストと工場環境を選び、依存を確定できない変更だけ全件へ広げ、選択jobのskip・cancel・failureを最終gateで拒否する製品所有CIへ更新した。
|
|
27
|
+
- 文書だけの変更はLinuxの文書検査だけを実行し、共通・未分類の製品変更は従来どおり3環境で検証する。
|
|
28
|
+
|
|
13
29
|
## [0.10.11] — 2026-09-01
|
|
14
30
|
|
|
15
31
|
### Fixed
|
|
@@ -1414,7 +1430,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1414
1430
|
|
|
1415
1431
|
---
|
|
1416
1432
|
|
|
1417
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.
|
|
1433
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.13...HEAD
|
|
1434
|
+
[0.10.13]: https://github.com/kitepon/Throughline/compare/v0.10.12...v0.10.13
|
|
1435
|
+
[0.10.12]: https://github.com/kitepon/Throughline/compare/v0.10.11...v0.10.12
|
|
1418
1436
|
[0.10.11]: https://github.com/kitepon/Throughline/compare/v0.10.10...v0.10.11
|
|
1419
1437
|
[0.10.10]: https://github.com/kitepon/Throughline/compare/v0.10.9...v0.10.10
|
|
1420
1438
|
[0.10.9]: https://github.com/kitepon/Throughline/compare/v0.10.8...v0.10.9
|
package/README.ja.md
CHANGED
|
@@ -74,6 +74,11 @@ cwd は源セッションの `project_path` であり、呼び出し元 cwd は
|
|
|
74
74
|
`/clear` では起動しない。aiterm / `--rules` / `--from` は使わない。
|
|
75
75
|
macOS Terminal のみ。
|
|
76
76
|
|
|
77
|
+
通常のhandoff-contextは、引き継いだことを最初の応答で一度だけ案内し、後続応答では
|
|
78
|
+
繰り返させない。project束縛済み補足へ`handoffDisclosure: "silent"`を指定した埋込製品だけは
|
|
79
|
+
案内を出さない。旧版がassistant本文へ付けた固定宣言は次回引き継ぎから除外し、ユーザーの
|
|
80
|
+
引用と実際の応答本文は保持する。
|
|
81
|
+
|
|
77
82
|
新席は `~/.grok/sessions/<encodeURIComponent(cwd)>/` のトップレベル
|
|
78
83
|
ディレクトリ。Desktop の Inactive 畳みは成功条件にしない。L2 が無い源
|
|
79
84
|
(`merged_into` チェーンの空席など)では起動しない。新しい chat で 1〜2
|
|
@@ -428,7 +433,8 @@ throughline handoff-context --session codex:<thread-id> --json
|
|
|
428
433
|
成功時の`throughline.handoff_context.v1`は`schema`、`status`、`sessionId`、`context`だけを返す。
|
|
429
434
|
`context`はSessionStartと同じ予算付き継承文脈である。任意の`--supplement-file <path>`には
|
|
430
435
|
`throughline.handoff_supplement.v1`、源sessionと同じ`projectPath`、`title`と`content`からなる
|
|
431
|
-
`sections
|
|
436
|
+
`sections`を指定する。任意の`handoffDisclosure`は`visible`(既定)または`silent`を受け取る。
|
|
437
|
+
補足は会話記憶と同じ9,500字枠へ入り、別projectの補足は拒否する。
|
|
432
438
|
DB作成・migration・baton消費・session merge・
|
|
433
439
|
latest session推測・`sessions.merged_into`変更・L1/L2/L3 rowの所属変更は行わない。AItermは任意の
|
|
434
440
|
別harness portable forkでこの境界を使う。Observer feedはcompleted-turn projectionであり代替ではない。
|
package/README.md
CHANGED
|
@@ -817,13 +817,20 @@ entry to the `tasks` array yourself:
|
|
|
817
817
|
|
|
818
818
|
## Commands
|
|
819
819
|
|
|
820
|
-
**The current release is v0.10.
|
|
820
|
+
**The current release is v0.10.13.** Throughline supports Claude Code, Codex,
|
|
821
821
|
Grok, and Cursor as documented host adapters. The versioned, read-only
|
|
822
822
|
`handoff-context` boundary opens only an existing database and leaves baton
|
|
823
823
|
state, session ownership, and memory rows unchanged. Factory diagnostics,
|
|
824
824
|
Observer, runtime-error, capture, and normal handoff behavior remain available
|
|
825
825
|
under the same explicit-failure and local-only contracts.
|
|
826
826
|
|
|
827
|
+
Normal handoffs announce inherited continuity once in the first response and
|
|
828
|
+
explicitly forbid repeating that line in later responses. A project-bound
|
|
829
|
+
supplement may set `handoffDisclosure` to `silent` for an embedded product that
|
|
830
|
+
keeps continuity metadata out of its chat. Legacy Throughline disclosure lines
|
|
831
|
+
in assistant memory are omitted from the next handoff; user quotations and the
|
|
832
|
+
actual reply body remain intact.
|
|
833
|
+
|
|
827
834
|
| Command | What it does |
|
|
828
835
|
| ---------------------------------------------- | ------------------------------------------------------------ |
|
|
829
836
|
| `throughline install` | Register Claude user hooks/slash commands, the global Codex UserPromptSubmit/PostToolUse/Stop hooks, the global `$throughline` Codex skill, `~/.grok/hooks/throughline.json`, and the current VS Code monitor task when applicable |
|
|
@@ -916,6 +923,7 @@ inheritance text used by SessionStart. A launcher may append
|
|
|
916
923
|
{
|
|
917
924
|
"schema": "throughline.handoff_supplement.v1",
|
|
918
925
|
"projectPath": "/absolute/bot/project",
|
|
926
|
+
"handoffDisclosure": "silent",
|
|
919
927
|
"sections": [
|
|
920
928
|
{ "title": "Long-term memory", "content": "..." },
|
|
921
929
|
{ "title": "Relevant knowledge", "content": "..." }
|
|
@@ -923,6 +931,7 @@ inheritance text used by SessionStart. A launcher may append
|
|
|
923
931
|
}
|
|
924
932
|
```
|
|
925
933
|
|
|
934
|
+
`handoffDisclosure` is optional and accepts `visible` (the default) or `silent`.
|
|
926
935
|
The supplement is included only when `projectPath` matches the source session
|
|
927
936
|
and shares the 9,500-character budget with conversation memory. If the captured
|
|
928
937
|
session has no dialogue context yet, a valid project-bound supplement is returned
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## 現行状態
|
|
8
8
|
|
|
9
|
-
- release candidateのpackage版は `0.10.
|
|
9
|
+
- release candidateのpackage版は `0.10.13`。tagとnpmの公開確認はpublish後に行う。
|
|
10
10
|
- Claude Code、Codex、Grok、Cursorをfirst-class hostとして扱う。
|
|
11
11
|
- 現行DB schemaはv9。schemaの正本は [src/db.mjs](../src/db.mjs) の
|
|
12
12
|
`CURRENT_VERSION`、二相handoffの判断は
|
|
@@ -52,6 +52,11 @@ Grok 1.0.4 バイナリの hook 出力型は `GateHookJson`(PreToolUse deny/up
|
|
|
52
52
|
この後ユーザーが指示を出す。何もせず待機すること。
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
- 通常の`{context}`は引き継ぎの事実と保持ターン数を最初の応答で一度だけ案内し、後続応答では
|
|
56
|
+
繰り返させない。project束縛済み補足が`handoffDisclosure: silent`を指定した時だけ案内しない。
|
|
57
|
+
Throughline自身が旧版でassistantへ付けた固定宣言は後継へ渡す会話本文から除外し、ユーザーが
|
|
58
|
+
同じ文を引用した内容は保持する。
|
|
59
|
+
|
|
55
60
|
- spawn の cwd は源セッションの `project_path`。呼び出し元 cwd は使わない。共有 `GROK_HOME` の対話 `grok`。位置引数 `[PROMPT]` を使う。`--rules` / `--system-prompt-override` / `--agent` / 単発 `-p` は使わない。aiterm は使わない。
|
|
56
61
|
- Claude / Codex の `/tl` 契約は変えない。Grok `/tl` 成功後の副作用起動だけが配線対象。
|
|
57
62
|
- 一覧の正は `~/.grok/sessions/<encodeURIComponent(cwd)>/<id>/`。Desktop Inactive 畳みは成功条件にしない。
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# ADR 0023: 引き継ぎ案内を通常は一度だけ表示する
|
|
2
|
+
|
|
3
|
+
Date: 2026-09-02
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Accepted
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
v0.10.12以前の引き継ぎコンテキストは、モデルへ「Throughlineで前のセッションから
|
|
12
|
+
Nターン分の記憶を引き継いだ」と応答冒頭で宣言させていた。この命令は最初の応答と
|
|
13
|
+
ともに同じhost sessionへ残り、再起動が一度しか起きていなくても後続応答で同じ宣言を
|
|
14
|
+
模倣させた。さらに、その応答を次の引き継ぎへ再投入すると宣言が連鎖する。
|
|
15
|
+
|
|
16
|
+
一方、通常のThroughline利用では、引き継ぎが成立したことを最初の応答で確認できる案内は
|
|
17
|
+
必要である。BellTeamのような埋込製品は会話画面へ基盤のメタ情報を出さない。
|
|
18
|
+
|
|
19
|
+
## Decision
|
|
20
|
+
|
|
21
|
+
- 通常の引き継ぎは、引き継ぎの事実と保持ターン数を最初の応答で一度だけ案内する。
|
|
22
|
+
- 同じhost sessionの2回目以降の応答では案内を繰り返させない。
|
|
23
|
+
- project束縛済み`throughline.handoff_supplement.v1`は任意の`handoffDisclosure`として
|
|
24
|
+
`visible`(既定)または`silent`を持てる。`silent`を指定した埋込製品では案内しない。
|
|
25
|
+
- Throughline自身が旧版でassistantへ付けた固定宣言行は、後継へ渡すassistant本文から除外する。
|
|
26
|
+
- ユーザー発言に含まれる同じ文字列は会話内容として保持する。
|
|
27
|
+
- 引き継ぎの成立はsession対応、生成コンテキスト、継続応答の内容で検証する。宣言の反復を
|
|
28
|
+
成立判定に使わない。
|
|
29
|
+
|
|
30
|
+
## Consequences
|
|
31
|
+
|
|
32
|
+
- 通常のThroughline利用では従来の引き継ぎ案内が残る。
|
|
33
|
+
- 埋込製品は既存のproject分離境界を使って案内だけを非表示にできる。
|
|
34
|
+
- 旧宣言が保存済みのsessionも、次回の正規引き継ぎでメタ行を持ち越さない。
|
|
35
|
+
- 保存済みの原会話は監査記録として変更しない。
|
package/package.json
CHANGED
|
@@ -38,6 +38,8 @@ export function renderHandoffSupplement(value, projectPath) {
|
|
|
38
38
|
value?.schema !== HANDOFF_SUPPLEMENT_SCHEMA ||
|
|
39
39
|
!isAbsolute(value.projectPath) ||
|
|
40
40
|
!sameProjectPath(value.projectPath, projectPath) ||
|
|
41
|
+
(value.handoffDisclosure !== undefined &&
|
|
42
|
+
!['visible', 'silent'].includes(value.handoffDisclosure)) ||
|
|
41
43
|
!Array.isArray(value.sections) ||
|
|
42
44
|
value.sections.length === 0
|
|
43
45
|
) {
|
|
@@ -65,21 +67,22 @@ export function readHandoffContext(sessionId, {
|
|
|
65
67
|
const db = openReadOnlyDb(dbPath);
|
|
66
68
|
try {
|
|
67
69
|
let supplementContext = null;
|
|
70
|
+
let handoffDisclosure = 'visible';
|
|
68
71
|
if (supplementFile) {
|
|
69
72
|
const row = db.prepare(
|
|
70
73
|
'SELECT project_path FROM sessions WHERE session_id = ?',
|
|
71
74
|
).get(sessionId);
|
|
72
75
|
if (!row?.project_path) return null;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
);
|
|
76
|
+
const supplement = JSON.parse(readFileSync(supplementFile, 'utf8'));
|
|
77
|
+
supplementContext = renderHandoffSupplement(supplement, row.project_path);
|
|
78
|
+
handoffDisclosure = supplement.handoffDisclosure ?? 'visible';
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
const separator = supplementContext ? '\n\n' : '';
|
|
80
82
|
const context = buildBudgetedResumeContext(db, {
|
|
81
83
|
sessionId,
|
|
82
84
|
isInheritance: true,
|
|
85
|
+
handoffDisclosure,
|
|
83
86
|
maxChars: INJECTION_BUDGET_CHARS - (supplementContext?.length ?? 0) - separator.length,
|
|
84
87
|
})?.text ?? null;
|
|
85
88
|
if (!context && !supplementContext) return null;
|
|
@@ -169,6 +169,38 @@ test('handoff-context adds a project-bound supplement inside the shared budget',
|
|
|
169
169
|
}
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
test('handoff-context supplement can silence disclosure without changing normal default', () => {
|
|
173
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-handoff-silent-'));
|
|
174
|
+
try {
|
|
175
|
+
const { db } = createFixture(home);
|
|
176
|
+
db.close();
|
|
177
|
+
const supplementFile = join(home, 'supplement.json');
|
|
178
|
+
writeFileSync(supplementFile, JSON.stringify({
|
|
179
|
+
schema: 'throughline.handoff_supplement.v1',
|
|
180
|
+
projectPath: '/work/project',
|
|
181
|
+
handoffDisclosure: 'silent',
|
|
182
|
+
sections: [{ title: 'Botプロフィール', content: 'BellTeamのBot' }],
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
const normal = runCli(home);
|
|
186
|
+
assert.equal(normal.status, 0, normal.stderr);
|
|
187
|
+
assert.match(JSON.parse(normal.stdout).context, /この引き継ぎ直後の最初の応答だけ/);
|
|
188
|
+
|
|
189
|
+
const silent = runCli(home, [
|
|
190
|
+
'handoff-context', '--session', SESSION_ID, '--json',
|
|
191
|
+
'--supplement-file', supplementFile,
|
|
192
|
+
]);
|
|
193
|
+
assert.equal(silent.status, 0, silent.stderr);
|
|
194
|
+
const context = JSON.parse(silent.stdout).context;
|
|
195
|
+
assert.doesNotMatch(context, /この引き継ぎ直後の最初の応答だけ/);
|
|
196
|
+
assert.doesNotMatch(context, /Throughline で前のセッションから .* ターン分/);
|
|
197
|
+
assert.match(context, /BellTeamのBot/);
|
|
198
|
+
assert.match(context, /所有権を変えずに記憶を渡して/);
|
|
199
|
+
} finally {
|
|
200
|
+
rmSync(home, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
172
204
|
test('handoff-context returns a project-bound supplement when the captured session has no dialogue yet', () => {
|
|
173
205
|
const home = mkdtempSync(join(tmpdir(), 'tl-handoff-supplement-only-'));
|
|
174
206
|
try {
|
|
@@ -24,16 +24,19 @@ test('Markdown-only CI runs the product-owned documentation contract', () => {
|
|
|
24
24
|
);
|
|
25
25
|
assert.match(
|
|
26
26
|
workflow,
|
|
27
|
-
/documentation-command:\s+npm
|
|
27
|
+
/documentation-command:\s+npm run verify:docs/,
|
|
28
28
|
);
|
|
29
29
|
assert.doesNotMatch(workflow, /documentation-command:\s*(?:["']{2})?\s*$/m);
|
|
30
30
|
|
|
31
31
|
const reusable = readFileSync(new URL('../.github/workflows/product-full-ci.yml', import.meta.url), 'utf8');
|
|
32
|
-
|
|
32
|
+
const runner = readFileSync(new URL('../scripts/run-product-ci-tests.mjs', import.meta.url), 'utf8');
|
|
33
|
+
assert.match(reusable, /name: documentation dependency install/);
|
|
34
|
+
assert.match(reusable, /scripts\/product-ci-plan\.mjs verify/);
|
|
33
35
|
assert.ok(
|
|
34
|
-
reusable.indexOf('
|
|
35
|
-
'documentation
|
|
36
|
+
reusable.indexOf('documentation dependency install') < reusable.indexOf('- name: documentation check'),
|
|
37
|
+
'documentation dependencies must be installed before verification',
|
|
36
38
|
);
|
|
39
|
+
assert.doesNotMatch(runner, /npm\.cmd|cmd\.exe/);
|
|
37
40
|
|
|
38
41
|
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
39
42
|
const result = spawnPortableSync(npmCommand, ['run', 'verify:docs', '--silent'], {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ALL_ENVIRONMENTS,
|
|
10
|
+
classifyPaths,
|
|
11
|
+
selectTestFiles,
|
|
12
|
+
verifyResults,
|
|
13
|
+
} from '../scripts/product-ci-plan.mjs';
|
|
14
|
+
|
|
15
|
+
const SCRIPT = resolve(import.meta.dirname, '..', 'scripts', 'product-ci-plan.mjs');
|
|
16
|
+
|
|
17
|
+
function git(root, ...args) {
|
|
18
|
+
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
19
|
+
assert.equal(result.status, 0, result.stderr);
|
|
20
|
+
return result.stdout.trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function repository(t) {
|
|
24
|
+
const root = await mkdtemp(join(tmpdir(), 'throughline-ci-plan-'));
|
|
25
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
26
|
+
git(root, 'init');
|
|
27
|
+
git(root, 'config', 'user.name', 'CI test');
|
|
28
|
+
git(root, 'config', 'user.email', 'ci@example.invalid');
|
|
29
|
+
await writeFile(join(root, 'README.md'), '# fixture\n', 'utf8');
|
|
30
|
+
git(root, 'add', 'README.md');
|
|
31
|
+
git(root, 'commit', '-m', 'initial');
|
|
32
|
+
const base = git(root, 'rev-parse', 'HEAD');
|
|
33
|
+
await mkdir(join(root, 'src', 'os'), { recursive: true });
|
|
34
|
+
await writeFile(join(root, 'src', 'os', 'windows-acl.mjs'), '// fixture\n', 'utf8');
|
|
35
|
+
git(root, 'add', 'src/os/windows-acl.mjs');
|
|
36
|
+
git(root, 'commit', '-m', 'windows');
|
|
37
|
+
return { root, base, head: git(root, 'rev-parse', 'HEAD') };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('文書だけの変更はLinuxの文書検査へ分類する', () => {
|
|
41
|
+
const plan = classifyPaths(['README.md', 'docs/04_public_release_plan.md']);
|
|
42
|
+
assert.equal(plan.productChange, false);
|
|
43
|
+
assert.deepEqual(plan.environments, ['linux-workstation']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('macOS固有変更はmacOSとLinuxへ分類する', () => {
|
|
47
|
+
const plan = classifyPaths(['src/os/macos-terminal.mjs']);
|
|
48
|
+
assert.equal(plan.productChange, true);
|
|
49
|
+
assert.deepEqual(plan.environments, ['macos-native', 'linux-workstation']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('Windows固有変更はLinuxとWindowsへ分類する', () => {
|
|
53
|
+
const plan = classifyPaths(['src/os/windows-acl.mjs']);
|
|
54
|
+
assert.equal(plan.productChange, true);
|
|
55
|
+
assert.deepEqual(plan.environments, ['linux-workstation', 'windows-native']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('共通変更と未分類変更は全環境へ広げる', () => {
|
|
59
|
+
for (const paths of [['src/db.mjs'], ['new-product/file.txt'], ['.github/workflows/test.yml'], []]) {
|
|
60
|
+
const plan = classifyPaths(paths);
|
|
61
|
+
assert.equal(plan.productChange, true);
|
|
62
|
+
assert.deepEqual(plan.environments, ALL_ENVIRONMENTS);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('実装変更は依存するテストだけを選ぶ', () => {
|
|
67
|
+
const plan = selectTestFiles(['src/terminal-size.mjs'], resolve(import.meta.dirname, '..'));
|
|
68
|
+
assert.equal(plan.testScope, 'selected');
|
|
69
|
+
assert.ok(plan.testFiles.includes('src/terminal-size.test.mjs'));
|
|
70
|
+
assert.ok(plan.testFiles.length < 82);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('依存を確定できない変更は全テストへ広げる', () => {
|
|
74
|
+
assert.deepEqual(selectTestFiles(['package.json'], resolve(import.meta.dirname, '..')), {
|
|
75
|
+
testScope: 'all',
|
|
76
|
+
testFiles: [],
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('結果判定は選択されたfullの成功と文書時の明示skipだけを受け入れる', () => {
|
|
81
|
+
assert.doesNotThrow(() => verifyResults({
|
|
82
|
+
classifyResult: 'success',
|
|
83
|
+
fullResult: 'success',
|
|
84
|
+
productChange: 'true',
|
|
85
|
+
}));
|
|
86
|
+
assert.doesNotThrow(() => verifyResults({
|
|
87
|
+
classifyResult: 'success',
|
|
88
|
+
fullResult: 'skipped',
|
|
89
|
+
productChange: 'false',
|
|
90
|
+
}));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('結果判定は分類失敗と理由不明skipを拒否する', () => {
|
|
94
|
+
assert.throws(() => verifyResults({
|
|
95
|
+
classifyResult: 'failure',
|
|
96
|
+
fullResult: 'skipped',
|
|
97
|
+
productChange: '',
|
|
98
|
+
}), /変更分類jobが成功していません/u);
|
|
99
|
+
assert.throws(() => verifyResults({
|
|
100
|
+
classifyResult: 'success',
|
|
101
|
+
fullResult: 'skipped',
|
|
102
|
+
productChange: 'true',
|
|
103
|
+
}), /選択計画と実行結果が一致しません/u);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('plan入口はGit履歴から変更を分類してGitHub outputへ書く', async (t) => {
|
|
107
|
+
const repo = await repository(t);
|
|
108
|
+
const output = join(repo.root, 'github-output.txt');
|
|
109
|
+
const result = spawnSync(process.execPath, [SCRIPT, 'plan'], {
|
|
110
|
+
cwd: repo.root,
|
|
111
|
+
encoding: 'utf8',
|
|
112
|
+
env: {
|
|
113
|
+
...process.env,
|
|
114
|
+
EVENT_NAME: 'push',
|
|
115
|
+
BEFORE_SHA: repo.base,
|
|
116
|
+
BASE_SHA: '',
|
|
117
|
+
DISPATCH_BASE: '',
|
|
118
|
+
GITHUB_REF: 'refs/heads/main',
|
|
119
|
+
GITHUB_SHA: repo.head,
|
|
120
|
+
GITHUB_OUTPUT: output,
|
|
121
|
+
REQUESTED_ENVIRONMENT: 'all',
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
assert.equal(result.status, 0, result.stderr);
|
|
125
|
+
const outputs = Object.fromEntries((await readFile(output, 'utf8')).trim().split('\n')
|
|
126
|
+
.map((line) => line.split(/=(.*)/su).slice(0, 2)));
|
|
127
|
+
assert.equal(outputs.product_change, 'true');
|
|
128
|
+
assert.deepEqual(JSON.parse(outputs.environments), ['linux-workstation', 'windows-native']);
|
|
129
|
+
assert.equal(outputs.test_scope, 'all');
|
|
130
|
+
assert.equal(outputs.comparison_base, repo.base);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('plan入口はzero SHAを明示失敗にする', async (t) => {
|
|
134
|
+
const repo = await repository(t);
|
|
135
|
+
const result = spawnSync(process.execPath, [SCRIPT, 'plan'], {
|
|
136
|
+
cwd: repo.root,
|
|
137
|
+
encoding: 'utf8',
|
|
138
|
+
env: {
|
|
139
|
+
...process.env,
|
|
140
|
+
EVENT_NAME: 'push',
|
|
141
|
+
BEFORE_SHA: '0'.repeat(40),
|
|
142
|
+
GITHUB_REF: 'refs/heads/main',
|
|
143
|
+
GITHUB_SHA: repo.head,
|
|
144
|
+
GITHUB_OUTPUT: join(repo.root, 'github-output.txt'),
|
|
145
|
+
REQUESTED_ENVIRONMENT: 'all',
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
assert.equal(result.status, 2);
|
|
149
|
+
assert.match(result.stderr, /CI comparison baseが必要です/u);
|
|
150
|
+
});
|
package/src/resume-context.mjs
CHANGED
|
@@ -24,11 +24,14 @@
|
|
|
24
24
|
import { buildHandoffRecord } from './handoff-record.mjs';
|
|
25
25
|
import { groupL3ByTurn, buildPartsSummary } from './l3-summary.mjs';
|
|
26
26
|
|
|
27
|
-
const RESUME_HEADER_TEMPLATE = (turnCount) =>
|
|
27
|
+
const RESUME_HEADER_TEMPLATE = (turnCount, handoffDisclosure) =>
|
|
28
28
|
`## Throughline: 直前スレッドの継続応答用コンテキスト(${turnCount} ターン分の文脈を保持)\n` +
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
(handoffDisclosure === 'visible'
|
|
30
|
+
? `\n` +
|
|
31
|
+
`**この引き継ぎ直後の最初の応答だけ、冒頭で以下を 1 行宣言してください**:\n` +
|
|
32
|
+
`> 「Throughline で前のセッションから ${turnCount} ターン分の記憶を引き継いだ状態で続けます」\n` +
|
|
33
|
+
`**2 回目以降の応答では、この宣言を繰り返さないでください。**\n`
|
|
34
|
+
: '') +
|
|
32
35
|
`\n` +
|
|
33
36
|
`**読み方 (重要):**\n` +
|
|
34
37
|
`- 下記「現在地」「L1」「L2」は **あなた自身が直前にユーザーと交わした会話** です ` +
|
|
@@ -52,6 +55,17 @@ const RESUME_HEADER_TEMPLATE = (turnCount) =>
|
|
|
52
55
|
|
|
53
56
|
const NORMAL_HEADER = '## Throughline: セッション記憶';
|
|
54
57
|
|
|
58
|
+
// v0.10.12以前にThroughline自身がassistantへ付与させていた宣言行。
|
|
59
|
+
// 製品由来のメタ情報だけを引き継ぎ材料から除き、会話本文への模倣連鎖を止める。
|
|
60
|
+
// userが同じ文を引用した場合は会話内容なので保持する。
|
|
61
|
+
const LEGACY_HANDOFF_DISCLOSURE =
|
|
62
|
+
/^「Throughline で前のセッションから \d+ ターン分の記憶を引き継いだ状態で続けます」[ \t]*(?:\r?\n[ \t]*)*/u;
|
|
63
|
+
|
|
64
|
+
function textForHandoff(row) {
|
|
65
|
+
if (row.role !== 'assistant') return row.text;
|
|
66
|
+
return row.text.replace(LEGACY_HANDOFF_DISCLOSURE, '');
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
// 現在地アンカーは最新 user / assistant 本文を再掲する。長すぎると注入全体が膨らむので
|
|
56
70
|
// この文字数で打ち切り、全文は L2 セクション側を参照させる。
|
|
57
71
|
const ANCHOR_MAX_CHARS = 600;
|
|
@@ -93,7 +107,13 @@ function pickLatestExchange(recentBodies) {
|
|
|
93
107
|
* l2Lines: { text: string, time: string }[],
|
|
94
108
|
* } | null}
|
|
95
109
|
*/
|
|
96
|
-
function buildResumeSections(
|
|
110
|
+
function buildResumeSections(
|
|
111
|
+
db,
|
|
112
|
+
{ sessionId, isInheritance, excludeOriginId = null, handoffDisclosure = 'visible' },
|
|
113
|
+
) {
|
|
114
|
+
if (!['visible', 'silent'].includes(handoffDisclosure)) {
|
|
115
|
+
throw new TypeError('invalid handoff disclosure');
|
|
116
|
+
}
|
|
97
117
|
const record = buildHandoffRecord(db, {
|
|
98
118
|
sessionId,
|
|
99
119
|
isInheritance,
|
|
@@ -102,15 +122,21 @@ function buildResumeSections(db, { sessionId, isInheritance, excludeOriginId = n
|
|
|
102
122
|
if (!record) return null;
|
|
103
123
|
|
|
104
124
|
const turnCount = record.stats.preservedContextRows;
|
|
105
|
-
const header = isInheritance
|
|
125
|
+
const header = isInheritance
|
|
126
|
+
? RESUME_HEADER_TEMPLATE(turnCount, handoffDisclosure)
|
|
127
|
+
: NORMAL_HEADER;
|
|
128
|
+
const recentBodies = record.memory.recentBodies.map((row) => ({
|
|
129
|
+
...row,
|
|
130
|
+
text: textForHandoff(row),
|
|
131
|
+
}));
|
|
106
132
|
|
|
107
133
|
const l3ByTurn = groupL3ByTurn(record.references.l3);
|
|
108
134
|
|
|
109
135
|
// 現在地アンカー: 引き継ぎ時のみ、最新 user / assistant turn をヘッダ直下に再掲する。
|
|
110
136
|
// L2 末尾アンカーだけだと、長い L2 で注意が前半に固着して話の流れを取り違える事例があった。
|
|
111
137
|
const anchorLines = [];
|
|
112
|
-
if (isInheritance &&
|
|
113
|
-
const { latestUser, latestAssistant } = pickLatestExchange(
|
|
138
|
+
if (isInheritance && recentBodies.length > 0) {
|
|
139
|
+
const { latestUser, latestAssistant } = pickLatestExchange(recentBodies);
|
|
114
140
|
if (latestUser) {
|
|
115
141
|
anchorLines.push(
|
|
116
142
|
`**最新ユーザー指示** [${latestUser.time}]: ${truncateForAnchor(latestUser.text)}`,
|
|
@@ -145,14 +171,14 @@ function buildResumeSections(db, { sessionId, isInheritance, excludeOriginId = n
|
|
|
145
171
|
// 同じターンの user 行と assistant 行の両方に貼ると同じ内容が二度出て紛らわしい。
|
|
146
172
|
const l2Lines = [];
|
|
147
173
|
const lastIdxPerTurn = new Map();
|
|
148
|
-
for (let i = 0; i <
|
|
149
|
-
const r =
|
|
174
|
+
for (let i = 0; i < recentBodies.length; i += 1) {
|
|
175
|
+
const r = recentBodies[i];
|
|
150
176
|
if (!r.text) continue;
|
|
151
177
|
const key = `${r.originSessionId}\x00${r.turnNumber}`;
|
|
152
178
|
lastIdxPerTurn.set(key, i);
|
|
153
179
|
}
|
|
154
|
-
for (let i = 0; i <
|
|
155
|
-
const r =
|
|
180
|
+
for (let i = 0; i < recentBodies.length; i += 1) {
|
|
181
|
+
const r = recentBodies[i];
|
|
156
182
|
if (!r.text) continue;
|
|
157
183
|
const key = `${r.originSessionId}\x00${r.turnNumber}`;
|
|
158
184
|
const isLastOfTurn = lastIdxPerTurn.get(key) === i;
|
|
@@ -207,9 +233,20 @@ function joinSections({ header, anchorLines, l1Lines, l2Lines }, { l1Note = null
|
|
|
207
233
|
*/
|
|
208
234
|
export function buildResumeContext(
|
|
209
235
|
db,
|
|
210
|
-
{
|
|
236
|
+
{
|
|
237
|
+
sessionId,
|
|
238
|
+
isInheritance,
|
|
239
|
+
excludeOriginId = null,
|
|
240
|
+
inflightMemo: _ignoredMemo = null,
|
|
241
|
+
handoffDisclosure = 'visible',
|
|
242
|
+
},
|
|
211
243
|
) {
|
|
212
|
-
const sections = buildResumeSections(db, {
|
|
244
|
+
const sections = buildResumeSections(db, {
|
|
245
|
+
sessionId,
|
|
246
|
+
isInheritance,
|
|
247
|
+
excludeOriginId,
|
|
248
|
+
handoffDisclosure,
|
|
249
|
+
});
|
|
213
250
|
if (!sections) return null;
|
|
214
251
|
return joinSections(sections);
|
|
215
252
|
}
|
|
@@ -344,9 +381,20 @@ function buildGuidanceLines({ sessionId, boundaryMs, remainingTurns, remainingRa
|
|
|
344
381
|
*/
|
|
345
382
|
export function buildBudgetedResumeContext(
|
|
346
383
|
db,
|
|
347
|
-
{
|
|
384
|
+
{
|
|
385
|
+
sessionId,
|
|
386
|
+
isInheritance,
|
|
387
|
+
excludeOriginId = null,
|
|
388
|
+
maxChars = INJECTION_BUDGET_CHARS,
|
|
389
|
+
handoffDisclosure = 'visible',
|
|
390
|
+
},
|
|
348
391
|
) {
|
|
349
|
-
const sections = buildResumeSections(db, {
|
|
392
|
+
const sections = buildResumeSections(db, {
|
|
393
|
+
sessionId,
|
|
394
|
+
isInheritance,
|
|
395
|
+
excludeOriginId,
|
|
396
|
+
handoffDisclosure,
|
|
397
|
+
});
|
|
350
398
|
if (!sections) return null;
|
|
351
399
|
|
|
352
400
|
const lineCost = (s) => s.length + 1; // join('\n') 分
|
|
@@ -113,10 +113,10 @@ test('buildResumeContext: header is terse and announces the Bash invocation cont
|
|
|
113
113
|
assert.match(text, /新規依頼ではなく、上記スレッドの \*\*続き\*\*/);
|
|
114
114
|
assert.match(text, /続きよろしく.*OK.*次は?/s);
|
|
115
115
|
assert.match(text, /新規会話ではない/);
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
assert.match(text, /応答の冒頭で必ず以下を 1 行宣言/);
|
|
116
|
+
// 通常のThroughline引き継ぎは最初の一度だけ可視化し、後続応答では反復させない。
|
|
117
|
+
assert.match(text, /この引き継ぎ直後の最初の応答だけ/);
|
|
119
118
|
assert.match(text, /Throughline で前のセッションから .* ターン分の記憶を引き継いだ状態で続けます/);
|
|
119
|
+
assert.match(text, /2 回目以降の応答では、この宣言を繰り返さない/);
|
|
120
120
|
assert.match(
|
|
121
121
|
text,
|
|
122
122
|
/\*\*各ターンの詳細\*\*: \*\*`Bash` ツールで `throughline detail HH:MM:SS` を実行\*\* \(該当ターンの本文+詳細を stdout に返します\)/,
|
|
@@ -130,6 +130,61 @@ test('buildResumeContext: header is terse and announces the Bash invocation cont
|
|
|
130
130
|
assert.match(text, /最新アシスタント発話の指示が、過去ターンのリストへの参照より上位/);
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
test('buildResumeContext: silent disclosure keeps inheritance internal', () => {
|
|
134
|
+
const db = makeDb();
|
|
135
|
+
insertBody(db, {
|
|
136
|
+
session: 'new',
|
|
137
|
+
origin: 'old',
|
|
138
|
+
turn: 1,
|
|
139
|
+
role: 'assistant',
|
|
140
|
+
text: '続きの本文',
|
|
141
|
+
createdAt: 1000,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const text = buildResumeContext(db, {
|
|
145
|
+
sessionId: 'new',
|
|
146
|
+
isInheritance: true,
|
|
147
|
+
handoffDisclosure: 'silent',
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
assert.ok(text);
|
|
151
|
+
assert.doesNotMatch(text, /この引き継ぎ直後の最初の応答だけ/);
|
|
152
|
+
assert.doesNotMatch(text, /Throughline で前のセッションから .* ターン分の記憶を引き継いだ状態で続けます/);
|
|
153
|
+
assert.match(text, /\[assistant\]: 続きの本文/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('buildResumeContext: legacy disclosure is removed only from assistant memory', () => {
|
|
157
|
+
const db = makeDb();
|
|
158
|
+
const disclosure = '「Throughline で前のセッションから 20 ターン分の記憶を引き継いだ状態で続けます」';
|
|
159
|
+
insertBody(db, {
|
|
160
|
+
session: 'new',
|
|
161
|
+
origin: 'old',
|
|
162
|
+
turn: 1,
|
|
163
|
+
role: 'user',
|
|
164
|
+
text: `この表示は何? ${disclosure}`,
|
|
165
|
+
createdAt: 1000,
|
|
166
|
+
});
|
|
167
|
+
insertBody(db, {
|
|
168
|
+
session: 'new',
|
|
169
|
+
origin: 'old',
|
|
170
|
+
turn: 1,
|
|
171
|
+
role: 'assistant',
|
|
172
|
+
text: `${disclosure}\n\n本題の応答です。`,
|
|
173
|
+
createdAt: 1001,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const text = buildResumeContext(db, {
|
|
177
|
+
sessionId: 'new',
|
|
178
|
+
isInheritance: true,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
assert.ok(text);
|
|
182
|
+
assert.equal(text.split(disclosure).length - 1, 2, 'user quote remains in anchor and L2 only');
|
|
183
|
+
assert.doesNotMatch(text, /\[assistant\]: 「Throughline で前のセッションから/);
|
|
184
|
+
assert.match(text, /\[assistant\]: 本題の応答です。/);
|
|
185
|
+
assert.match(text, /\*\*直前のアシスタント\*\* \[\d\d:\d\d:\d\d\]: 本題の応答です。/);
|
|
186
|
+
});
|
|
187
|
+
|
|
133
188
|
test('buildResumeContext: 現在地 anchor surfaces the latest user/assistant exchange above L1/L2', () => {
|
|
134
189
|
const db = makeDb();
|
|
135
190
|
// 25 turns to exercise an L2 window edge and ensure the anchor picks the newest.
|