specpi 0.10.0 → 0.11.2

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.
@@ -1,86 +1,86 @@
1
- {
2
- "name": "specpi-browser-runtime",
3
- "version": "0.1.0",
4
- "lockfileVersion": 3,
5
- "requires": true,
6
- "packages": {
7
- "": {
8
- "name": "specpi-browser-runtime",
9
- "version": "0.1.0",
10
- "license": "MIT",
11
- "dependencies": {
12
- "pixelmatch": "7.2.0",
13
- "playwright": "1.62.1",
14
- "pngjs": "7.0.0"
15
- },
16
- "engines": {
17
- "node": ">=22.19.0"
18
- }
19
- },
20
- "node_modules/fsevents": {
21
- "version": "2.3.2",
22
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
23
- "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
24
- "hasInstallScript": true,
25
- "license": "MIT",
26
- "optional": true,
27
- "os": [
28
- "darwin"
29
- ],
30
- "engines": {
31
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
32
- }
33
- },
34
- "node_modules/pixelmatch": {
35
- "version": "7.2.0",
36
- "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.2.0.tgz",
37
- "integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==",
38
- "license": "ISC",
39
- "dependencies": {
40
- "pngjs": "^7.0.0"
41
- },
42
- "bin": {
43
- "pixelmatch": "bin/pixelmatch"
44
- }
45
- },
46
- "node_modules/playwright": {
47
- "version": "1.62.1",
48
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
49
- "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
50
- "license": "Apache-2.0",
51
- "dependencies": {
52
- "playwright-core": "1.62.1"
53
- },
54
- "bin": {
55
- "playwright": "cli.js"
56
- },
57
- "engines": {
58
- "node": ">=20"
59
- },
60
- "optionalDependencies": {
61
- "fsevents": "2.3.2"
62
- }
63
- },
64
- "node_modules/playwright-core": {
65
- "version": "1.62.1",
66
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
67
- "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
68
- "license": "Apache-2.0",
69
- "bin": {
70
- "playwright-core": "cli.js"
71
- },
72
- "engines": {
73
- "node": ">=20"
74
- }
75
- },
76
- "node_modules/pngjs": {
77
- "version": "7.0.0",
78
- "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
79
- "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
80
- "license": "MIT",
81
- "engines": {
82
- "node": ">=14.19.0"
83
- }
84
- }
85
- }
86
- }
1
+ {
2
+ "name": "specpi-browser-runtime",
3
+ "version": "0.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "specpi-browser-runtime",
9
+ "version": "0.1.0",
10
+ "license": "MIT",
11
+ "dependencies": {
12
+ "pixelmatch": "7.2.0",
13
+ "playwright": "1.62.1",
14
+ "pngjs": "7.0.0"
15
+ },
16
+ "engines": {
17
+ "node": ">=22.19.0"
18
+ }
19
+ },
20
+ "node_modules/fsevents": {
21
+ "version": "2.3.2",
22
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
23
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
24
+ "hasInstallScript": true,
25
+ "license": "MIT",
26
+ "optional": true,
27
+ "os": [
28
+ "darwin"
29
+ ],
30
+ "engines": {
31
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
32
+ }
33
+ },
34
+ "node_modules/pixelmatch": {
35
+ "version": "7.2.0",
36
+ "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.2.0.tgz",
37
+ "integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==",
38
+ "license": "ISC",
39
+ "dependencies": {
40
+ "pngjs": "^7.0.0"
41
+ },
42
+ "bin": {
43
+ "pixelmatch": "bin/pixelmatch"
44
+ }
45
+ },
46
+ "node_modules/playwright": {
47
+ "version": "1.62.1",
48
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
49
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
50
+ "license": "Apache-2.0",
51
+ "dependencies": {
52
+ "playwright-core": "1.62.1"
53
+ },
54
+ "bin": {
55
+ "playwright": "cli.js"
56
+ },
57
+ "engines": {
58
+ "node": ">=20"
59
+ },
60
+ "optionalDependencies": {
61
+ "fsevents": "2.3.2"
62
+ }
63
+ },
64
+ "node_modules/playwright-core": {
65
+ "version": "1.62.1",
66
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
67
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
68
+ "license": "Apache-2.0",
69
+ "bin": {
70
+ "playwright-core": "cli.js"
71
+ },
72
+ "engines": {
73
+ "node": ">=20"
74
+ }
75
+ },
76
+ "node_modules/pngjs": {
77
+ "version": "7.0.0",
78
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
79
+ "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
80
+ "license": "MIT",
81
+ "engines": {
82
+ "node": ">=14.19.0"
83
+ }
84
+ }
85
+ }
86
+ }
@@ -1,15 +1,15 @@
1
- {
2
- "name": "specpi-browser-runtime",
3
- "private": true,
4
- "version": "0.1.0",
5
- "description": "Pinned local browser runtime managed by the SpecPi installer",
6
- "license": "MIT",
7
- "dependencies": {
8
- "pixelmatch": "7.2.0",
9
- "playwright": "1.62.1",
10
- "pngjs": "7.0.0"
11
- },
12
- "engines": {
13
- "node": ">=22.19.0"
14
- }
15
- }
1
+ {
2
+ "name": "specpi-browser-runtime",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "description": "Pinned local browser runtime managed by the SpecPi installer",
6
+ "license": "MIT",
7
+ "dependencies": {
8
+ "pixelmatch": "7.2.0",
9
+ "playwright": "1.62.1",
10
+ "pngjs": "7.0.0"
11
+ },
12
+ "engines": {
13
+ "node": ">=22.19.0"
14
+ }
15
+ }
@@ -1,47 +1,47 @@
1
- $ErrorActionPreference = 'Stop'
2
- $maxInput = 131072
3
- $maxTokens = 4096
4
- $maxCommands = 128
5
- $maxLiteral = 65536
6
- $text = [Console]::In.ReadToEnd()
7
- $edition = if ($PSVersionTable.PSEdition -eq 'Desktop') { 'Desktop' } else { 'Core' }
8
- function Clip([string]$s, [int]$n = 512) { if ($null -eq $s) { return $null }; if ($s.Length -gt $n) { return $s.Substring(0, $n) }; return $s }
9
- function Pos($e) { if ($null -eq $e) { return @{ start = 0; end = 0 } }; return @{ start = [int]$e.StartOffset; end = [int]$e.EndOffset } }
10
- function DynamicNode($n) { return ($n -is [System.Management.Automation.Language.ScriptBlockExpressionAst] -or $n -is [System.Management.Automation.Language.SubExpressionAst] -or $n -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -or $n -is [System.Management.Automation.Language.ExpandableStringExpressionAst]) }
11
- try {
12
- if ($text.Length -gt $maxInput) { throw 'input limit exceeded' }
13
- $tokens = $null; $errors = $null
14
- $ast = [System.Management.Automation.Language.Parser]::ParseInput($text, [ref]$tokens, [ref]$errors)
15
- $errorItems = @($errors | Select-Object -First 32 | ForEach-Object { $p = Pos $_.Extent; @{ errorId = Clip $_.ErrorId 96; start = $p.start; end = $p.end; message = Clip $_.Message 256 } })
16
- $commands = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true) | ForEach-Object {
17
- $command = $_; $p = Pos $command.Extent; $name = $command.GetCommandName()
18
- $pipeline = $command.Parent; while ($null -ne $pipeline -and -not ($pipeline -is [System.Management.Automation.Language.PipelineAst])) { $pipeline = $pipeline.Parent }
19
- $pipelineStart = if ($null -ne $pipeline) { [int]$pipeline.Extent.StartOffset } else { $null }
20
- $elements = @($command.CommandElements | Select-Object -First 256 | ForEach-Object {
21
- $ep = Pos $_.Extent; $literal = $null
22
- $literalTruncated = $false; $elementDynamic = [bool](DynamicNode $_)
23
- if ($_ -is [System.Management.Automation.Language.CommandParameterAst]) {
24
- $rawLiteral = '-' + $_.ParameterName
25
- if ($null -ne $_.Argument) {
26
- if ($_.Argument -is [System.Management.Automation.Language.StringConstantExpressionAst] -or $_.Argument -is [System.Management.Automation.Language.ConstantExpressionAst]) { $rawLiteral += ':' + [string]$_.Argument.Value }
27
- else { $elementDynamic = $true }
28
- }
29
- $literalTruncated = $rawLiteral.Length -gt $maxLiteral; $literal = Clip $rawLiteral $maxLiteral
30
- } elseif ($_ -is [System.Management.Automation.Language.StringConstantExpressionAst] -or $_ -is [System.Management.Automation.Language.ConstantExpressionAst]) { $rawLiteral = [string]$_.Value; $literalTruncated = $rawLiteral.Length -gt $maxLiteral; $literal = Clip $rawLiteral $maxLiteral }
31
- $rawExtent = [string]$_.Extent.Text; $rawTruncated = $rawExtent.Length -gt $maxLiteral
32
- @{ astType = $_.GetType().Name; start = $ep.start; end = $ep.end; literal = $literal; literalTruncated = $literalTruncated; raw = (Clip $rawExtent $maxLiteral); rawTruncated = $rawTruncated; dynamic = $elementDynamic }
33
- })
34
- $reds = @($command.Redirections | Select-Object -First 128 | ForEach-Object { $rp = Pos $_.Extent; $target = $null; $targetTruncated = $false; if ($_.Location -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $rawTarget = [string]$_.Location.Value; $target = Clip $rawTarget 4096; $targetTruncated = $rawTarget.Length -gt 4096 }; @{ astType = $_.GetType().Name; start = $rp.start; end = $rp.end; targetLiteral = $target; targetTruncated = [bool]$targetTruncated; dynamic = $null -eq $target } })
35
- @{ start = $p.start; end = $p.end; pipelineStart = $pipelineStart; commandName = $name; invocationOperator = ([string]$command.InvocationOperator); elements = $elements; elementsTruncated = ($command.CommandElements.Count -gt 256); redirections = $reds; redirectionsTruncated = ($command.Redirections.Count -gt 128) }
36
- })
37
- $dynamic = @($ast.FindAll({ param($n) DynamicNode $n }, $true) | Select-Object -First 256 | ForEach-Object { $p = Pos $_.Extent; @{ kind = $_.GetType().Name; start = $p.start; end = $p.end } })
38
- $stop = @($tokens | Where-Object { ([string]$_.Kind -eq 'StopParsing') -or $_.Text -eq '--%' } | Select-Object -First 128 | ForEach-Object { $p = Pos $_.Extent; @{ start = $p.start; end = $p.end } })
39
- $limit = $null; if ($tokens.Count -gt $maxTokens) { $limit = 'tokens' }; if ($commands.Count -gt $maxCommands) { $limit = 'commands' }; if (@($commands | Where-Object { $_.elementsTruncated }).Count -gt 0) { $limit = 'elements' }; if (@($commands | Where-Object { $_.redirectionsTruncated }).Count -gt 0) { $limit = 'redirections' }
40
- $out = @{ schema = 1; ok = ($errorItems.Count -eq 0 -and $null -eq $limit); parser = @{ edition = $edition; version = [string]$PSVersionTable.PSVersion }; tokenCount = [int]$tokens.Count; errors = $errorItems; commands = @($commands | Select-Object -First $maxCommands); dynamicConstructs = $dynamic; stopParsingTokens = $stop }
41
- if ($null -ne $limit) { $out.limitExceeded = $limit }
42
- [Console]::Out.Write((ConvertTo-Json $out -Compress -Depth 16))
43
- } catch {
44
- $out = @{ schema = 1; ok = $false; parser = @{ edition = $edition; version = [string]$PSVersionTable.PSVersion }; tokenCount = 0; errors = @(@{ errorId = 'GUARD_HELPER_FAILURE'; start = 0; end = 0; message = (Clip $_.Exception.Message 256) }); commands = @(); dynamicConstructs = @(); stopParsingTokens = @() }
45
- [Console]::Out.Write((ConvertTo-Json $out -Compress -Depth 16))
46
- exit 2
47
- }
1
+ $ErrorActionPreference = 'Stop'
2
+ $maxInput = 131072
3
+ $maxTokens = 4096
4
+ $maxCommands = 128
5
+ $maxLiteral = 65536
6
+ $text = [Console]::In.ReadToEnd()
7
+ $edition = if ($PSVersionTable.PSEdition -eq 'Desktop') { 'Desktop' } else { 'Core' }
8
+ function Clip([string]$s, [int]$n = 512) { if ($null -eq $s) { return $null }; if ($s.Length -gt $n) { return $s.Substring(0, $n) }; return $s }
9
+ function Pos($e) { if ($null -eq $e) { return @{ start = 0; end = 0 } }; return @{ start = [int]$e.StartOffset; end = [int]$e.EndOffset } }
10
+ function DynamicNode($n) { return ($n -is [System.Management.Automation.Language.ScriptBlockExpressionAst] -or $n -is [System.Management.Automation.Language.SubExpressionAst] -or $n -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -or $n -is [System.Management.Automation.Language.ExpandableStringExpressionAst]) }
11
+ try {
12
+ if ($text.Length -gt $maxInput) { throw 'input limit exceeded' }
13
+ $tokens = $null; $errors = $null
14
+ $ast = [System.Management.Automation.Language.Parser]::ParseInput($text, [ref]$tokens, [ref]$errors)
15
+ $errorItems = @($errors | Select-Object -First 32 | ForEach-Object { $p = Pos $_.Extent; @{ errorId = Clip $_.ErrorId 96; start = $p.start; end = $p.end; message = Clip $_.Message 256 } })
16
+ $commands = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true) | ForEach-Object {
17
+ $command = $_; $p = Pos $command.Extent; $name = $command.GetCommandName()
18
+ $pipeline = $command.Parent; while ($null -ne $pipeline -and -not ($pipeline -is [System.Management.Automation.Language.PipelineAst])) { $pipeline = $pipeline.Parent }
19
+ $pipelineStart = if ($null -ne $pipeline) { [int]$pipeline.Extent.StartOffset } else { $null }
20
+ $elements = @($command.CommandElements | Select-Object -First 256 | ForEach-Object {
21
+ $ep = Pos $_.Extent; $literal = $null
22
+ $literalTruncated = $false; $elementDynamic = [bool](DynamicNode $_)
23
+ if ($_ -is [System.Management.Automation.Language.CommandParameterAst]) {
24
+ $rawLiteral = '-' + $_.ParameterName
25
+ if ($null -ne $_.Argument) {
26
+ if ($_.Argument -is [System.Management.Automation.Language.StringConstantExpressionAst] -or $_.Argument -is [System.Management.Automation.Language.ConstantExpressionAst]) { $rawLiteral += ':' + [string]$_.Argument.Value }
27
+ else { $elementDynamic = $true }
28
+ }
29
+ $literalTruncated = $rawLiteral.Length -gt $maxLiteral; $literal = Clip $rawLiteral $maxLiteral
30
+ } elseif ($_ -is [System.Management.Automation.Language.StringConstantExpressionAst] -or $_ -is [System.Management.Automation.Language.ConstantExpressionAst]) { $rawLiteral = [string]$_.Value; $literalTruncated = $rawLiteral.Length -gt $maxLiteral; $literal = Clip $rawLiteral $maxLiteral }
31
+ $rawExtent = [string]$_.Extent.Text; $rawTruncated = $rawExtent.Length -gt $maxLiteral
32
+ @{ astType = $_.GetType().Name; start = $ep.start; end = $ep.end; literal = $literal; literalTruncated = $literalTruncated; raw = (Clip $rawExtent $maxLiteral); rawTruncated = $rawTruncated; dynamic = $elementDynamic }
33
+ })
34
+ $reds = @($command.Redirections | Select-Object -First 128 | ForEach-Object { $rp = Pos $_.Extent; $target = $null; $targetTruncated = $false; if ($_.Location -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $rawTarget = [string]$_.Location.Value; $target = Clip $rawTarget 4096; $targetTruncated = $rawTarget.Length -gt 4096 }; @{ astType = $_.GetType().Name; start = $rp.start; end = $rp.end; targetLiteral = $target; targetTruncated = [bool]$targetTruncated; dynamic = $null -eq $target } })
35
+ @{ start = $p.start; end = $p.end; pipelineStart = $pipelineStart; commandName = $name; invocationOperator = ([string]$command.InvocationOperator); elements = $elements; elementsTruncated = ($command.CommandElements.Count -gt 256); redirections = $reds; redirectionsTruncated = ($command.Redirections.Count -gt 128) }
36
+ })
37
+ $dynamic = @($ast.FindAll({ param($n) DynamicNode $n }, $true) | Select-Object -First 256 | ForEach-Object { $p = Pos $_.Extent; @{ kind = $_.GetType().Name; start = $p.start; end = $p.end } })
38
+ $stop = @($tokens | Where-Object { ([string]$_.Kind -eq 'StopParsing') -or $_.Text -eq '--%' } | Select-Object -First 128 | ForEach-Object { $p = Pos $_.Extent; @{ start = $p.start; end = $p.end } })
39
+ $limit = $null; if ($tokens.Count -gt $maxTokens) { $limit = 'tokens' }; if ($commands.Count -gt $maxCommands) { $limit = 'commands' }; if (@($commands | Where-Object { $_.elementsTruncated }).Count -gt 0) { $limit = 'elements' }; if (@($commands | Where-Object { $_.redirectionsTruncated }).Count -gt 0) { $limit = 'redirections' }
40
+ $out = @{ schema = 1; ok = ($errorItems.Count -eq 0 -and $null -eq $limit); parser = @{ edition = $edition; version = [string]$PSVersionTable.PSVersion }; tokenCount = [int]$tokens.Count; errors = $errorItems; commands = @($commands | Select-Object -First $maxCommands); dynamicConstructs = $dynamic; stopParsingTokens = $stop }
41
+ if ($null -ne $limit) { $out.limitExceeded = $limit }
42
+ [Console]::Out.Write((ConvertTo-Json $out -Compress -Depth 16))
43
+ } catch {
44
+ $out = @{ schema = 1; ok = $false; parser = @{ edition = $edition; version = [string]$PSVersionTable.PSVersion }; tokenCount = 0; errors = @(@{ errorId = 'GUARD_HELPER_FAILURE'; start = 0; end = 0; message = (Clip $_.Exception.Message 256) }); commands = @(); dynamicConstructs = @(); stopParsingTokens = @() }
45
+ [Console]::Out.Write((ConvertTo-Json $out -Compress -Depth 16))
46
+ exit 2
47
+ }
@@ -18,6 +18,8 @@ import { createHash } from "node:crypto";
18
18
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
19
  import { Key, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
20
20
  import { describeSpecPhase, transformSpecMarkdown } from "./spec/core.mjs";
21
+ import { canonicalRoot } from "./workflow-controls/scope.mjs";
22
+ import { readTaskContract } from "./workflow-controls/task-contract.mjs";
21
23
 
22
24
  const specPiAgentDir = process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
23
25
  const specPiStateDir = path.join(specPiAgentDir, "specpi");
@@ -68,7 +70,17 @@ if (hasValidatedSpecPiTools()) {
68
70
 
69
71
  type SpecPhase = "ready" | "thinking" | "reasoning" | "synthesizing" | `using ${string}` | `${string} failed`;
70
72
 
73
+ interface SpecTaskState {
74
+ active: boolean;
75
+ objective: string;
76
+ proven: number;
77
+ total: number;
78
+ digest?: string;
79
+ error?: string;
80
+ }
81
+
71
82
  const SPEC_STATE_ENTRY = "spec-mode";
83
+ const CHALLENGE_ENTRY = "specpi-completion-challenge";
72
84
  const SPEC_SYSTEM_GUIDANCE = `
73
85
 
74
86
  [SPEC MODE — SPEC EXECUTION]
@@ -118,13 +130,164 @@ export default function (pi: ExtensionAPI) {
118
130
  let phase: SpecPhase = "ready";
119
131
  let turnCount = 0;
120
132
  let toolCount = 0;
121
- let workflowScope: { active: boolean; pending: number; indeterminate: boolean } = {
133
+ let workflowScope: {
134
+ active: boolean;
135
+ pending: number;
136
+ indeterminate: boolean;
137
+ taskBound: boolean;
138
+ taskStale: boolean;
139
+ } = {
122
140
  active: false,
123
141
  pending: 0,
124
142
  indeterminate: false,
143
+ taskBound: false,
144
+ taskStale: false,
125
145
  };
146
+ let workflowTask: SpecTaskState = {
147
+ active: false,
148
+ objective: "",
149
+ proven: 0,
150
+ total: 0,
151
+ };
152
+ let taskRefreshPending = true;
153
+ let taskRefreshGeneration = 0;
126
154
  let toolsWereExpanded: boolean | undefined;
127
155
  let requestSpecRender: (() => void) | undefined;
156
+ let lastContext: ExtensionContext | undefined;
157
+
158
+ const resolveRoot = async (cwd: string) => {
159
+ try {
160
+ const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: 15_000 });
161
+ if (result.code === 0 && typeof result.stdout === "string" && result.stdout.trim()) {
162
+ return canonicalRoot(path.resolve(cwd, result.stdout.trim()));
163
+ }
164
+ } catch {
165
+ /* A non-Git session can still display a task contract rooted at its current directory. */
166
+ }
167
+
168
+ return canonicalRoot(cwd);
169
+ };
170
+
171
+ const refreshTask = async (ctx: ExtensionContext, expectedGeneration = taskRefreshGeneration) => {
172
+ const cwd = ctx.cwd;
173
+ const sessionId = ctx.sessionManager.getSessionId();
174
+ const isCurrent = () =>
175
+ expectedGeneration === taskRefreshGeneration &&
176
+ cwd === ctx.cwd &&
177
+ sessionId === ctx.sessionManager.getSessionId();
178
+ const root = await resolveRoot(cwd);
179
+ if (!isCurrent()) {
180
+ return false;
181
+ }
182
+
183
+ let contract;
184
+ try {
185
+ contract = readTaskContract(ctx.sessionManager.getBranch?.() ?? [], root);
186
+ } catch (error) {
187
+ if (!isCurrent()) {
188
+ return false;
189
+ }
190
+
191
+ workflowTask = {
192
+ active: false,
193
+ objective: "",
194
+ proven: 0,
195
+ total: 0,
196
+ error:
197
+ error instanceof Error
198
+ ? error.message.replace(/[\u0000-\u001f\u007f]+/gu, " ").slice(0, 160)
199
+ : "unavailable",
200
+ };
201
+ taskRefreshPending = false;
202
+
203
+ return true;
204
+ }
205
+
206
+ if (!isCurrent()) {
207
+ return false;
208
+ }
209
+
210
+ if (!contract) {
211
+ workflowTask = { active: false, objective: "", proven: 0, total: 0 };
212
+ taskRefreshPending = false;
213
+
214
+ return true;
215
+ }
216
+
217
+ let latestReview;
218
+ for (const entry of ctx.sessionManager.getBranch?.() ?? []) {
219
+ if (entry.type !== "custom" || entry.customType !== CHALLENGE_ENTRY) {
220
+ continue;
221
+ }
222
+
223
+ if (entry.data?.kind === "result") {
224
+ latestReview = entry.data;
225
+ } else if (entry.data?.kind === "cleared") {
226
+ latestReview = undefined;
227
+ }
228
+ }
229
+
230
+ if (latestReview?.facts?.taskContractDigest !== contract.digest) {
231
+ latestReview = undefined;
232
+ }
233
+
234
+ const provenIds = new Set(
235
+ latestReview?.result?.requirements
236
+ ?.filter((item: any) => item?.status === "proven" && typeof item.id === "string")
237
+ .map((item: any) => item.id) ?? [],
238
+ );
239
+ workflowTask = {
240
+ active: true,
241
+ objective: contract.objective,
242
+ proven: contract.requirements.filter((item: any) => provenIds.has(item.id)).length,
243
+ total: contract.requirements.length,
244
+ digest: contract.digest,
245
+ };
246
+ taskRefreshPending = false;
247
+
248
+ return true;
249
+ };
250
+
251
+ const queueTaskRefresh = (ctx = lastContext) => {
252
+ taskRefreshPending = true;
253
+ const generation = ++taskRefreshGeneration;
254
+ if (!ctx) {
255
+ requestSpecRender?.();
256
+
257
+ return;
258
+ }
259
+
260
+ void refreshTask(ctx, generation)
261
+ .then((applied) => {
262
+ if (applied) {
263
+ requestSpecRender?.();
264
+ }
265
+ })
266
+ .catch(() => {
267
+ if (generation === taskRefreshGeneration) {
268
+ requestSpecRender?.();
269
+ }
270
+ });
271
+ requestSpecRender?.();
272
+ };
273
+
274
+ const taskStatusLabel = () => {
275
+ if (workflowTask.error) {
276
+ return "INVALID";
277
+ }
278
+
279
+ if (!workflowTask.active) {
280
+ return "UNSET";
281
+ }
282
+
283
+ return `${workflowTask.proven}/${workflowTask.total}`;
284
+ };
285
+
286
+ const taskObjectiveLabel = () => {
287
+ const objective = workflowTask.objective.replace(/[\u0000-\u001f\u007f]+/gu, " ").trim();
288
+
289
+ return objective.length > 42 ? `${objective.slice(0, 39)}...` : objective;
290
+ };
128
291
 
129
292
  const updateActivity = (ctx: ExtensionContext) => {
130
293
  if (!enabled) {
@@ -226,15 +389,18 @@ export default function (pi: ExtensionAPI) {
226
389
  const run = `${countLabel(turnCount, "T")} · ${countLabel(toolCount, "X")}`;
227
390
  const scope = !workflowScope.active
228
391
  ? "UNSET"
229
- : workflowScope.pending > 0 || workflowScope.indeterminate
392
+ : workflowScope.pending > 0 || workflowScope.indeterminate || workflowScope.taskStale
230
393
  ? "REVIEW"
231
394
  : "CLEAN";
395
+ const taskStatus = taskStatusLabel();
396
+ const taskObjective = taskObjectiveLabel() || "UNSET";
232
397
 
233
398
  if (width < 44) {
234
399
  return [
235
400
  renderEdge("┌", "┐", "─ ACTIVE SPEC ", width, (text) => theme.fg("borderAccent", text)),
236
401
  truncateToWidth(theme.fg("accent", phaseText), width, ""),
237
- truncateToWidth(theme.fg("dim", `${run} · SCOPE ${scope} · OUTPUT HELD`), width, ""),
402
+ truncateToWidth(theme.fg("dim", `${run} · TASK ${taskStatus} · SCOPE ${scope}`), width, ""),
403
+ truncateToWidth(theme.fg("muted", taskObjective), width, ""),
238
404
  renderEdge("└", "┘", "─ /spec exits ", width, (text) => theme.fg("borderMuted", text)),
239
405
  ];
240
406
  }
@@ -252,6 +418,7 @@ export default function (pi: ExtensionAPI) {
252
418
  theme.fg("muted", "OUTPUT RESPONSE HELD · TOOLS COLLAPSED"),
253
419
  theme.fg("dim", `SCOPE ${scope}`),
254
420
  ),
421
+ row(theme.fg("muted", `TASK ${taskObjective}`), theme.fg("dim", `REQ ${taskStatus}`)),
255
422
  renderEdge("└", "┘", "─ CTRL+ALT+Z / EXIT SPEC MODE ", width, (text) =>
256
423
  theme.fg("borderMuted", text),
257
424
  ),
@@ -310,12 +477,26 @@ export default function (pi: ExtensionAPI) {
310
477
 
311
478
  pi.registerMarkdownTransformer((markdown, context) => transformSpecMarkdown(markdown, context, enabled));
312
479
 
313
- pi.on("session_start", async (_event, ctx) => {
480
+ const restoreSession = async (ctx: ExtensionContext) => {
481
+ lastContext = ctx;
482
+ const generation = ++taskRefreshGeneration;
483
+ if (enabled) {
484
+ clearMode(ctx);
485
+ }
486
+
314
487
  enabled = false;
315
488
  phase = "ready";
316
489
  turnCount = 0;
317
490
  toolCount = 0;
318
- workflowScope = { active: false, pending: 0, indeterminate: false };
491
+ workflowScope = {
492
+ active: false,
493
+ pending: 0,
494
+ indeterminate: false,
495
+ taskBound: false,
496
+ taskStale: false,
497
+ };
498
+ workflowTask = { active: false, objective: "", proven: 0, total: 0 };
499
+ taskRefreshPending = true;
319
500
  toolsWereExpanded = undefined;
320
501
  requestSpecRender = undefined;
321
502
 
@@ -330,18 +511,41 @@ export default function (pi: ExtensionAPI) {
330
511
  if (enabled) {
331
512
  applyMode(ctx);
332
513
  }
333
- });
514
+
515
+ if (await refreshTask(ctx, generation)) {
516
+ requestSpecRender?.();
517
+ }
518
+ };
519
+
520
+ pi.on("session_start", (_event, ctx) => restoreSession(ctx));
521
+ pi.on("session_tree", (_event, ctx) => restoreSession(ctx));
334
522
 
335
523
  pi.events.on("specpi:workflow-status", (state: any) => {
336
524
  workflowScope = {
337
525
  active: state?.active === true,
338
526
  pending: Number.isInteger(state?.pending) ? Math.max(0, state.pending) : 0,
339
527
  indeterminate: state?.indeterminate === true,
528
+ taskBound: state?.taskBound === true,
529
+ taskStale: state?.taskStale === true,
340
530
  };
341
- requestSpecRender?.();
531
+ if (state?.taskBound === true || state?.taskReviewChanged === true) {
532
+ // Scope status is also the notification used when a challenge result changes the proven requirement
533
+ // count. Refresh from the branch so the panel follows that result immediately while generation checks
534
+ // prevent a delayed root lookup from overwriting a newer session.
535
+ queueTaskRefresh();
536
+ } else {
537
+ taskRefreshPending = true;
538
+ requestSpecRender?.();
539
+ }
540
+ });
541
+
542
+ pi.events.on("specpi:task-contract-changed", () => {
543
+ queueTaskRefresh();
342
544
  });
343
545
 
344
546
  pi.on("session_shutdown", (_event, ctx) => {
547
+ taskRefreshGeneration += 1;
548
+ lastContext = undefined;
345
549
  if (enabled) {
346
550
  clearMode(ctx);
347
551
  } else {
@@ -397,12 +601,28 @@ export default function (pi: ExtensionAPI) {
397
601
  updateActivity(ctx);
398
602
  });
399
603
 
400
- pi.on("before_agent_start", async (event) => {
604
+ pi.on("before_agent_start", async (event, ctx) => {
605
+ lastContext = ctx;
606
+ if (taskRefreshPending || enabled) {
607
+ const generation = ++taskRefreshGeneration;
608
+ if (!(await refreshTask(ctx, generation))) {
609
+ return;
610
+ }
611
+
612
+ requestSpecRender?.();
613
+ }
614
+
401
615
  if (!enabled || event.systemPrompt.includes("[SPEC MODE — SPEC EXECUTION]")) {
402
616
  return;
403
617
  }
404
618
 
405
- return { systemPrompt: `${event.systemPrompt}${SPEC_SYSTEM_GUIDANCE}` };
619
+ const taskGuidance = workflowTask.active
620
+ ? `\n\n[SPEC TASK]\nObjective: ${taskObjectiveLabel()}\nFixed requirements proven: ${workflowTask.proven}/${workflowTask.total}. Assess the original requirement set by its stable IDs.`
621
+ : workflowTask.error
622
+ ? `\n\n[SPEC TASK]\nTask contract unavailable: ${workflowTask.error}`
623
+ : "";
624
+
625
+ return { systemPrompt: `${event.systemPrompt}${SPEC_SYSTEM_GUIDANCE}${taskGuidance}` };
406
626
  });
407
627
 
408
628
  pi.registerCommand("spec", {
@@ -414,6 +634,7 @@ export default function (pi: ExtensionAPI) {
414
634
  return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
415
635
  },
416
636
  handler: async (args, ctx) => {
637
+ lastContext = ctx;
417
638
  const action = args.trim().toLowerCase();
418
639
  if (!action) {
419
640
  toggleMode(ctx);
@@ -434,11 +655,16 @@ export default function (pi: ExtensionAPI) {
434
655
  }
435
656
 
436
657
  if (action === "status") {
658
+ const generation = ++taskRefreshGeneration;
659
+ if (!(await refreshTask(ctx, generation))) {
660
+ return;
661
+ }
662
+
437
663
  const state = describeSpecPhase(phase);
438
664
  const detail = state.detail ? ` · ${state.detail}` : "";
439
665
  ctx.ui.notify(
440
666
  enabled
441
- ? `SPEC MODE / ACTIVE · ${state.index} / ${state.label}${detail} · ${countLabel(turnCount, "T")} · ${countLabel(toolCount, "X")}`
667
+ ? `SPEC MODE / ACTIVE · ${state.index} / ${state.label}${detail} · ${countLabel(turnCount, "T")} · ${countLabel(toolCount, "X")} · task: ${taskObjectiveLabel() || "unset"} · requirements: ${taskStatusLabel()}`
442
668
  : "Spec mode is off.",
443
669
  "info",
444
670
  );